Merge pull request 'feature/JWT-OIDC-SSO' (#8) from feature/JWT-OIDC-SSO into DEV
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: Новые React-компоненты — папка kebab-case, TSX + CSS module, index.ts (furumi-node-player/client/src)
|
||||||
|
globs: furumi-node-player/client/src/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Структура новых компонентов (furumi-node-player/client)
|
||||||
|
|
||||||
|
**Область:** при добавлении **нового** компонента в `furumi-node-player/client/src/` используйте расположение и файлы ниже.
|
||||||
|
|
||||||
|
## Расположение
|
||||||
|
|
||||||
|
- Базовая папка: `furumi-node-player/client/src/components/`
|
||||||
|
- Для каждого компонента — **отдельная подпапка** с именем в **kebab-case** (например `play-button`, `search-dropdown`).
|
||||||
|
|
||||||
|
## Файлы внутри папки компонента
|
||||||
|
|
||||||
|
1. **Компонент:** `components/<имя-kebab>/<имя-kebab>.tsx` — реализация компонента; к нему подключён CSS-модуль (`import styles from './<имя-kebab>.module.css'`).
|
||||||
|
2. **Стили:** `components/<имя-kebab>/<имя-kebab>.module.css` — модульные стили для этого компонента.
|
||||||
|
3. **Баррель:** `components/<имя-kebab>/index.ts` — **реэкспорт всего** из файла компонента: `export * from './<имя-kebab>'`.
|
||||||
|
|
||||||
|
Импорт снаружи: `import { MyWidget } from './components/my-widget'` (относительный путь к папке), без указания `index.ts`.
|
||||||
|
|
||||||
|
## Пример (`my-widget`)
|
||||||
|
|
||||||
|
```
|
||||||
|
components/my-widget/
|
||||||
|
my-widget.tsx
|
||||||
|
my-widget.module.css
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// my-widget.tsx
|
||||||
|
import styles from './my-widget.module.css'
|
||||||
|
|
||||||
|
export function MyWidget() {
|
||||||
|
return <div className={styles.root}>…</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// index.ts
|
||||||
|
export * from './my-widget'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примечание
|
||||||
|
|
||||||
|
Существующие файлы-компоненты в корне `components/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Publish Node Player Image (dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- DEV
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.io
|
||||||
|
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile.node-player
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Publish Node Player Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
- '!DEV'
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.io
|
||||||
|
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine version and tags
|
||||||
|
id: info
|
||||||
|
run: |
|
||||||
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
|
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
|
||||||
|
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile.node-player
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/inbox
|
/docker/inbox
|
||||||
/storage
|
/docker/storage
|
||||||
.env
|
.env
|
||||||
|
|||||||
Generated
+17
-1
@@ -1140,7 +1140,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hmac",
|
"hmac",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken 10.3.0",
|
||||||
"libc",
|
"libc",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"ogg",
|
"ogg",
|
||||||
@@ -1178,6 +1178,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"clap",
|
"clap",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"jsonwebtoken 9.3.1",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openidconnect",
|
"openidconnect",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
@@ -1902,6 +1903,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonwebtoken"
|
name = "jsonwebtoken"
|
||||||
version = "10.3.0"
|
version = "10.3.0"
|
||||||
|
|||||||
@@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
|
||||||
|
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
|
||||||
|
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
|
||||||
|
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
|
||||||
|
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
|
||||||
|
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
|
||||||
|
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
|
||||||
|
|
||||||
|
# 2. Create dummy sources so cargo can resolve and build dependencies
|
||||||
|
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
|
||||||
|
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
|
||||||
|
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
|
||||||
|
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
|
||||||
|
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
|
||||||
|
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
|
||||||
|
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
|
||||||
|
|
||||||
|
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
|
||||||
|
RUN cargo build --release --bin furumi-agent 2>/dev/null || true
|
||||||
|
|
||||||
|
# 4. Copy real source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
|
||||||
|
RUN touch furumi-common/src/lib.rs furumi-agent/src/main.rs
|
||||||
|
|
||||||
ARG FURUMI_VERSION=dev
|
ARG FURUMI_VERSION=dev
|
||||||
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
|
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 1. Install server dependencies (cached layer)
|
||||||
|
COPY furumi-node-player/server/package.json furumi-node-player/server/package-lock.json ./server/
|
||||||
|
RUN cd server && npm ci
|
||||||
|
|
||||||
|
# 2. Install client dependencies (cached layer)
|
||||||
|
COPY furumi-node-player/client/package.json furumi-node-player/client/package-lock.json ./client/
|
||||||
|
RUN cd client && npm ci
|
||||||
|
|
||||||
|
# 3. Build server
|
||||||
|
COPY furumi-node-player/server/ ./server/
|
||||||
|
RUN cd server && npm run build
|
||||||
|
|
||||||
|
# 4. Build client (VITE_FURUMI_API_URL empty = relative /api on same origin)
|
||||||
|
COPY furumi-node-player/client/ ./client/
|
||||||
|
RUN cd client && npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Server runtime
|
||||||
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/server/node_modules ./server/node_modules
|
||||||
|
COPY --from=build /app/server/package.json ./server/
|
||||||
|
|
||||||
|
# Client static files
|
||||||
|
COPY --from=build /app/client/dist ./client/dist
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3001
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
@@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
|
||||||
|
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
|
||||||
|
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
|
||||||
|
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
|
||||||
|
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
|
||||||
|
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
|
||||||
|
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
|
||||||
|
|
||||||
|
# 2. Create dummy sources so cargo can resolve and build dependencies
|
||||||
|
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
|
||||||
|
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
|
||||||
|
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
|
||||||
|
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
|
||||||
|
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
|
||||||
|
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
|
||||||
|
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
|
||||||
|
|
||||||
|
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
|
||||||
|
RUN cargo build --release --bin furumi-web-player 2>/dev/null || true
|
||||||
|
|
||||||
|
# 4. Copy real source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
|
||||||
|
RUN touch furumi-common/src/lib.rs furumi-web-player/src/main.rs
|
||||||
|
|
||||||
ARG FURUMI_VERSION=dev
|
ARG FURUMI_VERSION=dev
|
||||||
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
|
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
|
|
||||||
agent:
|
agent:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ..
|
||||||
dockerfile: Dockerfile.agent
|
dockerfile: docker/Dockerfile.agent
|
||||||
container_name: furumi-agent
|
container_name: furumi-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -25,10 +25,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
environment:
|
environment:
|
||||||
|
RUST_LOG: info
|
||||||
FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
||||||
FURUMI_AGENT_INBOX_DIR: "/inbox"
|
FURUMI_AGENT_INBOX_DIR: "/inbox"
|
||||||
FURUMI_AGENT_STORAGE_DIR: "/storage"
|
FURUMI_AGENT_STORAGE_DIR: "/storage"
|
||||||
FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
||||||
|
FURUMI_AGENT_OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}"
|
||||||
FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}"
|
FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}"
|
||||||
FURUMI_PLAYER_BIND: "0.0.0.0:8090"
|
FURUMI_PLAYER_BIND: "0.0.0.0:8090"
|
||||||
FURUMI_AGENT_POLL_INTERVAL_SECS: 5
|
FURUMI_AGENT_POLL_INTERVAL_SECS: 5
|
||||||
@@ -41,8 +43,8 @@ services:
|
|||||||
|
|
||||||
web-player:
|
web-player:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ..
|
||||||
dockerfile: Dockerfile.web-player
|
dockerfile: docker/Dockerfile.web-player
|
||||||
container_name: furumi-web-player
|
container_name: furumi-web-player
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -53,7 +55,11 @@ services:
|
|||||||
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
||||||
FURUMI_PLAYER_STORAGE_DIR: "/storage"
|
FURUMI_PLAYER_STORAGE_DIR: "/storage"
|
||||||
FURUMI_PLAYER_BIND: "0.0.0.0:8085"
|
FURUMI_PLAYER_BIND: "0.0.0.0:8085"
|
||||||
FURUMI_PLAYER_API_KEY: "node-player-api-key"
|
FURUMI_PLAYER_OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}"
|
||||||
|
FURUMI_PLAYER_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
|
||||||
|
FURUMI_PLAYER_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
|
||||||
|
FURUMI_PLAYER_OIDC_REDIRECT_URL: "${OIDC_REDIRECT_URL}"
|
||||||
|
FURUMI_PLAYER_OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
|||||||
|
|
||||||
10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations.
|
10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations.
|
||||||
|
|
||||||
11. **Confidence**: Rate your confidence from 0.0 to 1.0.
|
11. **Confidence**: MUST be a decimal number between 0.0 and 1.0 (e.g., 0.95, 0.7, 0.3). NEVER use words like "high", "medium", "low" — only a numeric float value.
|
||||||
- 1.0: All fields are clear and unambiguous.
|
- 1.0: All fields are clear and unambiguous.
|
||||||
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
|
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
|
||||||
- 0.5-0.8: Some guesswork involved, human review recommended.
|
- 0.5-0.8: Some guesswork involved, human review recommended.
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8085
|
VITE_FURUMI_API_URL=http://localhost:8085
|
||||||
VITE_API_KEY=
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { FurumiPlayer } from './FurumiPlayer'
|
import { FurumiPlayer } from './FurumiPlayer'
|
||||||
|
import { setAuthToken, clearAuthToken } from './furumiApi'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
@@ -22,7 +23,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', [])
|
const apiBase = ''
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (runWithoutAuth) {
|
if (runWithoutAuth) {
|
||||||
@@ -34,12 +35,13 @@ function App() {
|
|||||||
|
|
||||||
const loadMe = async () => {
|
const loadMe = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBase}/api/me`, {
|
const response = await fetch(`${apiBase}/auth/me`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
clearAuthToken()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +51,23 @@ function App() {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setUser(data.user ?? null)
|
setUser(data.user ?? null)
|
||||||
|
|
||||||
|
// Fetch OIDC access token for Rust API Bearer auth
|
||||||
|
if (data.user) {
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch(`${apiBase}/auth/token`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (tokenRes.ok) {
|
||||||
|
const tokenData = await tokenRes.json()
|
||||||
|
if (tokenData.access_token) {
|
||||||
|
setAuthToken(tokenData.access_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Token fetch failed — API calls will fall back to other auth methods
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load session')
|
setError(err instanceof Error ? err.message : 'Failed to load session')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,10 +76,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadMe()
|
void loadMe()
|
||||||
}, [apiBase, runWithoutAuth])
|
}, [runWithoutAuth])
|
||||||
|
|
||||||
const loginUrl = `${apiBase}/api/login`
|
const loginUrl = `${apiBase}/auth/login`
|
||||||
const logoutUrl = `${apiBase}/api/logout`
|
const logoutUrl = `${apiBase}/auth/logout`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
import './furumi-player.css'
|
import './furumi-player.css'
|
||||||
import {
|
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
|
||||||
API_ROOT,
|
import { store, useAppDispatch, useAppSelector } from './store'
|
||||||
searchTracks,
|
|
||||||
preloadStream,
|
|
||||||
} from './furumiApi'
|
|
||||||
import { useAppDispatch, useAppSelector } from './store'
|
|
||||||
import { fetchArtists } from './store/slices/artistsSlice'
|
import { fetchArtists } from './store/slices/artistsSlice'
|
||||||
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
||||||
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
|
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
|
||||||
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
|
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
|
||||||
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
|
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
|
||||||
|
import {
|
||||||
|
addTrack,
|
||||||
|
addTracksBatch,
|
||||||
|
replaceQueue,
|
||||||
|
clearQueue,
|
||||||
|
playAtIndex,
|
||||||
|
removeFromQueueAt,
|
||||||
|
moveQueueItemInOrder,
|
||||||
|
rebuildShuffleOrder,
|
||||||
|
selectQueueOrder,
|
||||||
|
selectPlayingOrigIdx,
|
||||||
|
selectQueueScrollSignal,
|
||||||
|
selectNowPlayingTrack,
|
||||||
|
selectQueueItems,
|
||||||
|
} from './store/slices/queueSlice'
|
||||||
|
import { attachAudioPlayback } from './audioPlaybackService'
|
||||||
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 { Track } from './types'
|
import type { Track } from './types'
|
||||||
|
|
||||||
export function FurumiPlayer() {
|
export function FurumiPlayer() {
|
||||||
@@ -26,6 +37,13 @@ export function FurumiPlayer() {
|
|||||||
const albumsError = useAppSelector((s) => s.albums.error)
|
const albumsError = useAppSelector((s) => s.albums.error)
|
||||||
const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading)
|
const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading)
|
||||||
const albumTracksError = useAppSelector((s) => s.albumTracks.error)
|
const albumTracksError = useAppSelector((s) => s.albumTracks.error)
|
||||||
|
|
||||||
|
const queueItemsView = useAppSelector(selectQueueItems)
|
||||||
|
const queueOrderView = useAppSelector(selectQueueOrder)
|
||||||
|
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
|
||||||
|
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
|
||||||
|
const nowPlayingTrack = useAppSelector(selectNowPlayingTrack)
|
||||||
|
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -45,81 +63,56 @@ export function FurumiPlayer() {
|
|||||||
Array<{ result_type: string; slug: string; name: string; detail?: string }>
|
Array<{ result_type: string; slug: string; name: string; detail?: string }>
|
||||||
>([])
|
>([])
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
|
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => { })
|
||||||
|
|
||||||
const [nowPlayingTrack, setNowPlayingTrack] = useState<QueueItem | null>(null)
|
|
||||||
const [queueItemsView, setQueueItemsView] = useState<QueueItem[]>([])
|
|
||||||
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
|
||||||
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
|
||||||
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
|
||||||
const [queue, setQueue] = useState<QueueItem[]>([])
|
|
||||||
|
|
||||||
const queueActionsRef = useRef<{
|
const queueActionsRef = useRef<{
|
||||||
playIndex: (i: number) => void
|
playIndex: (i: number) => void
|
||||||
removeFromQueue: (idx: number) => void
|
removeFromQueue: (idx: number) => void
|
||||||
moveQueueItem: (fromPos: number, toPos: number) => void
|
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||||
|
clearQueue: () => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// --- Original player script adapted for React environment ---
|
if (!nowPlayingTrack) {
|
||||||
|
document.title = 'Furumi Player'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document.title = `${nowPlayingTrack.title} — Furumi`
|
||||||
|
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||||
|
title: nowPlayingTrack.title,
|
||||||
|
artist: nowPlayingTrack.artist || '',
|
||||||
|
album: '',
|
||||||
|
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nowPlayingTrack])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current
|
const audioEl = audioRef.current
|
||||||
if (!audioEl) return
|
if (!audioEl) return
|
||||||
const audio = audioEl
|
const audio = audioEl
|
||||||
|
|
||||||
let queueIndex = -1
|
|
||||||
let shuffle = false
|
|
||||||
let repeatAll = true
|
|
||||||
let shuffleOrder: number[] = []
|
|
||||||
let searchTimer: number | null = null
|
let searchTimer: number | null = null
|
||||||
let toastTimer: number | null = null
|
let toastTimer: number | null = null
|
||||||
let muted = false
|
|
||||||
|
|
||||||
// Restore prefs
|
function showToast(msg: string) {
|
||||||
try {
|
const t = document.getElementById('toast')
|
||||||
const v = window.localStorage.getItem('furumi_vol')
|
if (!t) return
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
t.textContent = msg
|
||||||
if (v !== null && volSlider) {
|
t.classList.add('show')
|
||||||
audio.volume = Number(v) / 100
|
if (toastTimer) window.clearTimeout(toastTimer)
|
||||||
volSlider.value = v
|
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
|
||||||
}
|
|
||||||
const btnShuffle = document.getElementById('btnShuffle')
|
|
||||||
const btnRepeat = document.getElementById('btnRepeat')
|
|
||||||
shuffle = window.localStorage.getItem('furumi_shuffle') === '1'
|
|
||||||
repeatAll = window.localStorage.getItem('furumi_repeat') !== '0'
|
|
||||||
btnShuffle?.classList.toggle('active', shuffle)
|
|
||||||
btnRepeat?.classList.toggle('active', repeatAll)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audio events ---
|
|
||||||
audio.addEventListener('timeupdate', () => {
|
|
||||||
if (audio.duration) {
|
|
||||||
const fill = document.getElementById('progressFill')
|
|
||||||
const timeElapsed = document.getElementById('timeElapsed')
|
|
||||||
const timeDuration = document.getElementById('timeDuration')
|
|
||||||
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
|
|
||||||
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
|
|
||||||
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
audio.addEventListener('ended', () => nextTrack())
|
|
||||||
audio.addEventListener('play', () => {
|
|
||||||
const btn = document.getElementById('btnPlayPause')
|
|
||||||
if (btn) btn.innerHTML = '⏸'
|
|
||||||
})
|
|
||||||
audio.addEventListener('pause', () => {
|
|
||||||
const btn = document.getElementById('btnPlayPause')
|
|
||||||
if (btn) btn.innerHTML = '▶'
|
|
||||||
})
|
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
showToast('Playback error')
|
|
||||||
nextTrack()
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Library navigation ---
|
|
||||||
async function showArtists() {
|
async function showArtists() {
|
||||||
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +224,6 @@ export function FurumiPlayer() {
|
|||||||
setBreadcrumbs(parts)
|
setBreadcrumbs(parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Queue management ---
|
|
||||||
function addTrackToQueue(
|
function addTrackToQueue(
|
||||||
track: {
|
track: {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -242,15 +234,11 @@ export function FurumiPlayer() {
|
|||||||
},
|
},
|
||||||
playNow?: boolean,
|
playNow?: boolean,
|
||||||
) {
|
) {
|
||||||
const existing = queue.findIndex((t) => t.slug === track.slug)
|
const prevIdx = store.getState().queue.currentIndex
|
||||||
if (existing !== -1) {
|
dispatch(addTrack({ track, playNow }))
|
||||||
if (playNow) playIndex(existing)
|
const q = store.getState().queue
|
||||||
return
|
if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) {
|
||||||
}
|
playIndex(q.currentIndex)
|
||||||
setQueue((q) => [...q, track]);
|
|
||||||
updateQueueModel()
|
|
||||||
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
|
||||||
playIndex(queue.length - 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,23 +247,19 @@ export function FurumiPlayer() {
|
|||||||
if (result.meta.requestStatus === 'rejected') return
|
if (result.meta.requestStatus === 'rejected') return
|
||||||
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
||||||
if (!tracks || !tracks.length) return
|
if (!tracks || !tracks.length) return
|
||||||
const list = tracks
|
const list = tracks.map((t) => ({
|
||||||
let firstIdx = queue.length
|
slug: t.slug,
|
||||||
list.forEach((t) => {
|
title: t.title,
|
||||||
if (queue.find((q) => q.slug === t.slug)) return
|
artist: t.artist_name,
|
||||||
setQueue((q) => [
|
album_slug: t.album_slug,
|
||||||
...q,
|
duration: t.duration_secs,
|
||||||
{
|
}))
|
||||||
slug: t.slug,
|
const prevIdx = store.getState().queue.currentIndex
|
||||||
title: t.title,
|
dispatch(addTracksBatch({ tracks: list, playFirst }))
|
||||||
artist: t.artist_name,
|
const q = store.getState().queue
|
||||||
album_slug: t.album_slug,
|
if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) {
|
||||||
duration: t.duration_secs,
|
playIndex(q.currentIndex)
|
||||||
},
|
}
|
||||||
])
|
|
||||||
})
|
|
||||||
updateQueueModel()
|
|
||||||
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,29 +268,30 @@ export function FurumiPlayer() {
|
|||||||
if (result.meta.requestStatus === 'rejected') return
|
if (result.meta.requestStatus === 'rejected') return
|
||||||
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
|
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
|
||||||
if (!tracks || !tracks.length) return
|
if (!tracks || !tracks.length) return
|
||||||
const list = tracks
|
const list = tracks.map((t) => ({
|
||||||
clearQueue()
|
|
||||||
setQueue(list.map((t) => ({
|
|
||||||
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()
|
dispatch(replaceQueue({ items: list, playFromIndex: 0 }))
|
||||||
playIndex(0)
|
playIndex(0)
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playback = attachAudioPlayback(audio, {
|
||||||
|
onEnded: nextTrack,
|
||||||
|
onErrorSkip: nextTrack,
|
||||||
|
onToast: showToast,
|
||||||
|
})
|
||||||
|
|
||||||
function playIndex(i: number) {
|
function playIndex(i: number) {
|
||||||
if (i < 0 || i >= queue.length) return
|
const q = store.getState().queue
|
||||||
queueIndex = i
|
if (i < 0 || i >= q.items.length) return
|
||||||
const track = queue[i]
|
dispatch(playAtIndex(i))
|
||||||
audio.src = `${API_ROOT}/stream/${track.slug}`
|
const track = store.getState().queue.items[i]
|
||||||
void audio.play().catch(() => {})
|
void playback.loadStreamForTrack(track.slug)
|
||||||
updateNowPlaying(track)
|
|
||||||
updateQueueModel()
|
|
||||||
setQueueScrollSignal((s) => s + 1)
|
|
||||||
if (window.history && window.history.replaceState) {
|
if (window.history && window.history.replaceState) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('t', track.slug)
|
url.searchParams.set('t', track.slug)
|
||||||
@@ -314,181 +299,58 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNowPlaying(track: QueueItem | null) {
|
|
||||||
setNowPlayingTrack(track)
|
|
||||||
if (!track) return
|
|
||||||
|
|
||||||
document.title = `${track.title} — Furumi`
|
|
||||||
|
|
||||||
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
|
||||||
title: track.title,
|
|
||||||
artist: track.artist || '',
|
|
||||||
album: '',
|
|
||||||
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentOrder() {
|
|
||||||
if (!shuffle) return [...Array(queue.length).keys()]
|
|
||||||
if (shuffleOrder.length !== queue.length) buildShuffleOrder()
|
|
||||||
return shuffleOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShuffleOrder() {
|
|
||||||
shuffleOrder = [...Array(queue.length).keys()]
|
|
||||||
for (let i = shuffleOrder.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]
|
|
||||||
}
|
|
||||||
if (queueIndex !== -1) {
|
|
||||||
const ci = shuffleOrder.indexOf(queueIndex)
|
|
||||||
if (ci > 0) {
|
|
||||||
shuffleOrder.splice(ci, 1)
|
|
||||||
shuffleOrder.unshift(queueIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQueueModel() {
|
|
||||||
const order = currentOrder()
|
|
||||||
setQueueItemsView(queue)
|
|
||||||
setQueueOrderView(order.slice())
|
|
||||||
setQueuePlayingOrigIdxView(queueIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFromQueue(idx: number) {
|
function removeFromQueue(idx: number) {
|
||||||
if (idx === queueIndex) {
|
const wasPlaying = store.getState().queue.currentIndex === idx
|
||||||
queueIndex = -1
|
dispatch(removeFromQueueAt(idx))
|
||||||
audio.pause()
|
if (wasPlaying) playback.pauseAndClearSource()
|
||||||
audio.src = ''
|
|
||||||
updateNowPlaying(null)
|
|
||||||
} else if (queueIndex > idx) {
|
|
||||||
queueIndex--
|
|
||||||
}
|
|
||||||
|
|
||||||
// queue.splice(idx, 1)
|
|
||||||
setQueue((q) => q.filter((_, i) => i !== idx));
|
|
||||||
|
|
||||||
if (shuffle) {
|
|
||||||
const si = shuffleOrder.indexOf(idx)
|
|
||||||
if (si !== -1) shuffleOrder.splice(si, 1)
|
|
||||||
for (let i = 0; i < shuffleOrder.length; i++) {
|
|
||||||
if (shuffleOrder[i] > idx) shuffleOrder[i]--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateQueueModel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveQueueItem(from: number, to: number) {
|
function moveQueueItem(fromPos: number, toPos: number) {
|
||||||
if (from === to) return
|
dispatch(moveQueueItemInOrder({ fromPos, toPos }))
|
||||||
if (shuffle) {
|
}
|
||||||
const item = shuffleOrder.splice(from, 1)[0]
|
|
||||||
shuffleOrder.splice(to, 0, item)
|
function clearQueuePlayback() {
|
||||||
} else {
|
dispatch(clearQueue())
|
||||||
const item = queue.splice(from, 1)[0]
|
playback.pauseAndClearSource()
|
||||||
queue.splice(to, 0, item)
|
}
|
||||||
if (queueIndex === from) queueIndex = to
|
|
||||||
else if (from < queueIndex && to >= queueIndex) queueIndex--
|
function nextTrack() {
|
||||||
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
const q = store.getState().queue
|
||||||
|
if (!q.items.length) return
|
||||||
|
const order = selectQueueOrder(store.getState())
|
||||||
|
const pos = order.indexOf(q.currentIndex)
|
||||||
|
if (pos < order.length - 1) playIndex(order[pos + 1])
|
||||||
|
else if (q.repeatAll) {
|
||||||
|
if (q.shuffle) dispatch(rebuildShuffleOrder())
|
||||||
|
const first = selectQueueOrder(store.getState())[0]
|
||||||
|
if (first !== undefined) playIndex(first)
|
||||||
}
|
}
|
||||||
updateQueueModel()
|
}
|
||||||
|
|
||||||
|
function prevTrack() {
|
||||||
|
const q = store.getState().queue
|
||||||
|
if (!q.items.length) return
|
||||||
|
if (playback.rewindCurrentTrackIfPastThreshold()) return
|
||||||
|
const order = selectQueueOrder(store.getState())
|
||||||
|
const pos = order.indexOf(q.currentIndex)
|
||||||
|
if (pos > 0) playIndex(order[pos - 1])
|
||||||
|
else if (q.repeatAll) playIndex(order[order.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
const q = store.getState().queue
|
||||||
|
playback.togglePlay(() => {
|
||||||
|
playIndex(q.currentIndex === -1 ? 0 : q.currentIndex)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
queueActionsRef.current = {
|
queueActionsRef.current = {
|
||||||
playIndex,
|
playIndex,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
moveQueueItem,
|
moveQueueItem,
|
||||||
|
clearQueue: clearQueuePlayback,
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearQueue() {
|
|
||||||
setQueue([]);
|
|
||||||
queueIndex = -1
|
|
||||||
shuffleOrder = []
|
|
||||||
audio.pause()
|
|
||||||
audio.src = ''
|
|
||||||
updateNowPlaying(null)
|
|
||||||
document.title = 'Furumi Player'
|
|
||||||
updateQueueModel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Playback controls ---
|
|
||||||
function togglePlay() {
|
|
||||||
if (!audio.src && queue.length) {
|
|
||||||
playIndex(queueIndex === -1 ? 0 : queueIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (audio.paused) void audio.play()
|
|
||||||
else audio.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextTrack() {
|
|
||||||
if (!queue.length) return
|
|
||||||
const order = currentOrder()
|
|
||||||
const pos = order.indexOf(queueIndex)
|
|
||||||
if (pos < order.length - 1) playIndex(order[pos + 1])
|
|
||||||
else if (repeatAll) {
|
|
||||||
if (shuffle) buildShuffleOrder()
|
|
||||||
playIndex(currentOrder()[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevTrack() {
|
|
||||||
if (!queue.length) return
|
|
||||||
if (audio.currentTime > 3) {
|
|
||||||
audio.currentTime = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const order = currentOrder()
|
|
||||||
const pos = order.indexOf(queueIndex)
|
|
||||||
if (pos > 0) playIndex(order[pos - 1])
|
|
||||||
else if (repeatAll) playIndex(order[order.length - 1])
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleShuffle() {
|
|
||||||
shuffle = !shuffle
|
|
||||||
if (shuffle) buildShuffleOrder()
|
|
||||||
const btn = document.getElementById('btnShuffle')
|
|
||||||
btn?.classList.toggle('active', shuffle)
|
|
||||||
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
|
||||||
updateQueueModel()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRepeat() {
|
|
||||||
repeatAll = !repeatAll
|
|
||||||
const btn = document.getElementById('btnRepeat')
|
|
||||||
btn?.classList.toggle('active', repeatAll)
|
|
||||||
window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Seek & Volume ---
|
|
||||||
function seekTo(e: MouseEvent) {
|
|
||||||
if (!audio.duration) return
|
|
||||||
const bar = document.getElementById('progressBar') as HTMLDivElement | null
|
|
||||||
if (!bar) return
|
|
||||||
const rect = bar.getBoundingClientRect()
|
|
||||||
const pct = (e.clientX - rect.left) / rect.width
|
|
||||||
audio.currentTime = pct * audio.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMute() {
|
|
||||||
muted = !muted
|
|
||||||
audio.muted = muted
|
|
||||||
const volIcon = document.getElementById('volIcon')
|
|
||||||
if (volIcon) volIcon.innerHTML = muted ? '🔇' : '🔊'
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVolume(v: number) {
|
|
||||||
audio.volume = v / 100
|
|
||||||
const volIcon = document.getElementById('volIcon')
|
|
||||||
if (volIcon) volIcon.innerHTML = v === 0 ? '🔇' : '🔊'
|
|
||||||
window.localStorage.setItem('furumi_vol', String(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Search ---
|
|
||||||
function onSearch(q: string) {
|
function onSearch(q: string) {
|
||||||
if (searchTimer) {
|
if (searchTimer) {
|
||||||
window.clearTimeout(searchTimer)
|
window.clearTimeout(searchTimer)
|
||||||
@@ -527,16 +389,6 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
function showToast(msg: string) {
|
|
||||||
const t = document.getElementById('toast')
|
|
||||||
if (!t) return
|
|
||||||
t.textContent = msg
|
|
||||||
t.classList.add('show')
|
|
||||||
if (toastTimer) window.clearTimeout(toastTimer)
|
|
||||||
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const sidebar = document.getElementById('sidebar')
|
const sidebar = document.getElementById('sidebar')
|
||||||
const overlay = document.getElementById('sidebarOverlay')
|
const overlay = document.getElementById('sidebarOverlay')
|
||||||
@@ -544,100 +396,101 @@ export function FurumiPlayer() {
|
|||||||
overlay?.classList.toggle('show')
|
overlay?.classList.toggle('show')
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MediaSession ---
|
const onMediaSeekTo = (d: { seekTime?: number }) => {
|
||||||
|
if (typeof d.seekTime === 'number') {
|
||||||
|
playback.seekToTime(d.seekTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
try {
|
try {
|
||||||
navigator.mediaSession.setActionHandler('play', togglePlay)
|
navigator.mediaSession.setActionHandler('play', togglePlay)
|
||||||
navigator.mediaSession.setActionHandler('pause', togglePlay)
|
navigator.mediaSession.setActionHandler('pause', togglePlay)
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
|
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
||||||
navigator.mediaSession.setActionHandler('seekto', (d: any) => {
|
navigator.mediaSession.setActionHandler('seekto', onMediaSeekTo as (d: any) => void)
|
||||||
if (typeof d.seekTime === 'number') {
|
|
||||||
audio.currentTime = d.seekTime
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Wire DOM events that were inline in HTML ---
|
const onMenuClick = () => toggleSidebar()
|
||||||
const btnMenu = document.querySelector('.btn-menu')
|
const btnMenu = document.querySelector('.btn-menu')
|
||||||
btnMenu?.addEventListener('click', () => toggleSidebar())
|
btnMenu?.addEventListener('click', onMenuClick)
|
||||||
|
|
||||||
|
const onSidebarOverlayClick = () => toggleSidebar()
|
||||||
const sidebarOverlay = document.getElementById('sidebarOverlay')
|
const sidebarOverlay = document.getElementById('sidebarOverlay')
|
||||||
sidebarOverlay?.addEventListener('click', () => toggleSidebar())
|
sidebarOverlay?.addEventListener('click', onSidebarOverlayClick)
|
||||||
|
|
||||||
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
|
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
|
||||||
|
const onSearchInput = (e: Event) => {
|
||||||
|
onSearch((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
const onSearchKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeSearch()
|
||||||
|
}
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', onSearchInput)
|
||||||
onSearch((e.target as HTMLInputElement).value)
|
searchInput.addEventListener('keydown', onSearchKeydown)
|
||||||
})
|
|
||||||
searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') closeSearch()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnShuffle = document.getElementById('btnShuffle')
|
const onPrevClick = () => prevTrack()
|
||||||
btnShuffle?.addEventListener('click', () => toggleShuffle())
|
const onPlayClick = () => togglePlay()
|
||||||
const btnRepeat = document.getElementById('btnRepeat')
|
const onNextClick = () => nextTrack()
|
||||||
btnRepeat?.addEventListener('click', () => toggleRepeat())
|
|
||||||
const btnClear = document.getElementById('btnClearQueue')
|
|
||||||
btnClear?.addEventListener('click', () => clearQueue())
|
|
||||||
|
|
||||||
const btnPrev = document.getElementById('btnPrev')
|
const btnPrev = document.getElementById('btnPrev')
|
||||||
btnPrev?.addEventListener('click', () => prevTrack())
|
btnPrev?.addEventListener('click', onPrevClick)
|
||||||
const btnPlay = document.getElementById('btnPlayPause')
|
const btnPlay = document.getElementById('btnPlayPause')
|
||||||
btnPlay?.addEventListener('click', () => togglePlay())
|
btnPlay?.addEventListener('click', onPlayClick)
|
||||||
const btnNext = document.getElementById('btnNext')
|
const btnNext = document.getElementById('btnNext')
|
||||||
btnNext?.addEventListener('click', () => nextTrack())
|
btnNext?.addEventListener('click', onNextClick)
|
||||||
|
|
||||||
const progressBar = document.getElementById('progressBar')
|
; (async () => {
|
||||||
progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent))
|
const url = new URL(window.location.href)
|
||||||
|
const urlSlug = url.searchParams.get('t')
|
||||||
const volIcon = document.getElementById('volIcon')
|
if (urlSlug) {
|
||||||
volIcon?.addEventListener('click', () => toggleMute())
|
try {
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
|
||||||
if (volSlider) {
|
addTrackToQueue(
|
||||||
volSlider.addEventListener('input', (e) => {
|
{
|
||||||
const v = Number((e.target as HTMLInputElement).value)
|
slug: detail.slug,
|
||||||
setVolume(v)
|
title: detail.title,
|
||||||
})
|
artist: detail.artist_name,
|
||||||
}
|
album_slug: detail.album_slug,
|
||||||
|
duration: detail.duration_secs,
|
||||||
const clearQueueBtn = document.getElementById('btnClearQueue')
|
},
|
||||||
clearQueueBtn?.addEventListener('click', () => clearQueue())
|
true,
|
||||||
|
)
|
||||||
// --- Init ---
|
} catch {
|
||||||
;(async () => {
|
// fetchTrackDetail rejected — track not found or error
|
||||||
const url = new URL(window.location.href)
|
}
|
||||||
const urlSlug = url.searchParams.get('t')
|
|
||||||
if (urlSlug) {
|
|
||||||
try {
|
|
||||||
const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
|
|
||||||
addTrackToQueue(
|
|
||||||
{
|
|
||||||
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()
|
||||||
void showArtists()
|
})()
|
||||||
})()
|
|
||||||
|
|
||||||
// Cleanup: best-effort remove listeners on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
queueActionsRef.current = null
|
queueActionsRef.current = null
|
||||||
audio.pause()
|
playback.dispose()
|
||||||
|
btnMenu?.removeEventListener('click', onMenuClick)
|
||||||
|
sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick)
|
||||||
|
searchInput?.removeEventListener('input', onSearchInput)
|
||||||
|
searchInput?.removeEventListener('keydown', onSearchKeydown)
|
||||||
|
btnPrev?.removeEventListener('click', onPrevClick)
|
||||||
|
btnPlay?.removeEventListener('click', onPlayClick)
|
||||||
|
btnNext?.removeEventListener('click', onNextClick)
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setActionHandler('play', null)
|
||||||
|
navigator.mediaSession.setActionHandler('pause', null)
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', null)
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', null)
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', null)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [dispatch])
|
||||||
|
|
||||||
const libraryLoading =
|
const libraryLoading =
|
||||||
breadcrumbs.length === 1
|
breadcrumbs.length === 1
|
||||||
@@ -666,10 +519,22 @@ export function FurumiPlayer() {
|
|||||||
libraryLoading={libraryLoading}
|
libraryLoading={libraryLoading}
|
||||||
libraryError={libraryError}
|
libraryError={libraryError}
|
||||||
libraryItems={libraryItems}
|
libraryItems={libraryItems}
|
||||||
queueItemsView={queueItemsView}
|
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||||
queueOrderView={queueOrderView}
|
onQueueRemove={(origIdx) =>
|
||||||
queuePlayingOrigIdxView={queuePlayingOrigIdxView}
|
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||||
queueScrollSignal={queueScrollSignal}
|
}
|
||||||
|
onQueueMove={(fromPos, toPos) =>
|
||||||
|
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||||
|
}
|
||||||
|
onClearQueue={() => queueActionsRef.current?.clearQueue()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlayerBar
|
||||||
|
track={nowPlayingTrack}
|
||||||
|
queue={queueItemsView}
|
||||||
|
order={queueOrderView}
|
||||||
|
playingOrigIdx={queuePlayingOrigIdxView}
|
||||||
|
scrollSignal={queueScrollSignal}
|
||||||
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||||
onQueueRemove={(origIdx) =>
|
onQueueRemove={(origIdx) =>
|
||||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||||
@@ -679,11 +544,8 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlayerBar track={nowPlayingTrack} />
|
|
||||||
|
|
||||||
<div className="toast" id="toast" />
|
<div className="toast" id="toast" />
|
||||||
<audio ref={audioRef} />
|
<audio ref={audioRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { preloadStream } from './furumiApi'
|
||||||
|
import { fmt } from './utils'
|
||||||
|
|
||||||
|
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
||||||
|
|
||||||
|
/** Seconds from track start above which "previous" rewinds current track instead. */
|
||||||
|
const PREV_TRACK_REWIND_THRESHOLD_SEC = 3
|
||||||
|
|
||||||
|
export interface AudioPlaybackCallbacks {
|
||||||
|
onEnded: () => void
|
||||||
|
/** Called after a recoverable playback error (to advance queue). */
|
||||||
|
onErrorSkip: () => void
|
||||||
|
onToast: (msg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioPlaybackHandle {
|
||||||
|
loadStreamForTrack(slug: string): Promise<void>
|
||||||
|
pauseAndClearSource(): void
|
||||||
|
togglePlay(whenNoSource: () => void): void
|
||||||
|
seekFromProgressBarClick(e: MouseEvent): void
|
||||||
|
toggleMute(): void
|
||||||
|
setVolume(percent: number): void
|
||||||
|
seekToTime(seconds: number): void
|
||||||
|
/** If current time is past the threshold, seeks to 0 and returns true (caller should skip prev-track logic). */
|
||||||
|
rewindCurrentTrackIfPastThreshold(): boolean
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVolumeFromStorage(audio: HTMLAudioElement) {
|
||||||
|
try {
|
||||||
|
const v = window.localStorage.getItem('furumi_vol')
|
||||||
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
|
if (v !== null && volSlider) {
|
||||||
|
audio.volume = Number(v) / 100
|
||||||
|
volSlider.value = v
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachAudioPlayback(
|
||||||
|
audio: HTMLAudioElement,
|
||||||
|
callbacks: AudioPlaybackCallbacks,
|
||||||
|
): AudioPlaybackHandle {
|
||||||
|
let muted = false
|
||||||
|
let playbackErrorSkips = 0
|
||||||
|
|
||||||
|
syncVolumeFromStorage(audio)
|
||||||
|
|
||||||
|
function onTimeUpdate() {
|
||||||
|
if (!audio.duration) return
|
||||||
|
const fill = document.getElementById('progressFill')
|
||||||
|
const timeElapsed = document.getElementById('timeElapsed')
|
||||||
|
const timeDuration = document.getElementById('timeDuration')
|
||||||
|
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
|
||||||
|
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
|
||||||
|
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayPauseButtonPlaying(playing: boolean) {
|
||||||
|
const btn = document.getElementById('btnPlayPause')
|
||||||
|
if (btn) btn.innerHTML = playing ? '⏸' : '▶'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlaying() {
|
||||||
|
playbackErrorSkips = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlay() {
|
||||||
|
setPlayPauseButtonPlaying(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPause() {
|
||||||
|
setPlayPauseButtonPlaying(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
callbacks.onToast('Playback error')
|
||||||
|
if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return
|
||||||
|
playbackErrorSkips += 1
|
||||||
|
callbacks.onErrorSkip()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnded() {
|
||||||
|
callbacks.onEnded()
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
audio.addEventListener('ended', onEnded)
|
||||||
|
audio.addEventListener('playing', onPlaying)
|
||||||
|
audio.addEventListener('play', onPlay)
|
||||||
|
audio.addEventListener('pause', onPause)
|
||||||
|
audio.addEventListener('error', onError)
|
||||||
|
|
||||||
|
const progressBar = document.getElementById('progressBar')
|
||||||
|
const onProgressClick = (e: Event) => seekFromProgressBarClick(e as MouseEvent)
|
||||||
|
progressBar?.addEventListener('click', onProgressClick)
|
||||||
|
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
const onVolIconClick = () => toggleMute()
|
||||||
|
volIcon?.addEventListener('click', onVolIconClick)
|
||||||
|
|
||||||
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
|
const onVolInput = (e: Event) => {
|
||||||
|
const v = Number((e.target as HTMLInputElement).value)
|
||||||
|
setVolume(v)
|
||||||
|
}
|
||||||
|
volSlider?.addEventListener('input', onVolInput)
|
||||||
|
|
||||||
|
async function loadStreamForTrack(slug: string) {
|
||||||
|
const response = await preloadStream(slug)
|
||||||
|
audio.src = URL.createObjectURL(response?.data)
|
||||||
|
await audio.play().catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseAndClearSource() {
|
||||||
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay(whenNoSource: () => void) {
|
||||||
|
if (!audio.src) {
|
||||||
|
whenNoSource()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (audio.paused) void audio.play()
|
||||||
|
else audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekFromProgressBarClick(e: MouseEvent) {
|
||||||
|
if (!audio.duration) return
|
||||||
|
const bar = document.getElementById('progressBar') as HTMLDivElement | null
|
||||||
|
if (!bar) return
|
||||||
|
const rect = bar.getBoundingClientRect()
|
||||||
|
const pct = (e.clientX - rect.left) / rect.width
|
||||||
|
audio.currentTime = pct * audio.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
muted = !muted
|
||||||
|
audio.muted = muted
|
||||||
|
const icon = document.getElementById('volIcon')
|
||||||
|
if (icon) icon.innerHTML = muted ? '🔇' : '🔊'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(percent: number) {
|
||||||
|
audio.volume = percent / 100
|
||||||
|
const icon = document.getElementById('volIcon')
|
||||||
|
if (icon) icon.innerHTML = percent === 0 ? '🔇' : '🔊'
|
||||||
|
window.localStorage.setItem('furumi_vol', String(percent))
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToTime(seconds: number) {
|
||||||
|
audio.currentTime = seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewindCurrentTrackIfPastThreshold(): boolean {
|
||||||
|
if (audio.currentTime > PREV_TRACK_REWIND_THRESHOLD_SEC) {
|
||||||
|
audio.currentTime = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
audio.removeEventListener('ended', onEnded)
|
||||||
|
audio.removeEventListener('playing', onPlaying)
|
||||||
|
audio.removeEventListener('play', onPlay)
|
||||||
|
audio.removeEventListener('pause', onPause)
|
||||||
|
audio.removeEventListener('error', onError)
|
||||||
|
progressBar?.removeEventListener('click', onProgressClick)
|
||||||
|
volIcon?.removeEventListener('click', onVolIconClick)
|
||||||
|
volSlider?.removeEventListener('input', onVolInput)
|
||||||
|
audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadStreamForTrack,
|
||||||
|
pauseAndClearSource,
|
||||||
|
togglePlay,
|
||||||
|
seekFromProgressBarClick,
|
||||||
|
toggleMute,
|
||||||
|
setVolume,
|
||||||
|
seekToTime,
|
||||||
|
rewindCurrentTrackIfPastThreshold,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
|
import {
|
||||||
|
selectPlayingOrigIdx,
|
||||||
|
selectQueueItems,
|
||||||
|
selectQueueOrder,
|
||||||
|
selectQueueScrollSignal,
|
||||||
|
selectRepeatAll,
|
||||||
|
selectShuffle,
|
||||||
|
toggleRepeat,
|
||||||
|
toggleShuffle,
|
||||||
|
} from '../store/slices/queueSlice'
|
||||||
import { Breadcrumbs } from './Breadcrumbs'
|
import { Breadcrumbs } from './Breadcrumbs'
|
||||||
import { LibraryList } from './LibraryList'
|
import { LibraryList } from './LibraryList'
|
||||||
import { QueueList, type QueueItem } from './QueueList'
|
import { QueueList } from './QueueList'
|
||||||
|
|
||||||
export type Crumb = { label: string; action?: () => void }
|
export type Crumb = { label: string; action?: () => void }
|
||||||
|
|
||||||
@@ -21,13 +32,10 @@ type MainPanelProps = {
|
|||||||
libraryLoading: boolean
|
libraryLoading: boolean
|
||||||
libraryError: string | null
|
libraryError: string | null
|
||||||
libraryItems: LibraryListItem[]
|
libraryItems: LibraryListItem[]
|
||||||
queueItemsView: QueueItem[]
|
|
||||||
queueOrderView: number[]
|
|
||||||
queuePlayingOrigIdxView: number
|
|
||||||
queueScrollSignal: number
|
|
||||||
onQueuePlay: (origIdx: number) => void
|
onQueuePlay: (origIdx: number) => void
|
||||||
onQueueRemove: (origIdx: number) => void
|
onQueueRemove: (origIdx: number) => void
|
||||||
onQueueMove: (fromPos: number, toPos: number) => void
|
onQueueMove: (fromPos: number, toPos: number) => void
|
||||||
|
onClearQueue: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MainPanel({
|
export function MainPanel({
|
||||||
@@ -35,14 +43,19 @@ export function MainPanel({
|
|||||||
libraryLoading,
|
libraryLoading,
|
||||||
libraryError,
|
libraryError,
|
||||||
libraryItems,
|
libraryItems,
|
||||||
queueItemsView,
|
|
||||||
queueOrderView,
|
|
||||||
queuePlayingOrigIdxView,
|
|
||||||
queueScrollSignal,
|
|
||||||
onQueuePlay,
|
onQueuePlay,
|
||||||
onQueueRemove,
|
onQueueRemove,
|
||||||
onQueueMove,
|
onQueueMove,
|
||||||
|
onClearQueue,
|
||||||
}: MainPanelProps) {
|
}: MainPanelProps) {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const queueItemsView = useAppSelector(selectQueueItems)
|
||||||
|
const queueOrderView = useAppSelector(selectQueueOrder)
|
||||||
|
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
|
||||||
|
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
|
||||||
|
const shuffle = useAppSelector(selectShuffle)
|
||||||
|
const repeatAll = useAppSelector(selectRepeatAll)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="sidebar-overlay" id="sidebarOverlay" />
|
<div className="sidebar-overlay" id="sidebarOverlay" />
|
||||||
@@ -58,13 +71,21 @@ export function MainPanel({
|
|||||||
<div className="queue-header">
|
<div className="queue-header">
|
||||||
<span>Queue</span>
|
<span>Queue</span>
|
||||||
<div className="queue-actions">
|
<div className="queue-actions">
|
||||||
<button className="queue-btn active" id="btnShuffle">
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`queue-btn${shuffle ? ' active' : ''}`}
|
||||||
|
onClick={() => dispatch(toggleShuffle())}
|
||||||
|
>
|
||||||
Shuffle
|
Shuffle
|
||||||
</button>
|
</button>
|
||||||
<button className="queue-btn active" id="btnRepeat">
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`queue-btn${repeatAll ? ' active' : ''}`}
|
||||||
|
onClick={() => dispatch(toggleRepeat())}
|
||||||
|
>
|
||||||
Repeat
|
Repeat
|
||||||
</button>
|
</button>
|
||||||
<button className="queue-btn" id="btnClearQueue">
|
<button type="button" className="queue-btn" onClick={onClearQueue}>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import { NowPlaying } from './NowPlaying'
|
import { NowPlaying } from './NowPlaying'
|
||||||
|
import { QueuePopover } from './queue-popover'
|
||||||
import type { QueueItem } from './QueueList'
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
export function PlayerBar({ track }: { track: QueueItem | null }) {
|
type PlayerBarProps = {
|
||||||
|
track: QueueItem | null
|
||||||
|
queue: QueueItem[]
|
||||||
|
order: number[]
|
||||||
|
playingOrigIdx: number
|
||||||
|
scrollSignal: number
|
||||||
|
onQueuePlay: (origIdx: number) => void
|
||||||
|
onQueueRemove: (origIdx: number) => void
|
||||||
|
onQueueMove: (fromPos: number, toPos: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerBar({
|
||||||
|
track,
|
||||||
|
queue,
|
||||||
|
order,
|
||||||
|
playingOrigIdx,
|
||||||
|
scrollSignal,
|
||||||
|
onQueuePlay,
|
||||||
|
onQueueRemove,
|
||||||
|
onQueueMove,
|
||||||
|
}: PlayerBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="player-bar">
|
<div className="player-bar">
|
||||||
<NowPlaying track={track} />
|
<NowPlaying track={track} />
|
||||||
@@ -30,6 +51,15 @@ export function PlayerBar({ track }: { track: QueueItem | null }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="volume-row">
|
<div className="volume-row">
|
||||||
|
<QueuePopover
|
||||||
|
queue={queue}
|
||||||
|
order={order}
|
||||||
|
playingOrigIdx={playingOrigIdx}
|
||||||
|
scrollSignal={scrollSignal}
|
||||||
|
onPlay={onQueuePlay}
|
||||||
|
onRemove={onQueueRemove}
|
||||||
|
onMove={onQueueMove}
|
||||||
|
/>
|
||||||
<span className="vol-icon" id="volIcon">
|
<span className="vol-icon" id="volIcon">
|
||||||
🔊
|
🔊
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
import { SearchDropdown } from './SearchDropdown'
|
import { SearchDropdown } from '../SearchDropdown'
|
||||||
|
import styles from './header.module.css'
|
||||||
|
|
||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
result_type: string
|
result_type: string
|
||||||
@@ -19,8 +20,8 @@ export function Header({
|
|||||||
onSearchSelect,
|
onSearchSelect,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="header">
|
<header className={styles.header}>
|
||||||
<div className="header-logo">
|
<div className={styles.headerLogo}>
|
||||||
<button className="btn-menu">☰</button>
|
<button className="btn-menu">☰</button>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="9" cy="18" r="3" />
|
<circle cx="9" cy="18" r="3" />
|
||||||
@@ -28,7 +29,7 @@ export function Header({
|
|||||||
<path d="M12 18V6l9-3v3" />
|
<path d="M12 18V6l9-3v3" />
|
||||||
</svg>
|
</svg>
|
||||||
Furumi
|
Furumi
|
||||||
<span className="header-version">v</span>
|
<span className={styles.headerVersion}>v</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<div className="search-wrap">
|
<div className="search-wrap">
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './Header'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './queue-popover'
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className={styles.root} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.trigger}
|
||||||
|
title="Playback queue"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-controls={open ? panelId : undefined}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className={styles.triggerIcon} aria-hidden>
|
||||||
|
☰
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
className={styles.popover}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
>
|
||||||
|
<div className={styles.header} id={titleId}>
|
||||||
|
Queue
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<QueueList
|
||||||
|
queue={queue}
|
||||||
|
order={order}
|
||||||
|
playingOrigIdx={playingOrigIdx}
|
||||||
|
scrollSignal={scrollSignal}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onMove={onMove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,40 +31,6 @@
|
|||||||
--danger: #f87171;
|
--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 {
|
.btn-menu {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -378,6 +344,10 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qi-title {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-item .qi-index {
|
.queue-item .qi-index {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -531,6 +501,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.np-artist {
|
.np-artist {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
|
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
|
const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? ''
|
||||||
export const API_ROOT = `${API_BASE}/api`
|
export const API_ROOT = `${FURUMI_API_BASE}/api`
|
||||||
|
|
||||||
const API_KEY = import.meta.env.VITE_API_KEY
|
|
||||||
|
|
||||||
export const furumiApi = axios.create({
|
export const furumiApi = axios.create({
|
||||||
baseURL: API_ROOT,
|
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<Artist[] | null> {
|
export async function getArtists(): Promise<Artist[] | null> {
|
||||||
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
|
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
|
||||||
return res?.data ?? null
|
return res?.data ?? null
|
||||||
@@ -44,6 +49,6 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function preloadStream(trackSlug: string) {
|
export async function preloadStream(trackSlug: string) {
|
||||||
await furumiApi.get(`/stream/${trackSlug}`).catch(() => null)
|
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import albumsReducer from './slices/albumsSlice'
|
|||||||
import albumTracksReducer from './slices/albumTracksSlice'
|
import albumTracksReducer from './slices/albumTracksSlice'
|
||||||
import artistTracksReducer from './slices/artistTracksSlice'
|
import artistTracksReducer from './slices/artistTracksSlice'
|
||||||
import trackDetailReducer from './slices/trackDetailSlice'
|
import trackDetailReducer from './slices/trackDetailSlice'
|
||||||
|
import queueReducer from './slices/queueSlice'
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -13,6 +14,7 @@ export const store = configureStore({
|
|||||||
albumTracks: albumTracksReducer,
|
albumTracks: albumTracksReducer,
|
||||||
artistTracks: artistTracksReducer,
|
artistTracks: artistTracksReducer,
|
||||||
trackDetail: trackDetailReducer,
|
trackDetail: trackDetailReducer,
|
||||||
|
queue: queueReducer,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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<number>) {
|
||||||
|
const i = action.payload
|
||||||
|
if (i < 0 || i >= state.items.length) return
|
||||||
|
state.currentIndex = i
|
||||||
|
state.scrollSignal += 1
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromQueueAt(state, action: PayloadAction<number>) {
|
||||||
|
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
|
||||||
@@ -6,7 +6,11 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/auth': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/callback': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { auth } from 'express-openid-connect';
|
import { auth } from 'express-openid-connect';
|
||||||
@@ -28,7 +29,6 @@ const oidcConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
|
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
|
||||||
// Keep a clear startup failure if OIDC is not configured.
|
|
||||||
throw new Error(
|
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)',
|
'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.use(auth(oidcConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/auth/health', (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/me', (req, res) => {
|
app.get('/auth/me', (req, res) => {
|
||||||
if (disableAuth) {
|
if (disableAuth) {
|
||||||
res.json({
|
res.json({
|
||||||
authenticated: false,
|
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) {
|
if (disableAuth) {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
return;
|
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) {
|
if (disableAuth) {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
return;
|
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, () => {
|
app.listen(port, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
|
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
|
||||||
|
|||||||
@@ -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"] }
|
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"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
openidconnect = "3.4"
|
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"
|
sha2 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ struct Args {
|
|||||||
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
|
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
|
||||||
oidc_session_secret: Option<String>,
|
oidc_session_secret: Option<String>,
|
||||||
|
|
||||||
/// API key for x-api-key header auth (alternative to OIDC session)
|
|
||||||
#[arg(long, env = "FURUMI_PLAYER_API_KEY")]
|
|
||||||
api_key: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -94,15 +91,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
if args.api_key.is_some() {
|
|
||||||
tracing::info!("x-api-key auth: enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = Arc::new(web::AppState {
|
let state = Arc::new(web::AppState {
|
||||||
pool,
|
pool,
|
||||||
storage_dir: Arc::new(args.storage_dir),
|
storage_dir: Arc::new(args.storage_dir),
|
||||||
oidc: oidc_state,
|
oidc: oidc_state,
|
||||||
api_key: args.api_key,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("Web player: http://{}", bind_addr);
|
tracing::info!("Web player: http://{}", bind_addr);
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ use axum::{
|
|||||||
extract::{Request, State},
|
extract::{Request, State},
|
||||||
http::{header, HeaderMap, StatusCode},
|
http::{header, HeaderMap, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
const X_API_KEY: &str = "x-api-key";
|
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
||||||
reqwest::async_http_client,
|
reqwest::async_http_client,
|
||||||
@@ -18,17 +17,26 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use hmac::{Hmac, Mac};
|
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 super::AppState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
const SESSION_COOKIE: &str = "furumi_session";
|
const SESSION_COOKIE: &str = "furumi_session";
|
||||||
|
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||||
|
|
||||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||||
|
|
||||||
pub struct OidcState {
|
pub struct OidcState {
|
||||||
pub client: CoreClient,
|
pub client: CoreClient,
|
||||||
pub session_secret: Vec<u8>,
|
pub session_secret: Vec<u8>,
|
||||||
|
jwks_uri: String,
|
||||||
|
issuer_url: String,
|
||||||
|
jwks_cache: RwLock<Option<(JwkSet, Instant)>>,
|
||||||
|
http_client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn oidc_init(
|
pub async fn oidc_init(
|
||||||
@@ -44,6 +52,9 @@ pub async fn oidc_init(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let jwks_uri = provider_metadata.jwks_uri().to_string();
|
||||||
|
let issuer_url = provider_metadata.issuer().to_string();
|
||||||
|
|
||||||
let client = CoreClient::from_provider_metadata(
|
let client = CoreClient::from_provider_metadata(
|
||||||
provider_metadata,
|
provider_metadata,
|
||||||
ClientId::new(client_id),
|
ClientId::new(client_id),
|
||||||
@@ -62,12 +73,70 @@ pub async fn oidc_init(
|
|||||||
b
|
b
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let http_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
tracing::info!("JWKS URI: {}", jwks_uri);
|
||||||
|
|
||||||
Ok(OidcState {
|
Ok(OidcState {
|
||||||
client,
|
client,
|
||||||
session_secret,
|
session_secret,
|
||||||
|
jwks_uri,
|
||||||
|
issuer_url,
|
||||||
|
jwks_cache: RwLock::new(None),
|
||||||
|
http_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OidcState {
|
||||||
|
async fn get_jwks(&self) -> anyhow::Result<JwkSet> {
|
||||||
|
{
|
||||||
|
let cache = self.jwks_cache.read().await;
|
||||||
|
if let Some((ref jwks, fetched_at)) = *cache {
|
||||||
|
if fetched_at.elapsed() < JWKS_CACHE_TTL {
|
||||||
|
return Ok(jwks.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.refresh_jwks().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_jwks(&self) -> anyhow::Result<JwkSet> {
|
||||||
|
tracing::debug!("Fetching JWKS from {}", self.jwks_uri);
|
||||||
|
let jwks: JwkSet = self.http_client.get(&self.jwks_uri).send().await?.json().await?;
|
||||||
|
let mut cache = self.jwks_cache.write().await;
|
||||||
|
*cache = Some((jwks.clone(), Instant::now()));
|
||||||
|
Ok(jwks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct BearerClaims {
|
||||||
|
sub: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String> {
|
||||||
|
let header = decode_header(token).ok()?;
|
||||||
|
let kid = header.kid.as_ref()?;
|
||||||
|
|
||||||
|
let mut jwks = oidc.get_jwks().await.ok()?;
|
||||||
|
let mut jwk = jwks.find(kid);
|
||||||
|
|
||||||
|
// Handle key rotation: refresh JWKS if kid not found
|
||||||
|
if jwk.is_none() {
|
||||||
|
jwks = oidc.refresh_jwks().await.ok()?;
|
||||||
|
jwk = jwks.find(kid);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = DecodingKey::from_jwk(jwk?).ok()?;
|
||||||
|
|
||||||
|
let mut validation = JwtValidation::new(header.alg);
|
||||||
|
validation.set_issuer(&[&oidc.issuer_url]);
|
||||||
|
validation.validate_aud = false;
|
||||||
|
|
||||||
|
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
|
||||||
|
Some(data.claims.sub)
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
|
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
|
||||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||||
mac.update(user_id.as_bytes());
|
mac.update(user_id.as_bytes());
|
||||||
@@ -94,20 +163,22 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auth middleware: requires valid SSO session cookie or x-api-key header.
|
/// Auth middleware: requires valid Bearer JWT or SSO session cookie.
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
req: Request,
|
req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 1. Check x-api-key header (if configured)
|
// 1. Check Bearer token — JWT from OIDC provider
|
||||||
if let Some(ref expected) = state.api_key {
|
if let Some(ref oidc) = state.oidc {
|
||||||
if let Some(val) = req
|
if let Some(token) = req
|
||||||
.headers()
|
.headers()
|
||||||
.get(X_API_KEY)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
{
|
{
|
||||||
if val == expected {
|
if let Some(user_id) = validate_bearer_token(oidc, token).await {
|
||||||
|
tracing::debug!("Bearer auth OK for user: {}", user_id);
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,36 +202,7 @@ pub async fn require_auth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let uri = req.uri().to_string();
|
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||||
if uri.starts_with("/api/") {
|
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
|
||||||
} else if state.oidc.is_some() {
|
|
||||||
Redirect::to("/login").into_response()
|
|
||||||
} else {
|
|
||||||
// Only API key configured — no web login available
|
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /login — show SSO login page.
|
|
||||||
pub async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
||||||
if state.oidc.is_none() {
|
|
||||||
return Redirect::to("/").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
Html(LOGIN_HTML).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /logout — clear session cookie.
|
|
||||||
pub async fn logout() -> impl IntoResponse {
|
|
||||||
let cookie = format!(
|
|
||||||
"{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
|
||||||
SESSION_COOKIE
|
|
||||||
);
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
|
|
||||||
headers.insert(header::LOCATION, "/login".parse().unwrap());
|
|
||||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -335,9 +377,9 @@ pub async fn oidc_callback(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let session_attrs = if is_https {
|
let session_attrs = if is_https {
|
||||||
"SameSite=Strict; Secure"
|
"SameSite=Lax; Secure"
|
||||||
} else {
|
} else {
|
||||||
"SameSite=Strict"
|
"SameSite=Lax"
|
||||||
};
|
};
|
||||||
|
|
||||||
let session_cookie = format!(
|
let session_cookie = format!(
|
||||||
@@ -354,47 +396,3 @@ pub async fn oidc_callback(
|
|||||||
|
|
||||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGIN_HTML: &str = r#"<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Furumi Player — Login</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: #0d0f14;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #161b27;
|
|
||||||
border: 1px solid #2a3347;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 3rem;
|
|
||||||
width: 360px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
|
||||||
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
|
||||||
.btn-sso {
|
|
||||||
display: block; width: 100%; padding: 0.75rem; text-align: center;
|
|
||||||
background: #7c6af7; border: none; border-radius: 8px;
|
|
||||||
color: #fff; font-size: 0.95rem; font-weight: 600; text-decoration: none;
|
|
||||||
cursor: pointer; transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.btn-sso:hover { background: #6b58e8; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="logo">Furumi</div>
|
|
||||||
<div class="subtitle">Sign in to continue</div>
|
|
||||||
<a href="/auth/login" class="btn-sso">SSO Login</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"#;
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ pub struct AppState {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub storage_dir: Arc<PathBuf>,
|
pub storage_dir: Arc<PathBuf>,
|
||||||
pub oidc: Option<Arc<auth::OidcState>>,
|
pub oidc: Option<Arc<auth::OidcState>>,
|
||||||
pub api_key: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||||
@@ -32,37 +31,27 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/stream/:slug", get(api::stream_track))
|
.route("/stream/:slug", get(api::stream_track))
|
||||||
.route("/search", get(api::search));
|
.route("/search", get(api::search));
|
||||||
|
|
||||||
let authed = Router::new()
|
let api = Router::new()
|
||||||
.route("/", get(player_html))
|
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
|
|
||||||
let requires_auth = state.oidc.is_some();
|
let requires_auth = state.oidc.is_some();
|
||||||
|
|
||||||
let app = if requires_auth {
|
let app = if requires_auth {
|
||||||
authed
|
api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
|
||||||
} else {
|
} else {
|
||||||
authed
|
api
|
||||||
};
|
};
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
|
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
|
||||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")])
|
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||||
.max_age(Duration::from_secs(600));
|
.max_age(Duration::from_secs(600));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", get(auth::login_page))
|
|
||||||
.route("/logout", get(auth::logout))
|
|
||||||
.route("/auth/login", get(auth::oidc_login))
|
.route("/auth/login", get(auth::oidc_login))
|
||||||
.route("/auth/callback", get(auth::oidc_callback))
|
.route("/auth/callback", get(auth::oidc_callback))
|
||||||
.merge(app)
|
.merge(app)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn player_html() -> axum::response::Html<String> {
|
|
||||||
let html = include_str!("player.html")
|
|
||||||
.replace("<!-- VERSION_PLACEHOLDER -->", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")));
|
|
||||||
axum::response::Html(html)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user