diff --git a/furumi-node-player/client/package-lock.json b/furumi-node-player/client/package-lock.json
index 3ebe2c3..6286599 100644
--- a/furumi-node-player/client/package-lock.json
+++ b/furumi-node-player/client/package-lock.json
@@ -8,9 +8,11 @@
"name": "client",
"version": "0.0.0",
"dependencies": {
+ "@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4",
- "react-dom": "^19.2.4"
+ "react-dom": "^19.2.4",
+ "react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -587,6 +589,32 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
@@ -849,6 +877,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -888,7 +928,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -904,6 +944,12 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@@ -1496,7 +1542,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2127,6 +2173,16 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "11.1.4",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
+ "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2831,6 +2887,50 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3094,6 +3194,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
diff --git a/furumi-node-player/client/package.json b/furumi-node-player/client/package.json
index d608129..9d6d481 100644
--- a/furumi-node-player/client/package.json
+++ b/furumi-node-player/client/package.json
@@ -10,9 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
+ "@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4",
- "react-dom": "^19.2.4"
+ "react-dom": "^19.2.4",
+ "react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
diff --git a/furumi-node-player/client/src/main.tsx b/furumi-node-player/client/src/main.tsx
index bef5202..9d4c1bf 100644
--- a/furumi-node-player/client/src/main.tsx
+++ b/furumi-node-player/client/src/main.tsx
@@ -1,10 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import { Provider } from 'react-redux'
+import { store } from './store'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
)
diff --git a/furumi-node-player/client/src/store/index.ts b/furumi-node-player/client/src/store/index.ts
new file mode 100644
index 0000000..c10bc40
--- /dev/null
+++ b/furumi-node-player/client/src/store/index.ts
@@ -0,0 +1,21 @@
+import { configureStore } from '@reduxjs/toolkit'
+import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
+import artistsReducer from './slices/artistsSlice'
+import albumsReducer from './slices/albumsSlice'
+import albumTracksReducer from './slices/albumTracksSlice'
+import trackDetailReducer from './slices/trackDetailSlice'
+
+export const store = configureStore({
+ reducer: {
+ artists: artistsReducer,
+ albums: albumsReducer,
+ albumTracks: albumTracksReducer,
+ trackDetail: trackDetailReducer,
+ },
+})
+
+export type RootState = ReturnType
+export type AppDispatch = typeof store.dispatch
+
+export const useAppDispatch = useDispatch.withTypes()
+export const useAppSelector: TypedUseSelectorHook = useSelector
diff --git a/furumi-node-player/client/src/store/slices/albumTracksSlice.ts b/furumi-node-player/client/src/store/slices/albumTracksSlice.ts
new file mode 100644
index 0000000..8ede869
--- /dev/null
+++ b/furumi-node-player/client/src/store/slices/albumTracksSlice.ts
@@ -0,0 +1,54 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
+import type { Track } from '../../types'
+import { getAlbumTracks } from '../../furumiApi'
+
+export const fetchAlbumTracks = createAsyncThunk(
+ 'albumTracks/fetch',
+ async (albumSlug: string, { rejectWithValue }) => {
+ const data = await getAlbumTracks(albumSlug)
+ if (data === null) return rejectWithValue('Failed to fetch album tracks')
+ return { albumSlug, tracks: data }
+ },
+)
+
+interface AlbumTracksState {
+ byAlbum: Record
+ loading: boolean
+ error: string | null
+}
+
+const initialState: AlbumTracksState = {
+ byAlbum: {},
+ loading: false,
+ error: null,
+}
+
+const albumTracksSlice = createSlice({
+ name: 'albumTracks',
+ initialState,
+ reducers: {
+ clearAlbumTracks(state) {
+ state.byAlbum = {}
+ state.error = null
+ },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchAlbumTracks.pending, (state) => {
+ state.loading = true
+ state.error = null
+ })
+ .addCase(fetchAlbumTracks.fulfilled, (state, action) => {
+ state.loading = false
+ state.byAlbum[action.payload.albumSlug] = action.payload.tracks
+ state.error = null
+ })
+ .addCase(fetchAlbumTracks.rejected, (state, action) => {
+ state.loading = false
+ state.error = action.payload as string ?? 'Unknown error'
+ })
+ },
+})
+
+export const { clearAlbumTracks } = albumTracksSlice.actions
+export default albumTracksSlice.reducer
diff --git a/furumi-node-player/client/src/store/slices/albumsSlice.ts b/furumi-node-player/client/src/store/slices/albumsSlice.ts
new file mode 100644
index 0000000..386c3f5
--- /dev/null
+++ b/furumi-node-player/client/src/store/slices/albumsSlice.ts
@@ -0,0 +1,54 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
+import type { Album } from '../../types'
+import { getArtistAlbums } from '../../furumiApi'
+
+export const fetchArtistAlbums = createAsyncThunk(
+ 'albums/fetchByArtist',
+ async (artistSlug: string, { rejectWithValue }) => {
+ const data = await getArtistAlbums(artistSlug)
+ if (data === null) return rejectWithValue('Failed to fetch albums')
+ return { artistSlug, albums: data }
+ },
+)
+
+interface AlbumsState {
+ byArtist: Record
+ loading: boolean
+ error: string | null
+}
+
+const initialState: AlbumsState = {
+ byArtist: {},
+ loading: false,
+ error: null,
+}
+
+const albumsSlice = createSlice({
+ name: 'albums',
+ initialState,
+ reducers: {
+ clearAlbums(state) {
+ state.byArtist = {}
+ state.error = null
+ },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchArtistAlbums.pending, (state) => {
+ state.loading = true
+ state.error = null
+ })
+ .addCase(fetchArtistAlbums.fulfilled, (state, action) => {
+ state.loading = false
+ state.byArtist[action.payload.artistSlug] = action.payload.albums
+ state.error = null
+ })
+ .addCase(fetchArtistAlbums.rejected, (state, action) => {
+ state.loading = false
+ state.error = action.payload as string ?? 'Unknown error'
+ })
+ },
+})
+
+export const { clearAlbums } = albumsSlice.actions
+export default albumsSlice.reducer
diff --git a/furumi-node-player/client/src/store/slices/artistsSlice.ts b/furumi-node-player/client/src/store/slices/artistsSlice.ts
new file mode 100644
index 0000000..ecef76a
--- /dev/null
+++ b/furumi-node-player/client/src/store/slices/artistsSlice.ts
@@ -0,0 +1,54 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
+import type { Artist } from '../../types'
+import { getArtists } from '../../furumiApi'
+
+export const fetchArtists = createAsyncThunk(
+ 'artists/fetch',
+ async (_, { rejectWithValue }) => {
+ const data = await getArtists()
+ if (data === null) return rejectWithValue('Failed to fetch artists')
+ return data
+ },
+)
+
+interface ArtistsState {
+ items: Artist[]
+ loading: boolean
+ error: string | null
+}
+
+const initialState: ArtistsState = {
+ items: [],
+ loading: false,
+ error: null,
+}
+
+const artistsSlice = createSlice({
+ name: 'artists',
+ initialState,
+ reducers: {
+ clearArtists(state) {
+ state.items = []
+ state.error = null
+ },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchArtists.pending, (state) => {
+ state.loading = true
+ state.error = null
+ })
+ .addCase(fetchArtists.fulfilled, (state, action) => {
+ state.loading = false
+ state.items = action.payload
+ state.error = null
+ })
+ .addCase(fetchArtists.rejected, (state, action) => {
+ state.loading = false
+ state.error = action.payload as string ?? 'Unknown error'
+ })
+ },
+})
+
+export const { clearArtists } = artistsSlice.actions
+export default artistsSlice.reducer
diff --git a/furumi-node-player/client/src/store/slices/trackDetailSlice.ts b/furumi-node-player/client/src/store/slices/trackDetailSlice.ts
new file mode 100644
index 0000000..60a852f
--- /dev/null
+++ b/furumi-node-player/client/src/store/slices/trackDetailSlice.ts
@@ -0,0 +1,57 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
+import type { TrackDetail } from '../../types'
+import { getTrackInfo } from '../../furumiApi'
+
+export const fetchTrackDetail = createAsyncThunk(
+ 'trackDetail/fetch',
+ async (trackSlug: string, { rejectWithValue }) => {
+ const data = await getTrackInfo(trackSlug)
+ if (data === null) return rejectWithValue('Failed to fetch track detail')
+ return { trackSlug, detail: data }
+ },
+)
+
+interface TrackDetailState {
+ bySlug: Record
+ loading: boolean
+ error: string | null
+}
+
+const initialState: TrackDetailState = {
+ bySlug: {},
+ loading: false,
+ error: null,
+}
+
+const trackDetailSlice = createSlice({
+ name: 'trackDetail',
+ initialState,
+ reducers: {
+ clearTrackDetail(state) {
+ state.bySlug = {}
+ state.error = null
+ },
+ removeTrackDetail(state, action: { payload: string }) {
+ delete state.bySlug[action.payload]
+ },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchTrackDetail.pending, (state) => {
+ state.loading = true
+ state.error = null
+ })
+ .addCase(fetchTrackDetail.fulfilled, (state, action) => {
+ state.loading = false
+ state.bySlug[action.payload.trackSlug] = action.payload.detail
+ state.error = null
+ })
+ .addCase(fetchTrackDetail.rejected, (state, action) => {
+ state.loading = false
+ state.error = action.payload as string ?? 'Unknown error'
+ })
+ },
+})
+
+export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions
+export default trackDetailSlice.reducer