Compare commits
16 Commits
3f2013e9d5
...
DEV
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea4aef4b2 | ||
|
|
8ceee6028a | ||
|
|
3491c52793 | ||
|
|
f0e1bbc7f8 | ||
|
|
8f38e27eb0 | ||
|
|
8cac2d1160 | ||
|
|
5a5dab85d0 | ||
|
|
310f0061d3 | ||
|
|
f26135ca25 | ||
| 71d5a38f21 | |||
| 8d70a5133a | |||
| 56760be586 | |||
| 108c374c6d | |||
| 2129dc8007 | |||
|
|
c30a3aff5d | ||
|
|
71d88bacf2 |
32
.cursor/rules/api-conventions.mdc
Normal file
32
.cursor/rules/api-conventions.mdc
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client)
|
||||||
|
globs: furumi-node-player/client/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# REST API в furumi-node-player
|
||||||
|
|
||||||
|
**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход.
|
||||||
|
|
||||||
|
Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей.
|
||||||
|
2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта.
|
||||||
|
3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке).
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// furumiApi.ts — добавлять сюда
|
||||||
|
export async function getSomething(id: string) {
|
||||||
|
const res = await furumiApi.get(`/something/${id}`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую
|
||||||
|
const data = await getSomething(id)
|
||||||
|
```
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.agent
|
file: docker/Dockerfile.agent
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
2
.github/workflows/docker-publish-agent.yml
vendored
2
.github/workflows/docker-publish-agent.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.agent
|
file: docker/Dockerfile.agent
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.web-player
|
file: docker/Dockerfile.web-player
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
2
.github/workflows/docker-publish-player.yml
vendored
2
.github/workflows/docker-publish-player.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.web-player
|
file: docker/Dockerfile.web-player
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
@@ -53,6 +53,7 @@ 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"
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
restart: always
|
restart: always
|
||||||
@@ -4,14 +4,14 @@ You are a music library artist merge assistant. You will receive a list of artis
|
|||||||
|
|
||||||
You will receive a structured list like:
|
You will receive a structured list like:
|
||||||
|
|
||||||
### Artist ID 42: "pink floyd"
|
### Artist ID 42: "deep purple"
|
||||||
Album ID 10: "the wall" (1979)
|
Album ID 10: "machine head" (1972)
|
||||||
- 01. "In the Flesh?" [track_id=100]
|
- 01. "Highway Star" [track_id=100]
|
||||||
- 02. "The Thin Ice" [track_id=101]
|
- 02. "Maybe I'm a Leo" [track_id=101]
|
||||||
|
|
||||||
### Artist ID 43: "Pink Floyd"
|
### Artist ID 43: "Deep Purple"
|
||||||
Album ID 11: "Wish You Were Here" (1975)
|
Album ID 11: "Burn" (1974)
|
||||||
- 01. "Shine On You Crazy Diamond (Parts I-V)" [track_id=200]
|
- 01. "Burn" [track_id=200]
|
||||||
|
|
||||||
## Your task
|
## Your task
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Determine if the artists are duplicates and produce a merge plan.
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
### 1. Canonical artist name
|
### 1. Canonical artist name
|
||||||
- Use correct capitalization and canonical spelling (e.g., "pink floyd" → "Pink Floyd", "AC DC" → "AC/DC").
|
- Use correct capitalization and canonical spelling (e.g., "deep purple" → "Deep Purple", "AC DC" → "AC/DC").
|
||||||
- If the database already contains an artist with a well-formed name, prefer that exact form.
|
- If the database already contains an artist with a well-formed name, prefer that exact form.
|
||||||
- If one artist has clearly more tracks or albums, their name spelling may be more authoritative.
|
- If one artist has clearly more tracks or albums, their name spelling may be more authoritative.
|
||||||
- Fix obvious typos or casing errors.
|
- Fix obvious typos or casing errors.
|
||||||
@@ -54,7 +54,7 @@ Determine if the artists are duplicates and produce a merge plan.
|
|||||||
|
|
||||||
You MUST respond with a single JSON object, no markdown fences, no extra text:
|
You MUST respond with a single JSON object, no markdown fences, no extra text:
|
||||||
|
|
||||||
{"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "The Wall", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Wish You Were Here", "merge_into_album_id": null}], "notes": "..."}
|
{"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "Machine Head", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Burn", "merge_into_album_id": null}], "notes": "..."}
|
||||||
|
|
||||||
- `canonical_artist_name`: the single correct name for this artist after merging.
|
- `canonical_artist_name`: the single correct name for this artist after merging.
|
||||||
- `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided).
|
- `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided).
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
|
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
|
||||||
- "pink floyd" → "Pink Floyd"
|
- "deep purple" → "Deep Purple"
|
||||||
- "AC DC" → "AC/DC"
|
- "AC DC" → "AC/DC"
|
||||||
- "Guns n roses" → "Guns N' Roses"
|
- "guns n roses" → "Guns N' Roses"
|
||||||
- "Led zepplin" → "Led Zeppelin" (fix common misspellings)
|
- "led zepplin" → "Led Zeppelin" (fix common misspellings)
|
||||||
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
|
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
|
||||||
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
|
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
|
||||||
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
|
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
|
||||||
@@ -43,12 +43,12 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
|||||||
- Preserve original language for non-English albums.
|
- Preserve original language for non-English albums.
|
||||||
- If the database already contains a matching album under the same artist, use the existing name exactly.
|
- If the database already contains a matching album under the same artist, use the existing name exactly.
|
||||||
- Do not alter the creative content of album names (same principle as track titles).
|
- Do not alter the creative content of album names (same principle as track titles).
|
||||||
- **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "The Wall" (1979) remastered in 2011 → album: "The Wall (Remastered)", year: 2011.
|
- **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "Paranoid" (1970) remastered in 2009 → album: "Paranoid (Remastered)", year: 2009.
|
||||||
|
|
||||||
4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
|
4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
|
||||||
- Use title case for English titles.
|
- Use title case for English titles.
|
||||||
- Preserve original language for non-English titles.
|
- Preserve original language for non-English titles.
|
||||||
- Remove leading track numbers if present (e.g., "01 - Have a Cigar" → "Have a Cigar").
|
- Remove leading track numbers if present (e.g., "01 - Smoke on the Water" → "Smoke on the Water").
|
||||||
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content. If a title contains unusual punctuation, numbers, apostrophes, or symbols — they are intentional and must be kept as-is.
|
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content. If a title contains unusual punctuation, numbers, apostrophes, or symbols — they are intentional and must be kept as-is.
|
||||||
- If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names.
|
- If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names.
|
||||||
|
|
||||||
|
|||||||
@@ -188,8 +188,20 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
|
// Source file is gone — check if already in library by hash
|
||||||
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
|
let in_library: (bool,) = sqlx::query_as(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM tracks WHERE file_hash = $1)"
|
||||||
|
)
|
||||||
|
.bind(&pt.file_hash)
|
||||||
|
.fetch_one(&state.pool).await.unwrap_or((false,));
|
||||||
|
|
||||||
|
if in_library.0 {
|
||||||
|
tracing::info!(id = %pt.id, "Source missing but track already in library — merging");
|
||||||
|
db::update_pending_status(&state.pool, pt.id, "merged", None).await?;
|
||||||
|
} else {
|
||||||
|
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
|
||||||
|
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2
furumi-node-player/client/.env.example
Normal file
2
furumi-node-player/client/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8085
|
||||||
|
VITE_API_KEY=
|
||||||
395
furumi-node-player/client/package-lock.json
generated
395
furumi-node-player/client/package-lock.json
generated
@@ -8,8 +8,11 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
|
"axios": "^1.7.9",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-redux": "^9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -586,6 +589,32 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.10",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||||
@@ -848,6 +877,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -887,7 +928,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -903,6 +944,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||||
@@ -1287,6 +1334,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.11",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -1352,6 +1416,19 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1420,6 +1497,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -1453,7 +1542,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -1481,6 +1570,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1491,6 +1589,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.321",
|
"version": "1.5.321",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
||||||
@@ -1498,6 +1610,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1795,6 +1952,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1810,6 +2003,15 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -1820,6 +2022,43 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -1846,6 +2085,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -1856,6 +2107,45 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -1883,6 +2173,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -2325,6 +2625,36 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -2520,6 +2850,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -2551,6 +2887,50 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -2814,6 +3194,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
|
"axios": "^1.7.9",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-redux": "^9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { FurumiPlayer } from './FurumiPlayer'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
@@ -62,72 +63,78 @@ function App() {
|
|||||||
const logoutUrl = `${apiBase}/api/logout`
|
const logoutUrl = `${apiBase}/api/logout`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<>
|
||||||
<section className="card">
|
{!loading && (user || runWithoutAuth) ? (
|
||||||
<h1>OIDC Login</h1>
|
<FurumiPlayer />
|
||||||
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
) : (
|
||||||
|
<main className="page">
|
||||||
|
<section className="card">
|
||||||
|
<h1>OIDC Login</h1>
|
||||||
|
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
||||||
|
|
||||||
<div className="settings">
|
<div className="settings">
|
||||||
<label className="toggle">
|
<label className="toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={runWithoutAuth}
|
checked={runWithoutAuth}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.checked
|
const next = e.target.checked
|
||||||
setRunWithoutAuth(next)
|
setRunWithoutAuth(next)
|
||||||
try {
|
try {
|
||||||
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
|
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
|
||||||
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
|
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>Запускать без авторизации</span>
|
<span>Запускать без авторизации</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p>Проверяю сессию...</p>}
|
{loading && <p>Проверяю сессию...</p>}
|
||||||
{error && <p className="error">Ошибка: {error}</p>}
|
{error && <p className="error">Ошибка: {error}</p>}
|
||||||
|
|
||||||
{!loading && runWithoutAuth && (
|
{!loading && runWithoutAuth && (
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
Режим без авторизации включён. Для входа отключи настройку выше.
|
Режим без авторизации включён. Для входа отключи настройку выше.
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !user && (
|
|
||||||
<a className="btn" href={loginUrl}>
|
|
||||||
Войти через OIDC
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && user && (
|
|
||||||
<div className="profile">
|
|
||||||
<p>
|
|
||||||
<strong>ID:</strong> {user.sub}
|
|
||||||
</p>
|
|
||||||
{user.name && (
|
|
||||||
<p>
|
|
||||||
<strong>Имя:</strong> {user.name}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{user.email && (
|
|
||||||
<p>
|
{!loading && !user && (
|
||||||
<strong>Email:</strong> {user.email}
|
<a className="btn" href={loginUrl}>
|
||||||
</p>
|
Войти через OIDC
|
||||||
)}
|
|
||||||
{!runWithoutAuth && (
|
|
||||||
<a className="btn ghost" href={logoutUrl}>
|
|
||||||
Выйти
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
{!loading && user && (
|
||||||
</section>
|
<div className="profile">
|
||||||
</main>
|
<p>
|
||||||
|
<strong>ID:</strong> {user.sub}
|
||||||
|
</p>
|
||||||
|
{user.name && (
|
||||||
|
<p>
|
||||||
|
<strong>Имя:</strong> {user.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{user.email && (
|
||||||
|
<p>
|
||||||
|
<strong>Email:</strong> {user.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!runWithoutAuth && (
|
||||||
|
<a className="btn ghost" href={logoutUrl}>
|
||||||
|
Выйти
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
689
furumi-node-player/client/src/FurumiPlayer.tsx
Normal file
689
furumi-node-player/client/src/FurumiPlayer.tsx
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
|
import './furumi-player.css'
|
||||||
|
import {
|
||||||
|
API_ROOT,
|
||||||
|
searchTracks,
|
||||||
|
preloadStream,
|
||||||
|
} from './furumiApi'
|
||||||
|
import { useAppDispatch, useAppSelector } from './store'
|
||||||
|
import { fetchArtists } from './store/slices/artistsSlice'
|
||||||
|
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
||||||
|
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
|
||||||
|
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
|
||||||
|
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
|
||||||
|
import { fmt } from './utils'
|
||||||
|
import { Header } from './components/Header'
|
||||||
|
import { MainPanel, type Crumb } from './components/MainPanel'
|
||||||
|
import { PlayerBar } from './components/PlayerBar'
|
||||||
|
import type { QueueItem } from './components/QueueList'
|
||||||
|
import type { Track } from './types'
|
||||||
|
|
||||||
|
export function FurumiPlayer() {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const artistsLoading = useAppSelector((s) => s.artists.loading)
|
||||||
|
const artistsError = useAppSelector((s) => s.artists.error)
|
||||||
|
const albumsLoading = useAppSelector((s) => s.albums.loading)
|
||||||
|
const albumsError = useAppSelector((s) => s.albums.error)
|
||||||
|
const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading)
|
||||||
|
const albumTracksError = useAppSelector((s) => s.albumTracks.error)
|
||||||
|
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const [libraryItems, setLibraryItems] = useState<
|
||||||
|
Array<{
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
const [searchResults, setSearchResults] = useState<
|
||||||
|
Array<{ result_type: string; slug: string; name: string; detail?: string }>
|
||||||
|
>([])
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
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<{
|
||||||
|
playIndex: (i: number) => void
|
||||||
|
removeFromQueue: (idx: number) => void
|
||||||
|
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// --- Original player script adapted for React environment ---
|
||||||
|
const audioEl = audioRef.current
|
||||||
|
if (!audioEl) return
|
||||||
|
const audio = audioEl
|
||||||
|
|
||||||
|
let queueIndex = -1
|
||||||
|
let shuffle = false
|
||||||
|
let repeatAll = true
|
||||||
|
let shuffleOrder: number[] = []
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
let toastTimer: number | null = null
|
||||||
|
let muted = false
|
||||||
|
|
||||||
|
// Restore prefs
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
||||||
|
try {
|
||||||
|
const artists = await dispatch(fetchArtists()).unwrap()
|
||||||
|
setLibraryItems(
|
||||||
|
artists.map((a) => ({
|
||||||
|
key: `artist:${a.slug}`,
|
||||||
|
className: 'file-item dir',
|
||||||
|
icon: '👤',
|
||||||
|
name: a.name,
|
||||||
|
detail: `${a.album_count} albums`,
|
||||||
|
onClick: () => void showArtistAlbums(a.slug, a.name),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Error is stored in artists.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showArtistAlbums(artistSlug: string, artistName: string) {
|
||||||
|
setBreadcrumb([
|
||||||
|
{ label: 'Artists', action: showArtists },
|
||||||
|
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap()
|
||||||
|
const allTracksItem = {
|
||||||
|
key: `artist-all:${artistSlug}`,
|
||||||
|
className: 'file-item',
|
||||||
|
icon: '▶',
|
||||||
|
name: 'Play all tracks',
|
||||||
|
nameClassName: 'name',
|
||||||
|
onClick: () => void playAllArtistTracks(artistSlug),
|
||||||
|
}
|
||||||
|
const albumItems = albums.map((a) => {
|
||||||
|
const year = a.year ? ` (${a.year})` : ''
|
||||||
|
return {
|
||||||
|
key: `album:${a.slug}`,
|
||||||
|
className: 'file-item dir',
|
||||||
|
icon: '💿',
|
||||||
|
name: `${a.name}${year}`,
|
||||||
|
detail: `${a.track_count} tracks`,
|
||||||
|
onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName),
|
||||||
|
button: {
|
||||||
|
title: 'Add album to queue',
|
||||||
|
onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
void addAlbumToQueue(a.slug)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setLibraryItems([allTracksItem, ...albumItems])
|
||||||
|
} catch {
|
||||||
|
// Error is stored in albums.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAlbumTracks(
|
||||||
|
albumSlug: string,
|
||||||
|
albumName: string,
|
||||||
|
artistSlug: string,
|
||||||
|
artistName: string,
|
||||||
|
) {
|
||||||
|
setBreadcrumb([
|
||||||
|
{ label: 'Artists', action: showArtists },
|
||||||
|
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
||||||
|
{ label: albumName },
|
||||||
|
])
|
||||||
|
const result = await dispatch(fetchAlbumTracks(albumSlug))
|
||||||
|
if (result.meta.requestStatus === 'rejected') return
|
||||||
|
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
||||||
|
const playAlbumItem = {
|
||||||
|
key: `album-play:${albumSlug}`,
|
||||||
|
className: 'file-item',
|
||||||
|
icon: '▶',
|
||||||
|
name: 'Play album',
|
||||||
|
onClick: () => {
|
||||||
|
void addAlbumToQueue(albumSlug, true)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const trackItems = tracks.map((t) => {
|
||||||
|
const num = t.track_number ? `${t.track_number}. ` : ''
|
||||||
|
const dur = t.duration_secs ? fmt(t.duration_secs) : ''
|
||||||
|
return {
|
||||||
|
key: `track:${t.slug}`,
|
||||||
|
className: 'file-item',
|
||||||
|
icon: '🎵',
|
||||||
|
name: `${num}${t.title}`,
|
||||||
|
detail: dur,
|
||||||
|
onClick: () => {
|
||||||
|
addTrackToQueue(
|
||||||
|
{
|
||||||
|
slug: t.slug,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist_name,
|
||||||
|
album_slug: albumSlug,
|
||||||
|
duration: t.duration_secs,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setLibraryItems([playAlbumItem, ...trackItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBreadcrumb(parts: Crumb[]) {
|
||||||
|
setBreadcrumbs(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queue management ---
|
||||||
|
function addTrackToQueue(
|
||||||
|
track: {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
album_slug: string | null
|
||||||
|
duration: number | null
|
||||||
|
},
|
||||||
|
playNow?: boolean,
|
||||||
|
) {
|
||||||
|
const existing = queue.findIndex((t) => t.slug === track.slug)
|
||||||
|
if (existing !== -1) {
|
||||||
|
if (playNow) playIndex(existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQueue((q) => [...q, track]);
|
||||||
|
updateQueueModel()
|
||||||
|
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||||
|
playIndex(queue.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
|
||||||
|
const result = await dispatch(fetchAlbumTracks(albumSlug))
|
||||||
|
if (result.meta.requestStatus === 'rejected') return
|
||||||
|
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
||||||
|
if (!tracks || !tracks.length) return
|
||||||
|
const list = tracks
|
||||||
|
let firstIdx = queue.length
|
||||||
|
list.forEach((t) => {
|
||||||
|
if (queue.find((q) => q.slug === t.slug)) return
|
||||||
|
setQueue((q) => [
|
||||||
|
...q,
|
||||||
|
{
|
||||||
|
slug: t.slug,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist_name,
|
||||||
|
album_slug: t.album_slug,
|
||||||
|
duration: t.duration_secs,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
updateQueueModel()
|
||||||
|
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||||
|
showToast(`Added ${list.length} tracks`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playAllArtistTracks(artistSlug: string) {
|
||||||
|
const result = await dispatch(fetchArtistTracks(artistSlug))
|
||||||
|
if (result.meta.requestStatus === 'rejected') return
|
||||||
|
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
|
||||||
|
if (!tracks || !tracks.length) return
|
||||||
|
const list = tracks
|
||||||
|
clearQueue()
|
||||||
|
setQueue(list.map((t) => ({
|
||||||
|
slug: t.slug,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist_name,
|
||||||
|
album_slug: t.album_slug,
|
||||||
|
duration: t.duration_secs,
|
||||||
|
})))
|
||||||
|
updateQueueModel()
|
||||||
|
playIndex(0)
|
||||||
|
showToast(`Added ${list.length} tracks`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function playIndex(i: number) {
|
||||||
|
if (i < 0 || i >= queue.length) return
|
||||||
|
queueIndex = i
|
||||||
|
const track = queue[i]
|
||||||
|
audio.src = `${API_ROOT}/stream/${track.slug}`
|
||||||
|
void audio.play().catch(() => {})
|
||||||
|
updateNowPlaying(track)
|
||||||
|
updateQueueModel()
|
||||||
|
setQueueScrollSignal((s) => s + 1)
|
||||||
|
if (window.history && window.history.replaceState) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('t', track.slug)
|
||||||
|
window.history.replaceState(null, '', url.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (idx === queueIndex) {
|
||||||
|
queueIndex = -1
|
||||||
|
audio.pause()
|
||||||
|
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) {
|
||||||
|
if (from === to) return
|
||||||
|
if (shuffle) {
|
||||||
|
const item = shuffleOrder.splice(from, 1)[0]
|
||||||
|
shuffleOrder.splice(to, 0, item)
|
||||||
|
} else {
|
||||||
|
const item = queue.splice(from, 1)[0]
|
||||||
|
queue.splice(to, 0, item)
|
||||||
|
if (queueIndex === from) queueIndex = to
|
||||||
|
else if (from < queueIndex && to >= queueIndex) queueIndex--
|
||||||
|
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
||||||
|
}
|
||||||
|
updateQueueModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
queueActionsRef.current = {
|
||||||
|
playIndex,
|
||||||
|
removeFromQueue,
|
||||||
|
moveQueueItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (searchTimer) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
if (q.length < 2) {
|
||||||
|
closeSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
const results = await searchTracks(q)
|
||||||
|
if (!results || !(results as any[]).length) {
|
||||||
|
closeSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchResults(results as any[])
|
||||||
|
setSearchOpen(true)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
setSearchOpen(false)
|
||||||
|
setSearchResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchSelect(type: string, slug: string) {
|
||||||
|
closeSearch()
|
||||||
|
if (type === 'artist') void showArtistAlbums(slug, '')
|
||||||
|
else if (type === 'album') void addAlbumToQueue(slug, true)
|
||||||
|
else if (type === 'track') {
|
||||||
|
addTrackToQueue(
|
||||||
|
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
void preloadStream(slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
const sidebar = document.getElementById('sidebar')
|
||||||
|
const overlay = document.getElementById('sidebarOverlay')
|
||||||
|
sidebar?.classList.toggle('open')
|
||||||
|
overlay?.classList.toggle('show')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MediaSession ---
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setActionHandler('play', togglePlay)
|
||||||
|
navigator.mediaSession.setActionHandler('pause', togglePlay)
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', (d: any) => {
|
||||||
|
if (typeof d.seekTime === 'number') {
|
||||||
|
audio.currentTime = d.seekTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire DOM events that were inline in HTML ---
|
||||||
|
const btnMenu = document.querySelector('.btn-menu')
|
||||||
|
btnMenu?.addEventListener('click', () => toggleSidebar())
|
||||||
|
|
||||||
|
const sidebarOverlay = document.getElementById('sidebarOverlay')
|
||||||
|
sidebarOverlay?.addEventListener('click', () => toggleSidebar())
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
onSearch((e.target as HTMLInputElement).value)
|
||||||
|
})
|
||||||
|
searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeSearch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnShuffle = document.getElementById('btnShuffle')
|
||||||
|
btnShuffle?.addEventListener('click', () => toggleShuffle())
|
||||||
|
const btnRepeat = document.getElementById('btnRepeat')
|
||||||
|
btnRepeat?.addEventListener('click', () => toggleRepeat())
|
||||||
|
const btnClear = document.getElementById('btnClearQueue')
|
||||||
|
btnClear?.addEventListener('click', () => clearQueue())
|
||||||
|
|
||||||
|
const btnPrev = document.getElementById('btnPrev')
|
||||||
|
btnPrev?.addEventListener('click', () => prevTrack())
|
||||||
|
const btnPlay = document.getElementById('btnPlayPause')
|
||||||
|
btnPlay?.addEventListener('click', () => togglePlay())
|
||||||
|
const btnNext = document.getElementById('btnNext')
|
||||||
|
btnNext?.addEventListener('click', () => nextTrack())
|
||||||
|
|
||||||
|
const progressBar = document.getElementById('progressBar')
|
||||||
|
progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent))
|
||||||
|
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
volIcon?.addEventListener('click', () => toggleMute())
|
||||||
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
|
if (volSlider) {
|
||||||
|
volSlider.addEventListener('input', (e) => {
|
||||||
|
const v = Number((e.target as HTMLInputElement).value)
|
||||||
|
setVolume(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearQueueBtn = document.getElementById('btnClearQueue')
|
||||||
|
clearQueueBtn?.addEventListener('click', () => clearQueue())
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
;(async () => {
|
||||||
|
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()
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Cleanup: best-effort remove listeners on unmount
|
||||||
|
return () => {
|
||||||
|
queueActionsRef.current = null
|
||||||
|
audio.pause()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const libraryLoading =
|
||||||
|
breadcrumbs.length === 1
|
||||||
|
? artistsLoading
|
||||||
|
: breadcrumbs.length === 2
|
||||||
|
? albumsLoading
|
||||||
|
: albumTracksLoading
|
||||||
|
|
||||||
|
const libraryError =
|
||||||
|
breadcrumbs.length === 1
|
||||||
|
? artistsError
|
||||||
|
: breadcrumbs.length === 2
|
||||||
|
? albumsError
|
||||||
|
: albumTracksError
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="furumi-root">
|
||||||
|
<Header
|
||||||
|
searchOpen={searchOpen}
|
||||||
|
searchResults={searchResults}
|
||||||
|
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MainPanel
|
||||||
|
breadcrumbs={breadcrumbs}
|
||||||
|
libraryLoading={libraryLoading}
|
||||||
|
libraryError={libraryError}
|
||||||
|
libraryItems={libraryItems}
|
||||||
|
queueItemsView={queueItemsView}
|
||||||
|
queueOrderView={queueOrderView}
|
||||||
|
queuePlayingOrigIdxView={queuePlayingOrigIdxView}
|
||||||
|
queueScrollSignal={queueScrollSignal}
|
||||||
|
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||||
|
onQueueRemove={(origIdx) =>
|
||||||
|
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||||
|
}
|
||||||
|
onQueueMove={(fromPos, toPos) =>
|
||||||
|
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlayerBar track={nowPlayingTrack} />
|
||||||
|
|
||||||
|
<div className="toast" id="toast" />
|
||||||
|
<audio ref={audioRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
30
furumi-node-player/client/src/components/Breadcrumbs.tsx
Normal file
30
furumi-node-player/client/src/components/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
type Crumb = {
|
||||||
|
label: string
|
||||||
|
action?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type BreadcrumbsProps = {
|
||||||
|
items: Crumb[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||||
|
if (!items.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="breadcrumb">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1
|
||||||
|
return (
|
||||||
|
<span key={`${item.label}-${index}`}>
|
||||||
|
{!isLast && item.action ? (
|
||||||
|
<span onClick={item.action}>{item.label}</span>
|
||||||
|
) : (
|
||||||
|
<span>{item.label}</span>
|
||||||
|
)}
|
||||||
|
{!isLast ? ' / ' : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
furumi-node-player/client/src/components/Header.tsx
Normal file
45
furumi-node-player/client/src/components/Header.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { SearchDropdown } from './SearchDropdown'
|
||||||
|
|
||||||
|
type SearchResultItem = {
|
||||||
|
result_type: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
searchOpen: boolean
|
||||||
|
searchResults: SearchResultItem[]
|
||||||
|
onSearchSelect: (type: string, slug: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
searchOpen,
|
||||||
|
searchResults,
|
||||||
|
onSearchSelect,
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-logo">
|
||||||
|
<button className="btn-menu">☰</button>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="9" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="15" r="3" />
|
||||||
|
<path d="M12 18V6l9-3v3" />
|
||||||
|
</svg>
|
||||||
|
Furumi
|
||||||
|
<span className="header-version">v</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<div className="search-wrap">
|
||||||
|
<input id="searchInput" placeholder="Search..." />
|
||||||
|
<SearchDropdown
|
||||||
|
isOpen={searchOpen}
|
||||||
|
results={searchResults}
|
||||||
|
onSelect={onSearchSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
furumi-node-player/client/src/components/LibraryList.tsx
Normal file
54
furumi-node-player/client/src/components/LibraryList.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { MouseEvent } from 'react'
|
||||||
|
|
||||||
|
type LibraryListButton = {
|
||||||
|
title: string
|
||||||
|
onClick: (ev: MouseEvent<HTMLButtonElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryListItem = {
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: LibraryListButton
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryListProps = {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
items: LibraryListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryList({ loading, error, items }: LibraryListProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.key} className={item.className} onClick={item.onClick}>
|
||||||
|
<span className="icon">{item.icon}</span>
|
||||||
|
<span className={item.nameClassName ?? 'name'}>{item.name}</span>
|
||||||
|
{item.detail ? <span className="detail">{item.detail}</span> : null}
|
||||||
|
{item.button ? (
|
||||||
|
<button className="add-btn" title={item.button.title} onClick={item.button.onClick}>
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
furumi-node-player/client/src/components/MainPanel.tsx
Normal file
86
furumi-node-player/client/src/components/MainPanel.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||||
|
import { Breadcrumbs } from './Breadcrumbs'
|
||||||
|
import { LibraryList } from './LibraryList'
|
||||||
|
import { QueueList, type QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
export type Crumb = { label: string; action?: () => void }
|
||||||
|
|
||||||
|
export type LibraryListItem = {
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
type MainPanelProps = {
|
||||||
|
breadcrumbs: Crumb[]
|
||||||
|
libraryLoading: boolean
|
||||||
|
libraryError: string | null
|
||||||
|
libraryItems: LibraryListItem[]
|
||||||
|
queueItemsView: QueueItem[]
|
||||||
|
queueOrderView: number[]
|
||||||
|
queuePlayingOrigIdxView: number
|
||||||
|
queueScrollSignal: number
|
||||||
|
onQueuePlay: (origIdx: number) => void
|
||||||
|
onQueueRemove: (origIdx: number) => void
|
||||||
|
onQueueMove: (fromPos: number, toPos: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainPanel({
|
||||||
|
breadcrumbs,
|
||||||
|
libraryLoading,
|
||||||
|
libraryError,
|
||||||
|
libraryItems,
|
||||||
|
queueItemsView,
|
||||||
|
queueOrderView,
|
||||||
|
queuePlayingOrigIdxView,
|
||||||
|
queueScrollSignal,
|
||||||
|
onQueuePlay,
|
||||||
|
onQueueRemove,
|
||||||
|
onQueueMove,
|
||||||
|
}: MainPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="main">
|
||||||
|
<div className="sidebar-overlay" id="sidebarOverlay" />
|
||||||
|
<aside className="sidebar" id="sidebar">
|
||||||
|
<div className="sidebar-header">Library</div>
|
||||||
|
<Breadcrumbs items={breadcrumbs} />
|
||||||
|
<div className="file-list" id="fileList">
|
||||||
|
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="queue-panel">
|
||||||
|
<div className="queue-header">
|
||||||
|
<span>Queue</span>
|
||||||
|
<div className="queue-actions">
|
||||||
|
<button className="queue-btn active" id="btnShuffle">
|
||||||
|
Shuffle
|
||||||
|
</button>
|
||||||
|
<button className="queue-btn active" id="btnRepeat">
|
||||||
|
Repeat
|
||||||
|
</button>
|
||||||
|
<button className="queue-btn" id="btnClearQueue">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="queue-list" id="queueList">
|
||||||
|
<QueueList
|
||||||
|
queue={queueItemsView}
|
||||||
|
order={queueOrderView}
|
||||||
|
playingOrigIdx={queuePlayingOrigIdxView}
|
||||||
|
scrollSignal={queueScrollSignal}
|
||||||
|
onPlay={onQueuePlay}
|
||||||
|
onRemove={onQueueRemove}
|
||||||
|
onMove={onQueueMove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
furumi-node-player/client/src/components/NowPlaying.tsx
Normal file
53
furumi-node-player/client/src/components/NowPlaying.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { API_ROOT } from '../furumiApi'
|
||||||
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
function Cover({ src }: { src: string }) {
|
||||||
|
const [errored, setErrored] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
if (errored) return <>🎵</>
|
||||||
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||||
|
if (!track) {
|
||||||
|
return (
|
||||||
|
<div className="np-info">
|
||||||
|
<div className="np-cover" id="npCover">
|
||||||
|
🎵
|
||||||
|
</div>
|
||||||
|
<div className="np-text">
|
||||||
|
<div className="np-title" id="npTitle">
|
||||||
|
Nothing playing
|
||||||
|
</div>
|
||||||
|
<div className="np-artist" id="npArtist">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="np-info">
|
||||||
|
<div className="np-cover" id="npCover">
|
||||||
|
<Cover src={coverUrl} />
|
||||||
|
</div>
|
||||||
|
<div className="np-text">
|
||||||
|
<div className="np-title" id="npTitle">
|
||||||
|
{track.title}
|
||||||
|
</div>
|
||||||
|
<div className="np-artist" id="npArtist">
|
||||||
|
{track.artist || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
47
furumi-node-player/client/src/components/PlayerBar.tsx
Normal file
47
furumi-node-player/client/src/components/PlayerBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NowPlaying } from './NowPlaying'
|
||||||
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
export function PlayerBar({ track }: { track: QueueItem | null }) {
|
||||||
|
return (
|
||||||
|
<div className="player-bar">
|
||||||
|
<NowPlaying track={track} />
|
||||||
|
<div className="controls">
|
||||||
|
<div className="ctrl-btns">
|
||||||
|
<button className="ctrl-btn" id="btnPrev">
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn" id="btnNext">
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="progress-row">
|
||||||
|
<span className="time" id="timeElapsed">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
<div className="progress-bar" id="progressBar">
|
||||||
|
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
|
||||||
|
</div>
|
||||||
|
<span className="time" id="timeDuration">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="volume-row">
|
||||||
|
<span className="vol-icon" id="volIcon">
|
||||||
|
🔊
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="volume-slider"
|
||||||
|
id="volSlider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
defaultValue={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
furumi-node-player/client/src/components/QueueList.tsx
Normal file
143
furumi-node-player/client/src/components/QueueList.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { API_ROOT } from '../furumiApi'
|
||||||
|
|
||||||
|
export type QueueItem = {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
album_slug: string | null
|
||||||
|
duration: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueListProps = {
|
||||||
|
queue: QueueItem[]
|
||||||
|
order: number[]
|
||||||
|
playingOrigIdx: number
|
||||||
|
scrollSignal: number
|
||||||
|
onPlay: (origIdx: number) => void
|
||||||
|
onRemove: (origIdx: number) => void
|
||||||
|
onMove: (fromPos: number, toPos: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(secs: number) {
|
||||||
|
if (!secs || Number.isNaN(secs)) return '0:00'
|
||||||
|
const s = Math.floor(secs)
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h > 0) return `${h}:${pad(m % 60)}:${pad(s % 60)}`
|
||||||
|
return `${m}:${pad(s % 60)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cover({ src }: { src: string }) {
|
||||||
|
const [errored, setErrored] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
if (errored) return <>🎵</>
|
||||||
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueueList({
|
||||||
|
queue,
|
||||||
|
order,
|
||||||
|
playingOrigIdx,
|
||||||
|
scrollSignal,
|
||||||
|
onPlay,
|
||||||
|
onRemove,
|
||||||
|
onMove,
|
||||||
|
}: QueueListProps) {
|
||||||
|
const playingRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [draggingPos, setDraggingPos] = useState<number | null>(null)
|
||||||
|
const [dragOverPos, setDragOverPos] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playingRef.current) {
|
||||||
|
playingRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}, [playingOrigIdx, scrollSignal])
|
||||||
|
|
||||||
|
if (!queue.length) {
|
||||||
|
return (
|
||||||
|
<div className="queue-empty">
|
||||||
|
<div className="empty-icon">🎵</div>
|
||||||
|
<div>Select an album to start</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{order.map((origIdx, pos) => {
|
||||||
|
const t = queue[origIdx]
|
||||||
|
if (!t) return null
|
||||||
|
|
||||||
|
const isPlaying = origIdx === playingOrigIdx
|
||||||
|
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
|
||||||
|
const dur = t.duration ? fmt(t.duration) : ''
|
||||||
|
const isDragging = draggingPos === pos
|
||||||
|
const isDragOver = dragOverPos === pos
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${t.slug}:${pos}`}
|
||||||
|
ref={isPlaying ? playingRef : null}
|
||||||
|
className={`queue-item${isPlaying ? ' playing' : ''}${isDragging ? ' dragging' : ''}${
|
||||||
|
isDragOver ? ' drag-over' : ''
|
||||||
|
}`}
|
||||||
|
draggable
|
||||||
|
onClick={() => onPlay(origIdx)}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
setDraggingPos(pos)
|
||||||
|
e.dataTransfer?.setData('text/plain', String(pos))
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggingPos(null)
|
||||||
|
setDragOverPos(null)
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
onDragEnter={() => {
|
||||||
|
setDragOverPos(pos)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => {
|
||||||
|
setDragOverPos((cur) => (cur === pos ? null : cur))
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOverPos(null)
|
||||||
|
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
|
||||||
|
if (!Number.isNaN(from)) onMove(from, pos)
|
||||||
|
setDraggingPos(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||||
|
<div className="qi-cover">
|
||||||
|
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||||
|
</div>
|
||||||
|
<div className="qi-info">
|
||||||
|
<div className="qi-title">{t.title}</div>
|
||||||
|
<div className="qi-artist">{t.artist || ''}</div>
|
||||||
|
</div>
|
||||||
|
<span className="qi-dur">{dur}</span>
|
||||||
|
<button
|
||||||
|
className="qi-remove"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove(origIdx)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
30
furumi-node-player/client/src/components/SearchDropdown.tsx
Normal file
30
furumi-node-player/client/src/components/SearchDropdown.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
type SearchResultItem = {
|
||||||
|
result_type: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchDropdownProps = {
|
||||||
|
isOpen: boolean
|
||||||
|
results: SearchResultItem[]
|
||||||
|
onSelect: (type: string, slug: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchDropdown({ isOpen, results, onSelect }: SearchDropdownProps) {
|
||||||
|
return (
|
||||||
|
<div className={`search-dropdown${isOpen ? ' open' : ''}`}>
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`${r.result_type}:${r.slug}`}
|
||||||
|
className="search-result"
|
||||||
|
onClick={() => onSelect(r.result_type, r.slug)}
|
||||||
|
>
|
||||||
|
<span className="sr-type">{r.result_type}</span>
|
||||||
|
{r.name}
|
||||||
|
{r.detail ? <span className="sr-detail">{r.detail}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
754
furumi-node-player/client/src/furumi-player.css
Normal file
754
furumi-node-player/client/src/furumi-player.css
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
.furumi-root,
|
||||||
|
.furumi-root * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furumi-root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-base: #0a0c12;
|
||||||
|
--bg-panel: #111520;
|
||||||
|
--bg-card: #161d2e;
|
||||||
|
--bg-hover: #1e2740;
|
||||||
|
--bg-active: #252f4a;
|
||||||
|
--border: #1f2c45;
|
||||||
|
--accent: #7c6af7;
|
||||||
|
--accent-dim: #5a4fcf;
|
||||||
|
--accent-glow: rgba(124, 106, 247, 0.3);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--success: #34d399;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-version {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-menu {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px 6px 30px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 220px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap::before {
|
||||||
|
content: '🔍';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result .sr-type {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result .sr-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
resize: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 0.85rem 1rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span {
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.dir {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .detail {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .add-btn {
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover .add-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .add-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
padding: 0.85rem 1.25rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.55rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing {
|
||||||
|
background: var(--bg-active);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing .qi-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-index {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 1.5rem;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing .qi-index::before {
|
||||||
|
content: '▶';
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-cover {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-dur {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qi-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover .qi-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qi-remove:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.drag-over {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty .empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-cover {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-main {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff !important;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
box-shadow: 0 0 14px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-main:hover {
|
||||||
|
background: var(--accent-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover .progress-fill::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 1.5rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: all 0.25s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.btn-menu {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 85%;
|
||||||
|
max-width: 320px;
|
||||||
|
z-index: 30;
|
||||||
|
transition: left 0.3s;
|
||||||
|
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
49
furumi-node-player/client/src/furumiApi.ts
Normal file
49
furumi-node-player/client/src/furumiApi.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||||
|
export const API_ROOT = `${API_BASE}/api`
|
||||||
|
|
||||||
|
const API_KEY = import.meta.env.VITE_API_KEY
|
||||||
|
|
||||||
|
export const furumiApi = axios.create({
|
||||||
|
baseURL: API_ROOT,
|
||||||
|
headers: API_KEY ? { 'x-api-key': API_KEY } : {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function getArtists(): Promise<Artist[] | null> {
|
||||||
|
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistAlbums(artistSlug: string): Promise<Album[] | null> {
|
||||||
|
const res = await furumiApi.get<Album[]>(`/artists/${artistSlug}/albums`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbumTracks(albumSlug: string): Promise<Track[] | null> {
|
||||||
|
const res = await furumiApi.get<Track[]>(`/albums/${albumSlug}`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistTracks(artistSlug: string): Promise<Track[] | null> {
|
||||||
|
const res = await furumiApi.get<Track[]>(`/artists/${artistSlug}/tracks`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchTracks(query: string): Promise<SearchResult[] | null> {
|
||||||
|
const res = await furumiApi
|
||||||
|
.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
|
||||||
|
.catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | null> {
|
||||||
|
const res = await furumiApi.get<TrackDetail>(`/tracks/${trackSlug}`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preloadStream(trackSlug: string) {
|
||||||
|
await furumiApi.get(`/stream/${trackSlug}`).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
@@ -5,6 +10,10 @@ body {
|
|||||||
background-color: #f3f6fb;
|
background-color: #f3f6fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Provider } from 'react-redux'
|
||||||
|
import { store } from './store'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
23
furumi-node-player/client/src/store/index.ts
Normal file
23
furumi-node-player/client/src/store/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit'
|
||||||
|
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
|
||||||
|
import artistsReducer from './slices/artistsSlice'
|
||||||
|
import albumsReducer from './slices/albumsSlice'
|
||||||
|
import albumTracksReducer from './slices/albumTracksSlice'
|
||||||
|
import artistTracksReducer from './slices/artistTracksSlice'
|
||||||
|
import trackDetailReducer from './slices/trackDetailSlice'
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
artists: artistsReducer,
|
||||||
|
albums: albumsReducer,
|
||||||
|
albumTracks: albumTracksReducer,
|
||||||
|
artistTracks: artistTracksReducer,
|
||||||
|
trackDetail: trackDetailReducer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
|
export type AppDispatch = typeof store.dispatch
|
||||||
|
|
||||||
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||||
|
import type { Track } from '../../types'
|
||||||
|
import { getAlbumTracks } from '../../furumiApi'
|
||||||
|
|
||||||
|
export const fetchAlbumTracks = createAsyncThunk(
|
||||||
|
'albumTracks/fetch',
|
||||||
|
async (albumSlug: string, { rejectWithValue }) => {
|
||||||
|
const data = await getAlbumTracks(albumSlug)
|
||||||
|
if (data === null) return rejectWithValue('Failed to fetch album tracks')
|
||||||
|
return { albumSlug, tracks: data }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AlbumTracksState {
|
||||||
|
byAlbum: Record<string, Track[]>
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AlbumTracksState = {
|
||||||
|
byAlbum: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumTracksSlice = createSlice({
|
||||||
|
name: 'albumTracks',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearAlbumTracks(state) {
|
||||||
|
state.byAlbum = {}
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchAlbumTracks.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchAlbumTracks.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.byAlbum[action.payload.albumSlug] = action.payload.tracks
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchAlbumTracks.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = action.payload as string ?? 'Unknown error'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { clearAlbumTracks } = albumTracksSlice.actions
|
||||||
|
export default albumTracksSlice.reducer
|
||||||
54
furumi-node-player/client/src/store/slices/albumsSlice.ts
Normal file
54
furumi-node-player/client/src/store/slices/albumsSlice.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||||
|
import type { Album } from '../../types'
|
||||||
|
import { getArtistAlbums } from '../../furumiApi'
|
||||||
|
|
||||||
|
export const fetchArtistAlbums = createAsyncThunk(
|
||||||
|
'albums/fetchByArtist',
|
||||||
|
async (artistSlug: string, { rejectWithValue }) => {
|
||||||
|
const data = await getArtistAlbums(artistSlug)
|
||||||
|
if (data === null) return rejectWithValue('Failed to fetch albums')
|
||||||
|
return { artistSlug, albums: data }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AlbumsState {
|
||||||
|
byArtist: Record<string, Album[]>
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AlbumsState = {
|
||||||
|
byArtist: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumsSlice = createSlice({
|
||||||
|
name: 'albums',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearAlbums(state) {
|
||||||
|
state.byArtist = {}
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchArtistAlbums.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchArtistAlbums.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.byArtist[action.payload.artistSlug] = action.payload.albums
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchArtistAlbums.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = action.payload as string ?? 'Unknown error'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { clearAlbums } = albumsSlice.actions
|
||||||
|
export default albumsSlice.reducer
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||||
|
import type { Track } from '../../types'
|
||||||
|
import { getArtistTracks } from '../../furumiApi'
|
||||||
|
|
||||||
|
export const fetchArtistTracks = createAsyncThunk(
|
||||||
|
'artistTracks/fetch',
|
||||||
|
async (artistSlug: string, { rejectWithValue }) => {
|
||||||
|
const data = await getArtistTracks(artistSlug)
|
||||||
|
if (data === null) return rejectWithValue('Failed to fetch artist tracks')
|
||||||
|
return { artistSlug, tracks: data }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ArtistTracksState {
|
||||||
|
byArtist: Record<string, Track[]>
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ArtistTracksState = {
|
||||||
|
byArtist: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistTracksSlice = createSlice({
|
||||||
|
name: 'artistTracks',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearArtistTracks(state) {
|
||||||
|
state.byArtist = {}
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchArtistTracks.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchArtistTracks.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.byArtist[action.payload.artistSlug] = action.payload.tracks
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchArtistTracks.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = action.payload as string ?? 'Unknown error'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { clearArtistTracks } = artistTracksSlice.actions
|
||||||
|
export default artistTracksSlice.reducer
|
||||||
54
furumi-node-player/client/src/store/slices/artistsSlice.ts
Normal file
54
furumi-node-player/client/src/store/slices/artistsSlice.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||||
|
import type { Artist } from '../../types'
|
||||||
|
import { getArtists } from '../../furumiApi'
|
||||||
|
|
||||||
|
export const fetchArtists = createAsyncThunk(
|
||||||
|
'artists/fetch',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
const data = await getArtists()
|
||||||
|
if (data === null) return rejectWithValue('Failed to fetch artists')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ArtistsState {
|
||||||
|
items: Artist[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ArtistsState = {
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistsSlice = createSlice({
|
||||||
|
name: 'artists',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearArtists(state) {
|
||||||
|
state.items = []
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchArtists.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchArtists.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.items = action.payload
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchArtists.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = action.payload as string ?? 'Unknown error'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { clearArtists } = artistsSlice.actions
|
||||||
|
export default artistsSlice.reducer
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||||
|
import type { TrackDetail } from '../../types'
|
||||||
|
import { getTrackInfo } from '../../furumiApi'
|
||||||
|
|
||||||
|
export const fetchTrackDetail = createAsyncThunk(
|
||||||
|
'trackDetail/fetch',
|
||||||
|
async (trackSlug: string, { rejectWithValue }) => {
|
||||||
|
const data = await getTrackInfo(trackSlug)
|
||||||
|
if (data === null) return rejectWithValue('Failed to fetch track detail')
|
||||||
|
return { trackSlug, detail: data }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface TrackDetailState {
|
||||||
|
bySlug: Record<string, TrackDetail>
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TrackDetailState = {
|
||||||
|
bySlug: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDetailSlice = createSlice({
|
||||||
|
name: 'trackDetail',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearTrackDetail(state) {
|
||||||
|
state.bySlug = {}
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
removeTrackDetail(state, action: { payload: string }) {
|
||||||
|
delete state.bySlug[action.payload]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchTrackDetail.pending, (state) => {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchTrackDetail.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.bySlug[action.payload.trackSlug] = action.payload.detail
|
||||||
|
state.error = null
|
||||||
|
})
|
||||||
|
.addCase(fetchTrackDetail.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.error = action.payload as string ?? 'Unknown error'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions
|
||||||
|
export default trackDetailSlice.reducer
|
||||||
42
furumi-node-player/client/src/types.ts
Normal file
42
furumi-node-player/client/src/types.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// API entity types (see PLAYER-API.md)
|
||||||
|
|
||||||
|
export interface Artist {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
album_count: number
|
||||||
|
track_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
year: number | null
|
||||||
|
track_count: number
|
||||||
|
has_cover: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
track_number: number | null
|
||||||
|
duration_secs: number
|
||||||
|
artist_name: string
|
||||||
|
album_name: string | null
|
||||||
|
album_slug: string | null
|
||||||
|
genre: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackDetail extends Track {
|
||||||
|
storage_path: string
|
||||||
|
artist_slug: string
|
||||||
|
album_year: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchResultType = 'artist' | 'album' | 'track'
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
result_type: SearchResultType
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
detail: string | null
|
||||||
|
}
|
||||||
14
furumi-node-player/client/src/utils.ts
Normal file
14
furumi-node-player/client/src/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmt(secs: number) {
|
||||||
|
if (!secs || Number.isNaN(secs)) return '0:00'
|
||||||
|
const s = Math.floor(secs)
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h > 0) {
|
||||||
|
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
|
||||||
|
}
|
||||||
|
return `${m}:${pad(s % 60)}`
|
||||||
|
}
|
||||||
@@ -25,3 +25,4 @@ base64 = "0.22"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ struct Args {
|
|||||||
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
|
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
|
||||||
#[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]
|
||||||
@@ -90,10 +94,15 @@ 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);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use axum::{
|
|||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, 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,
|
||||||
@@ -92,37 +94,51 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auth middleware: requires valid SSO session cookie.
|
/// Auth middleware: requires valid SSO session cookie or x-api-key header.
|
||||||
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 {
|
||||||
let oidc = match &state.oidc {
|
// 1. Check x-api-key header (if configured)
|
||||||
Some(o) => o,
|
if let Some(ref expected) = state.api_key {
|
||||||
None => return next.run(req).await, // No OIDC configured = no auth
|
if let Some(val) = req
|
||||||
};
|
.headers()
|
||||||
|
.get(X_API_KEY)
|
||||||
let cookies = req
|
.and_then(|v| v.to_str().ok())
|
||||||
.headers()
|
{
|
||||||
.get(header::COOKIE)
|
if val == expected {
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
for c in cookies.split(';') {
|
|
||||||
let c = c.trim();
|
|
||||||
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
|
||||||
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
|
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Check SSO session cookie (if OIDC configured)
|
||||||
|
if let Some(ref oidc) = state.oidc {
|
||||||
|
let cookies = req
|
||||||
|
.headers()
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
for c in cookies.split(';') {
|
||||||
|
let c = c.trim();
|
||||||
|
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||||
|
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
|
||||||
|
return next.run(req).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let uri = req.uri().to_string();
|
let uri = req.uri().to_string();
|
||||||
if uri.starts_with("/api/") {
|
if uri.starts_with("/api/") {
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||||
} else {
|
} else if state.oidc.is_some() {
|
||||||
Redirect::to("/login").into_response()
|
Redirect::to("/login").into_response()
|
||||||
|
} else {
|
||||||
|
// Only API key configured — no web login available
|
||||||
|
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ pub mod auth;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::{Router, routing::get, middleware};
|
use axum::{Router, routing::get, middleware};
|
||||||
|
use axum::http::{header, Method};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -13,6 +16,7 @@ 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,21 +36,28 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/", get(player_html))
|
.route("/", get(player_html))
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
|
|
||||||
let has_oidc = state.oidc.is_some();
|
let requires_auth = state.oidc.is_some();
|
||||||
|
|
||||||
let app = if has_oidc {
|
let app = if requires_auth {
|
||||||
authed
|
authed
|
||||||
.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
|
authed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
|
||||||
|
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")])
|
||||||
|
.max_age(Duration::from_secs(600));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", get(auth::login_page))
|
.route("/login", get(auth::login_page))
|
||||||
.route("/logout", get(auth::logout))
|
.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)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user