diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css
new file mode 100644
index 0000000..3777ab4
--- /dev/null
+++ b/furumi-node-player/client/src/components/header/header.module.css
@@ -0,0 +1,35 @@
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1.5rem;
+ background: var(--bg-panel);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ z-index: 10;
+}
+
+.headerLogo {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-weight: 700;
+ font-size: 1.1rem;
+ color: #ffffff;
+}
+
+.headerLogo svg {
+ width: 22px;
+ height: 22px;
+}
+
+.headerVersion {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ background: rgba(255, 255, 255, 0.05);
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ margin-left: 0.25rem;
+ font-weight: 500;
+ text-decoration: none;
+}
\ No newline at end of file
diff --git a/furumi-node-player/client/src/components/header/index.ts b/furumi-node-player/client/src/components/header/index.ts
new file mode 100644
index 0000000..220d1b1
--- /dev/null
+++ b/furumi-node-player/client/src/components/header/index.ts
@@ -0,0 +1 @@
+export * from './Header'
\ No newline at end of file
diff --git a/furumi-node-player/client/src/components/queue-popover/index.ts b/furumi-node-player/client/src/components/queue-popover/index.ts
new file mode 100644
index 0000000..e2aa02a
--- /dev/null
+++ b/furumi-node-player/client/src/components/queue-popover/index.ts
@@ -0,0 +1 @@
+export * from './queue-popover'
diff --git a/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css b/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css
new file mode 100644
index 0000000..d16d59f
--- /dev/null
+++ b/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css
@@ -0,0 +1,68 @@
+.root {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.trigger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.35rem;
+ margin: 0;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ font: inherit;
+ line-height: 1;
+}
+
+.trigger:hover {
+ color: var(--text);
+ background: var(--bg-hover);
+}
+
+.triggerIcon {
+ font-size: 0.95rem;
+}
+
+.popover {
+ position: absolute;
+ bottom: calc(100% + 0.5rem);
+ right: 0;
+ z-index: 60;
+ display: flex;
+ flex-direction: column;
+ min-width: min(100vw - 2rem, 320px);
+ max-width: min(100vw - 2rem, 360px);
+ max-height: min(50vh, 360px);
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--bg-card);
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
+}
+
+.header {
+ flex-shrink: 0;
+ padding: 0.55rem 0.75rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+}
+
+.body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 0.35rem 0.5rem;
+}
+
+.body :global(.queue-empty) {
+ padding: 1.25rem 0.75rem;
+ font-size: 0.8rem;
+}
diff --git a/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx b/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx
new file mode 100644
index 0000000..40eb69a
--- /dev/null
+++ b/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx
@@ -0,0 +1,86 @@
+import { useEffect, useId, useRef, useState } from 'react'
+import { QueueList, type QueueItem } from '../QueueList'
+import styles from './queue-popover.module.css'
+
+export type QueuePopoverProps = {
+ queue: QueueItem[]
+ order: number[]
+ playingOrigIdx: number
+ scrollSignal: number
+ onPlay: (origIdx: number) => void
+ onRemove: (origIdx: number) => void
+ onMove: (fromPos: number, toPos: number) => void
+}
+
+export function QueuePopover({
+ queue,
+ order,
+ playingOrigIdx,
+ scrollSignal,
+ onPlay,
+ onRemove,
+ onMove,
+}: QueuePopoverProps) {
+ const [open, setOpen] = useState(false)
+ const rootRef = useRef
(null)
+ const titleId = useId()
+ const panelId = useId()
+
+ useEffect(() => {
+ if (!open) return
+ function onDocMouseDown(e: MouseEvent) {
+ const el = rootRef.current
+ if (el && !el.contains(e.target as Node)) setOpen(false)
+ }
+ function onKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('mousedown', onDocMouseDown)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('mousedown', onDocMouseDown)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [open])
+
+ return (
+
+
+ {open && (
+
+ )}
+
+ )
+}
diff --git a/furumi-node-player/client/src/furumi-player.css b/furumi-node-player/client/src/furumi-player.css
index 12c80f1..01adf74 100644
--- a/furumi-node-player/client/src/furumi-player.css
+++ b/furumi-node-player/client/src/furumi-player.css
@@ -31,40 +31,6 @@
--danger: #f87171;
}
-.header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.75rem 1.5rem;
- background: var(--bg-panel);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
- z-index: 10;
-}
-
-.header-logo {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- font-weight: 700;
- font-size: 1.1rem;
-}
-
-.header-logo svg {
- width: 22px;
- height: 22px;
-}
-
-.header-version {
- font-size: 0.7rem;
- color: var(--text-muted);
- background: rgba(255, 255, 255, 0.05);
- padding: 0.1rem 0.4rem;
- border-radius: 4px;
- margin-left: 0.25rem;
- font-weight: 500;
- text-decoration: none;
-}
.btn-menu {
display: none;
@@ -378,6 +344,10 @@
color: var(--accent);
}
+.qi-title {
+ color: #ffffff;
+}
+
.queue-item .qi-index {
font-size: 0.75rem;
color: var(--text-muted);
@@ -531,6 +501,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ color: #ffffff;
}
.np-artist {
diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts
index e7518ec..176166a 100644
--- a/furumi-node-player/client/src/furumiApi.ts
+++ b/furumi-node-player/client/src/furumiApi.ts
@@ -1,16 +1,21 @@
import axios from 'axios'
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
-const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
-export const API_ROOT = `${API_BASE}/api`
-
-const API_KEY = import.meta.env.VITE_API_KEY
+const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? ''
+export const API_ROOT = `${FURUMI_API_BASE}/api`
export const furumiApi = axios.create({
baseURL: API_ROOT,
- headers: API_KEY ? { 'x-api-key': API_KEY } : {},
})
+export function setAuthToken(token: string) {
+ furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
+}
+
+export function clearAuthToken() {
+ delete furumiApi.defaults.headers.common['Authorization']
+}
+
export async function getArtists(): Promise {
const res = await furumiApi.get('/artists').catch(() => null)
return res?.data ?? null
@@ -44,6 +49,6 @@ export async function getTrackInfo(trackSlug: string): Promise null)
+ return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
}
diff --git a/furumi-node-player/client/src/store/index.ts b/furumi-node-player/client/src/store/index.ts
index 49b5321..37ff304 100644
--- a/furumi-node-player/client/src/store/index.ts
+++ b/furumi-node-player/client/src/store/index.ts
@@ -5,6 +5,7 @@ import albumsReducer from './slices/albumsSlice'
import albumTracksReducer from './slices/albumTracksSlice'
import artistTracksReducer from './slices/artistTracksSlice'
import trackDetailReducer from './slices/trackDetailSlice'
+import queueReducer from './slices/queueSlice'
export const store = configureStore({
reducer: {
@@ -13,6 +14,7 @@ export const store = configureStore({
albumTracks: albumTracksReducer,
artistTracks: artistTracksReducer,
trackDetail: trackDetailReducer,
+ queue: queueReducer,
},
})
diff --git a/furumi-node-player/client/src/store/slices/queueSlice.ts b/furumi-node-player/client/src/store/slices/queueSlice.ts
new file mode 100644
index 0000000..09c5b0e
--- /dev/null
+++ b/furumi-node-player/client/src/store/slices/queueSlice.ts
@@ -0,0 +1,274 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
+import type { QueueItem } from '../../components/QueueList'
+
+export interface QueueState {
+ items: QueueItem[]
+ currentIndex: number
+ shuffle: boolean
+ repeatAll: boolean
+ shuffleOrder: number[]
+ scrollSignal: number
+}
+
+function readShufflePref(): boolean {
+ try {
+ return window.localStorage.getItem('furumi_shuffle') === '1'
+ } catch {
+ return false
+ }
+}
+
+function readRepeatPref(): boolean {
+ try {
+ return window.localStorage.getItem('furumi_repeat') !== '0'
+ } catch {
+ return true
+ }
+}
+
+function buildShuffleOrder(state: QueueState) {
+ const n = state.items.length
+ if (n === 0) {
+ state.shuffleOrder = []
+ return
+ }
+ const order = [...Array(n).keys()]
+ for (let i = order.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ ;[order[i], order[j]] = [order[j], order[i]]
+ }
+ if (state.currentIndex !== -1) {
+ const ci = order.indexOf(state.currentIndex)
+ if (ci > 0) {
+ order.splice(ci, 1)
+ order.unshift(state.currentIndex)
+ }
+ }
+ state.shuffleOrder = order
+}
+
+function ensureShuffleOrder(state: QueueState) {
+ if (!state.shuffle) return
+ if (state.shuffleOrder.length !== state.items.length) {
+ buildShuffleOrder(state)
+ }
+}
+
+const initialState: QueueState = {
+ items: [],
+ currentIndex: -1,
+ shuffle: typeof window !== 'undefined' ? readShufflePref() : false,
+ repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true,
+ shuffleOrder: [],
+ scrollSignal: 0,
+}
+
+const queueSlice = createSlice({
+ name: 'queue',
+ initialState,
+ reducers: {
+ addTrack(
+ state,
+ action: PayloadAction<{
+ track: QueueItem
+ playNow?: boolean
+ }>,
+ ) {
+ const { track, playNow } = action.payload
+ const existing = state.items.findIndex((t) => t.slug === track.slug)
+ if (existing !== -1) {
+ if (playNow) {
+ state.currentIndex = existing
+ state.scrollSignal += 1
+ }
+ return
+ }
+ const oldLen = state.items.length
+ const idle = state.currentIndex === -1
+ state.items.push(track)
+ ensureShuffleOrder(state)
+ if (playNow || (oldLen === 0 && idle)) {
+ state.currentIndex = state.items.length - 1
+ state.scrollSignal += 1
+ }
+ },
+
+ addTracksBatch(
+ state,
+ action: PayloadAction<{
+ tracks: QueueItem[]
+ playFirst?: boolean
+ }>,
+ ) {
+ const { tracks, playFirst } = action.payload
+ let firstNewIdx: number | null = null
+ for (const t of tracks) {
+ if (state.items.some((q) => q.slug === t.slug)) continue
+ if (firstNewIdx === null) firstNewIdx = state.items.length
+ state.items.push(t)
+ }
+ ensureShuffleOrder(state)
+ if (firstNewIdx === null) return
+ if (playFirst || state.currentIndex === -1) {
+ state.currentIndex = firstNewIdx
+ state.scrollSignal += 1
+ }
+ },
+
+ replaceQueue(
+ state,
+ action: PayloadAction<{
+ items: QueueItem[]
+ playFromIndex?: number
+ }>,
+ ) {
+ const { items, playFromIndex = 0 } = action.payload
+ state.items = items
+ state.currentIndex =
+ items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1
+ state.shuffleOrder = []
+ ensureShuffleOrder(state)
+ },
+
+ clearQueue(state) {
+ state.items = []
+ state.currentIndex = -1
+ state.shuffleOrder = []
+ state.scrollSignal += 1
+ },
+
+ playAtIndex(state, action: PayloadAction) {
+ const i = action.payload
+ if (i < 0 || i >= state.items.length) return
+ state.currentIndex = i
+ state.scrollSignal += 1
+ },
+
+ removeFromQueueAt(state, action: PayloadAction) {
+ const idx = action.payload
+ if (idx < 0 || idx >= state.items.length) return
+
+ if (idx === state.currentIndex) {
+ state.currentIndex = -1
+ } else if (state.currentIndex > idx) {
+ state.currentIndex -= 1
+ }
+
+ state.items.splice(idx, 1)
+
+ if (state.shuffle) {
+ const si = state.shuffleOrder.indexOf(idx)
+ if (si !== -1) state.shuffleOrder.splice(si, 1)
+ for (let i = 0; i < state.shuffleOrder.length; i++) {
+ if (state.shuffleOrder[i] > idx) state.shuffleOrder[i] -= 1
+ }
+ }
+
+ ensureShuffleOrder(state)
+ },
+
+ moveQueueItemInOrder(
+ state,
+ action: PayloadAction<{ fromPos: number; toPos: number }>,
+ ) {
+ const { fromPos, toPos } = action.payload
+ if (fromPos === toPos) return
+
+ if (state.shuffle) {
+ const order = state.shuffleOrder
+ if (fromPos < 0 || fromPos >= order.length) return
+ if (toPos < 0 || toPos >= order.length) return
+ const item = order.splice(fromPos, 1)[0]
+ order.splice(toPos, 0, item)
+ return
+ }
+
+ const items = state.items
+ if (fromPos < 0 || fromPos >= items.length) return
+ if (toPos < 0 || toPos >= items.length) return
+ const qIdx = state.currentIndex
+ const item = items.splice(fromPos, 1)[0]
+ items.splice(toPos, 0, item)
+ if (qIdx === fromPos) state.currentIndex = toPos
+ else if (fromPos < qIdx && toPos >= qIdx) state.currentIndex -= 1
+ else if (fromPos > qIdx && toPos <= qIdx) state.currentIndex += 1
+ },
+
+ toggleShuffle(state) {
+ state.shuffle = !state.shuffle
+ try {
+ window.localStorage.setItem('furumi_shuffle', state.shuffle ? '1' : '0')
+ } catch {
+ // ignore
+ }
+ if (state.shuffle) buildShuffleOrder(state)
+ else state.shuffleOrder = []
+ },
+
+ toggleRepeat(state) {
+ state.repeatAll = !state.repeatAll
+ try {
+ window.localStorage.setItem('furumi_repeat', state.repeatAll ? '1' : '0')
+ } catch {
+ // ignore
+ }
+ },
+
+ rebuildShuffleOrder(state) {
+ if (state.shuffle) buildShuffleOrder(state)
+ },
+ },
+})
+
+export const {
+ addTrack,
+ addTracksBatch,
+ replaceQueue,
+ clearQueue,
+ playAtIndex,
+ removeFromQueueAt,
+ moveQueueItemInOrder,
+ toggleShuffle,
+ toggleRepeat,
+ rebuildShuffleOrder,
+} = queueSlice.actions
+
+type QueueSliceRoot = { queue: QueueState }
+
+export function selectQueueItems(state: QueueSliceRoot) {
+ return state.queue.items
+}
+
+// TODO: toggle shuffle should rebuild the shuffle order
+export function selectQueueOrder(state: QueueSliceRoot): number[] {
+ const q = state.queue
+ if (!q.shuffle) return q.items.map((_, i) => i)
+ if (q.shuffleOrder.length !== q.items.length) {
+ return q.items.map((_, i) => i)
+ }
+ return q.shuffleOrder
+}
+
+export function selectPlayingOrigIdx(state: QueueSliceRoot) {
+ return state.queue.currentIndex
+}
+
+export function selectQueueScrollSignal(state: QueueSliceRoot) {
+ return state.queue.scrollSignal
+}
+
+export function selectNowPlayingTrack(state: QueueSliceRoot): QueueItem | null {
+ const q = state.queue
+ if (q.currentIndex < 0 || q.currentIndex >= q.items.length) return null
+ return q.items[q.currentIndex]
+}
+
+export function selectShuffle(state: QueueSliceRoot) {
+ return state.queue.shuffle
+}
+
+export function selectRepeatAll(state: QueueSliceRoot) {
+ return state.queue.repeatAll
+}
+
+export default queueSlice.reducer
diff --git a/furumi-node-player/client/vite.config.ts b/furumi-node-player/client/vite.config.ts
index ab8ee06..58fa2c9 100644
--- a/furumi-node-player/client/vite.config.ts
+++ b/furumi-node-player/client/vite.config.ts
@@ -6,7 +6,11 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
- '/api': {
+ '/auth': {
+ target: 'http://localhost:3001',
+ changeOrigin: true,
+ },
+ '/callback': {
target: 'http://localhost:3001',
changeOrigin: true,
},
diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts
index 59622ad..93f7f29 100644
--- a/furumi-node-player/server/src/index.ts
+++ b/furumi-node-player/server/src/index.ts
@@ -1,5 +1,6 @@
import 'dotenv/config';
+import path from 'path';
import cors from 'cors';
import express from 'express';
import { auth } from 'express-openid-connect';
@@ -28,7 +29,6 @@ const oidcConfig = {
};
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
- // Keep a clear startup failure if OIDC is not configured.
throw new Error(
'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)',
);
@@ -46,11 +46,11 @@ if (!disableAuth) {
app.use(auth(oidcConfig));
}
-app.get('/api/health', (_req, res) => {
+app.get('/auth/health', (_req, res) => {
res.json({ ok: true });
});
-app.get('/api/me', (req, res) => {
+app.get('/auth/me', (req, res) => {
if (disableAuth) {
res.json({
authenticated: false,
@@ -74,7 +74,32 @@ app.get('/api/me', (req, res) => {
});
});
-app.get('/api/login', (req, res) => {
+app.get('/auth/token', (req, res) => {
+ if (disableAuth) {
+ res.status(204).end();
+ return;
+ }
+
+ if (!req.oidc.isAuthenticated()) {
+ res.status(401).json({ authenticated: false });
+ return;
+ }
+
+ const accessToken = req.oidc.accessToken?.access_token;
+ const expiresAt = req.oidc.accessToken?.expires_at;
+ if (!accessToken) {
+ res.status(500).json({ error: 'no access token in session' });
+ return;
+ }
+
+ res.json({
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_at: expiresAt,
+ });
+});
+
+app.get('/auth/login', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
@@ -85,7 +110,7 @@ app.get('/api/login', (req, res) => {
});
});
-app.get('/api/logout', (req, res) => {
+app.get('/auth/logout', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
@@ -96,6 +121,13 @@ app.get('/api/logout', (req, res) => {
});
});
+// Production: serve Vite-built client as static files
+const clientDist = path.resolve(import.meta.dirname, '../../client/dist');
+app.use(express.static(clientDist));
+app.get('*', (_req, res) => {
+ res.sendFile(path.join(clientDist, 'index.html'));
+});
+
app.listen(port, () => {
console.log(
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml
index 822300c..6638f38 100644
--- a/furumi-web-player/Cargo.toml
+++ b/furumi-web-player/Cargo.toml
@@ -18,7 +18,8 @@ mime_guess = "2.0"
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
tokio-util = { version = "0.7", features = ["io"] }
openidconnect = "3.4"
-reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
+jsonwebtoken = "9"
sha2 = "0.10"
hmac = "0.12"
base64 = "0.22"
diff --git a/furumi-web-player/src/main.rs b/furumi-web-player/src/main.rs
index b8a8592..f95c39b 100644
--- a/furumi-web-player/src/main.rs
+++ b/furumi-web-player/src/main.rs
@@ -40,9 +40,6 @@ struct Args {
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
oidc_session_secret: Option,
- /// API key for x-api-key header auth (alternative to OIDC session)
- #[arg(long, env = "FURUMI_PLAYER_API_KEY")]
- api_key: Option,
}
#[tokio::main]
@@ -94,15 +91,10 @@ async fn main() -> Result<(), Box> {
std::process::exit(1);
});
- if args.api_key.is_some() {
- tracing::info!("x-api-key auth: enabled");
- }
-
let state = Arc::new(web::AppState {
pool,
storage_dir: Arc::new(args.storage_dir),
oidc: oidc_state,
- api_key: args.api_key,
});
tracing::info!("Web player: http://{}", bind_addr);
diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs
index 33f8184..98574f7 100644
--- a/furumi-web-player/src/web/auth.rs
+++ b/furumi-web-player/src/web/auth.rs
@@ -3,10 +3,9 @@ use axum::{
extract::{Request, State},
http::{header, HeaderMap, StatusCode},
middleware::Next,
- response::{Html, IntoResponse, Redirect, Response},
+ response::{IntoResponse, Redirect, Response},
};
-const X_API_KEY: &str = "x-api-key";
use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client,
@@ -18,17 +17,26 @@ use serde::Deserialize;
use base64::Engine;
use hmac::{Hmac, Mac};
+use jsonwebtoken::{decode, decode_header, DecodingKey, Validation as JwtValidation};
+use jsonwebtoken::jwk::JwkSet;
+use std::time::{Duration, Instant};
+use tokio::sync::RwLock;
use super::AppState;
use std::sync::Arc;
const SESSION_COOKIE: &str = "furumi_session";
+const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
type HmacSha256 = Hmac;
pub struct OidcState {
pub client: CoreClient,
pub session_secret: Vec,
+ jwks_uri: String,
+ issuer_url: String,
+ jwks_cache: RwLock