Compare commits
6 Commits
e42566f44e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 761306eb9d | |||
| e85ed32b7b | |||
| e34440498c | |||
| f873542d02 | |||
| 3f2013e9d5 | |||
| 7ede23ff94 |
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
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)
|
|
||||||
```
|
|
||||||
Generated
+54
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -572,6 +578,15 @@ version = "2.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.15"
|
version = "0.5.15"
|
||||||
@@ -969,6 +984,16 @@ version = "0.5.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1017,6 +1042,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"id3",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1165,6 +1191,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
@@ -1748,6 +1775,17 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id3"
|
||||||
|
version = "1.16.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"byteorder",
|
||||||
|
"flate2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -2021,6 +2059,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -3412,6 +3460,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simple_asn1"
|
name = "simple_asn1"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ 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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
|
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
|
||||||
|
id3 = "1"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = { version = "1.50", features = ["full"] }
|
tokio = { version = "1.50", features = ["full"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -19,9 +19,25 @@ pub struct RawMetadata {
|
|||||||
pub duration_secs: Option<f64>,
|
pub duration_secs: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract metadata from an audio file using Symphonia.
|
/// Extract metadata from an audio file.
|
||||||
|
/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file
|
||||||
|
/// (e.g., ID3 tag with large embedded cover art exceeds Symphonia's 1 MB probe limit).
|
||||||
/// Must be called from a blocking context (spawn_blocking).
|
/// Must be called from a blocking context (spawn_blocking).
|
||||||
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||||
|
match extract_via_symphonia(path) {
|
||||||
|
Ok(meta) => return Ok(meta),
|
||||||
|
Err(e) => {
|
||||||
|
let is_mp3 = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("mp3")).unwrap_or(false);
|
||||||
|
if is_mp3 {
|
||||||
|
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
|
||||||
|
return extract_mp3_via_id3(path);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||||
let file = std::fs::File::open(path)?;
|
let file = std::fs::File::open(path)?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
@@ -66,6 +82,25 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
|||||||
Ok(meta)
|
Ok(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
|
||||||
|
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||||
|
use id3::TagLike;
|
||||||
|
|
||||||
|
let tag = id3::Tag::read_from_path(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut meta = RawMetadata::default();
|
||||||
|
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
|
||||||
|
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
|
||||||
|
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
|
||||||
|
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
|
||||||
|
meta.track_number = tag.track();
|
||||||
|
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
|
||||||
|
// duration_secs remains None — acceptable for large-cover files
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
|
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
|
||||||
for tag in tags {
|
for tag in tags {
|
||||||
let value = fix_encoding(tag.value.to_string());
|
let value = fix_encoding(tag.value.to_string());
|
||||||
|
|||||||
@@ -113,32 +113,38 @@ fn build_user_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct OllamaRequest {
|
struct ChatRequest {
|
||||||
model: String,
|
model: String,
|
||||||
messages: Vec<OllamaMessage>,
|
messages: Vec<ChatMessage>,
|
||||||
format: String,
|
response_format: ChatResponseFormat,
|
||||||
stream: bool,
|
stream: bool,
|
||||||
options: OllamaOptions,
|
temperature: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct OllamaMessage {
|
struct ChatMessage {
|
||||||
role: String,
|
role: String,
|
||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct OllamaOptions {
|
struct ChatResponseFormat {
|
||||||
temperature: f64,
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct OllamaResponse {
|
struct ChatResponse {
|
||||||
message: OllamaResponseMessage,
|
choices: Vec<ChatChoice>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct OllamaResponseMessage {
|
struct ChatChoice {
|
||||||
|
message: ChatResponseMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChatResponseMessage {
|
||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,25 +159,25 @@ pub async fn call_ollama(
|
|||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let request = OllamaRequest {
|
let request = ChatRequest {
|
||||||
model: model.to_owned(),
|
model: model.to_owned(),
|
||||||
messages: vec![
|
messages: vec![
|
||||||
OllamaMessage {
|
ChatMessage {
|
||||||
role: "system".to_owned(),
|
role: "system".to_owned(),
|
||||||
content: system_prompt.to_owned(),
|
content: system_prompt.to_owned(),
|
||||||
},
|
},
|
||||||
OllamaMessage {
|
ChatMessage {
|
||||||
role: "user".to_owned(),
|
role: "user".to_owned(),
|
||||||
content: user_message.to_owned(),
|
content: user_message.to_owned(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
format: "json".to_owned(),
|
response_format: ChatResponseFormat { kind: "json_object".to_owned() },
|
||||||
stream: false,
|
stream: false,
|
||||||
options: OllamaOptions { temperature: 0.1 },
|
temperature: 0.1,
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/api/chat", base_url.trim_end_matches('/'));
|
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||||
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API...");
|
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API...");
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let mut req = client.post(&url).json(&request);
|
let mut req = client.post(&url).json(&request);
|
||||||
@@ -184,18 +190,25 @@ pub async fn call_ollama(
|
|||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error");
|
tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error");
|
||||||
anyhow::bail!("Ollama returned {}: {}", status, body);
|
anyhow::bail!("LLM returned {}: {}", status, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ollama_resp: OllamaResponse = resp.json().await?;
|
let chat_resp: ChatResponse = resp.json().await?;
|
||||||
|
let content = chat_resp
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))?
|
||||||
|
.message
|
||||||
|
.content;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
elapsed_ms = elapsed.as_millis() as u64,
|
||||||
response_len = ollama_resp.message.content.len(),
|
response_len = content.len(),
|
||||||
"Ollama response received"
|
"LLM response received"
|
||||||
);
|
);
|
||||||
tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output");
|
tracing::debug!(raw_response = %content, "LLM raw output");
|
||||||
Ok(ollama_resp.message.content)
|
Ok(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the LLM JSON response into NormalizedFields.
|
/// Parse the LLM JSON response into NormalizedFields.
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8085
|
|
||||||
VITE_API_KEY=
|
|
||||||
+3
-392
@@ -8,11 +8,8 @@
|
|||||||
"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",
|
||||||
@@ -589,32 +586,6 @@
|
|||||||
"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",
|
||||||
@@ -877,18 +848,6 @@
|
|||||||
"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",
|
||||||
@@ -928,7 +887,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==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -944,12 +903,6 @@
|
|||||||
"@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",
|
||||||
@@ -1334,23 +1287,6 @@
|
|||||||
"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",
|
||||||
@@ -1416,19 +1352,6 @@
|
|||||||
"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",
|
||||||
@@ -1497,18 +1420,6 @@
|
|||||||
"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",
|
||||||
@@ -1542,7 +1453,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==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -1570,15 +1481,6 @@
|
|||||||
"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",
|
||||||
@@ -1589,20 +1491,6 @@
|
|||||||
"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",
|
||||||
@@ -1610,51 +1498,6 @@
|
|||||||
"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",
|
||||||
@@ -1952,42 +1795,6 @@
|
|||||||
"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",
|
||||||
@@ -2003,15 +1810,6 @@
|
|||||||
"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",
|
||||||
@@ -2022,43 +1820,6 @@
|
|||||||
"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",
|
||||||
@@ -2085,18 +1846,6 @@
|
|||||||
"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",
|
||||||
@@ -2107,45 +1856,6 @@
|
|||||||
"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",
|
||||||
@@ -2173,16 +1883,6 @@
|
|||||||
"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",
|
||||||
@@ -2625,36 +2325,6 @@
|
|||||||
"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",
|
||||||
@@ -2850,12 +2520,6 @@
|
|||||||
"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",
|
||||||
@@ -2887,50 +2551,6 @@
|
|||||||
"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",
|
||||||
@@ -3194,15 +2814,6 @@
|
|||||||
"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,11 +10,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -61,11 +61,12 @@ function App() {
|
|||||||
|
|
||||||
const loginUrl = `${apiBase}/api/login`
|
const loginUrl = `${apiBase}/api/login`
|
||||||
const logoutUrl = `${apiBase}/api/logout`
|
const logoutUrl = `${apiBase}/api/logout`
|
||||||
|
const playerApiRoot = `${apiBase}/api`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!loading && (user || runWithoutAuth) ? (
|
{!loading && (user || runWithoutAuth) ? (
|
||||||
<FurumiPlayer />
|
<FurumiPlayer apiRoot={playerApiRoot} />
|
||||||
) : (
|
) : (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
|
|||||||
@@ -1,54 +1,24 @@
|
|||||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
import './furumi-player.css'
|
import './furumi-player.css'
|
||||||
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
|
import { createFurumiApiClient } from './furumiApi'
|
||||||
import { store, useAppDispatch, useAppSelector } from './store'
|
import { SearchDropdown } from './components/SearchDropdown'
|
||||||
import { fetchArtists } from './store/slices/artistsSlice'
|
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||||
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
import { LibraryList } from './components/LibraryList'
|
||||||
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
|
import { QueueList, type QueueItem } from './components/QueueList'
|
||||||
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
|
import { NowPlaying } from './components/NowPlaying'
|
||||||
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
|
|
||||||
import {
|
|
||||||
addTrack,
|
|
||||||
addTracksBatch,
|
|
||||||
replaceQueue,
|
|
||||||
clearQueue,
|
|
||||||
playAtIndex,
|
|
||||||
removeFromQueueAt,
|
|
||||||
moveQueueItemInOrder,
|
|
||||||
toggleShuffle,
|
|
||||||
toggleRepeat,
|
|
||||||
rebuildShuffleOrder,
|
|
||||||
selectQueueOrder,
|
|
||||||
selectPlayingOrigIdx,
|
|
||||||
selectQueueScrollSignal,
|
|
||||||
selectNowPlayingTrack,
|
|
||||||
selectQueueItems,
|
|
||||||
} from './store/slices/queueSlice'
|
|
||||||
import { attachAudioPlayback } from './audioPlaybackService'
|
|
||||||
import { fmt } from './utils'
|
|
||||||
import { Header } from './components/Header'
|
|
||||||
import { MainPanel, type Crumb } from './components/MainPanel'
|
|
||||||
import { PlayerBar } from './components/PlayerBar'
|
|
||||||
import type { Track } from './types'
|
|
||||||
|
|
||||||
export function FurumiPlayer() {
|
type FurumiPlayerProps = {
|
||||||
const dispatch = useAppDispatch()
|
apiRoot: string
|
||||||
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 queueItemsView = useAppSelector(selectQueueItems)
|
type Crumb = { label: string; action?: () => void }
|
||||||
const queueOrderView = useAppSelector(selectQueueOrder)
|
|
||||||
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
|
|
||||||
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
|
|
||||||
const nowPlayingTrack = useAppSelector(selectNowPlayingTrack)
|
|
||||||
|
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||||
|
const [breadcrumbs, setBreadcrumbs] = useState<Array<{ label: string; action?: () => void }>>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
const [libraryLoading, setLibraryLoading] = useState(false)
|
||||||
|
const [libraryError, setLibraryError] = useState<string | null>(null)
|
||||||
const [libraryItems, setLibraryItems] = useState<
|
const [libraryItems, setLibraryItems] = useState<
|
||||||
Array<{
|
Array<{
|
||||||
key: string
|
key: string
|
||||||
@@ -67,69 +37,93 @@ export function FurumiPlayer() {
|
|||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
|
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
|
||||||
|
|
||||||
|
const [nowPlayingTrack, setNowPlayingTrack] = useState<QueueItem | null>(null)
|
||||||
|
const [queueItemsView, setQueueItemsView] = useState<QueueItem[]>([])
|
||||||
|
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
||||||
|
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
||||||
|
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
||||||
|
|
||||||
const queueActionsRef = useRef<{
|
const queueActionsRef = useRef<{
|
||||||
playIndex: (i: number) => void
|
playIndex: (i: number) => void
|
||||||
removeFromQueue: (idx: number) => void
|
removeFromQueue: (idx: number) => void
|
||||||
moveQueueItem: (fromPos: number, toPos: number) => void
|
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!nowPlayingTrack) {
|
// --- Original player script adapted for React environment ---
|
||||||
document.title = 'Furumi Player'
|
const audio = document.getElementById('audioEl') as HTMLAudioElement
|
||||||
return
|
if (!audio) return
|
||||||
}
|
|
||||||
document.title = `${nowPlayingTrack.title} — Furumi`
|
let queue: QueueItem[] = []
|
||||||
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
|
let queueIndex = -1
|
||||||
if ('mediaSession' in navigator) {
|
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 {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
const v = window.localStorage.getItem('furumi_vol')
|
||||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
title: nowPlayingTrack.title,
|
if (v !== null && volSlider) {
|
||||||
artist: nowPlayingTrack.artist || '',
|
audio.volume = Number(v) / 100
|
||||||
album: '',
|
volSlider.value = v
|
||||||
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
}
|
||||||
})
|
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 {
|
} catch {
|
||||||
// ignore
|
// 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)
|
||||||
}
|
}
|
||||||
}, [nowPlayingTrack])
|
})
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
const shuffle = useAppSelector((s) => s.queue.shuffle)
|
// --- API helper ---
|
||||||
const repeatAll = useAppSelector((s) => s.queue.repeatAll)
|
const API = apiRoot
|
||||||
|
const api = createFurumiApiClient(API)
|
||||||
useEffect(() => {
|
|
||||||
const btnShuffle = document.getElementById('btnShuffle')
|
|
||||||
const btnRepeat = document.getElementById('btnRepeat')
|
|
||||||
btnShuffle?.classList.toggle('active', shuffle)
|
|
||||||
btnRepeat?.classList.toggle('active', repeatAll)
|
|
||||||
}, [shuffle, repeatAll])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const audioEl = audioRef.current
|
|
||||||
if (!audioEl) return
|
|
||||||
const audio = audioEl
|
|
||||||
|
|
||||||
let searchTimer: number | null = null
|
|
||||||
let toastTimer: number | null = null
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// --- Library navigation ---
|
||||||
async function showArtists() {
|
async function showArtists() {
|
||||||
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
||||||
try {
|
setLibraryLoading(true)
|
||||||
const artists = await dispatch(fetchArtists()).unwrap()
|
setLibraryError(null)
|
||||||
|
const artists = await api('/artists')
|
||||||
|
if (!artists) {
|
||||||
|
setLibraryLoading(false)
|
||||||
|
setLibraryError('Error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLibraryLoading(false)
|
||||||
setLibraryItems(
|
setLibraryItems(
|
||||||
artists.map((a) => ({
|
(artists as any[]).map((a) => ({
|
||||||
key: `artist:${a.slug}`,
|
key: `artist:${a.slug}`,
|
||||||
className: 'file-item dir',
|
className: 'file-item dir',
|
||||||
icon: '👤',
|
icon: '👤',
|
||||||
@@ -138,9 +132,6 @@ export function FurumiPlayer() {
|
|||||||
onClick: () => void showArtistAlbums(a.slug, a.name),
|
onClick: () => void showArtistAlbums(a.slug, a.name),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
} catch {
|
|
||||||
// Error is stored in artists.error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showArtistAlbums(artistSlug: string, artistName: string) {
|
async function showArtistAlbums(artistSlug: string, artistName: string) {
|
||||||
@@ -148,8 +139,15 @@ export function FurumiPlayer() {
|
|||||||
{ label: 'Artists', action: showArtists },
|
{ label: 'Artists', action: showArtists },
|
||||||
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
||||||
])
|
])
|
||||||
try {
|
setLibraryLoading(true)
|
||||||
const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap()
|
setLibraryError(null)
|
||||||
|
const albums = await api('/artists/' + artistSlug + '/albums')
|
||||||
|
if (!albums) {
|
||||||
|
setLibraryLoading(false)
|
||||||
|
setLibraryError('Error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLibraryLoading(false)
|
||||||
const allTracksItem = {
|
const allTracksItem = {
|
||||||
key: `artist-all:${artistSlug}`,
|
key: `artist-all:${artistSlug}`,
|
||||||
className: 'file-item',
|
className: 'file-item',
|
||||||
@@ -158,7 +156,7 @@ export function FurumiPlayer() {
|
|||||||
nameClassName: 'name',
|
nameClassName: 'name',
|
||||||
onClick: () => void playAllArtistTracks(artistSlug),
|
onClick: () => void playAllArtistTracks(artistSlug),
|
||||||
}
|
}
|
||||||
const albumItems = albums.map((a) => {
|
const albumItems = (albums as any[]).map((a) => {
|
||||||
const year = a.year ? ` (${a.year})` : ''
|
const year = a.year ? ` (${a.year})` : ''
|
||||||
return {
|
return {
|
||||||
key: `album:${a.slug}`,
|
key: `album:${a.slug}`,
|
||||||
@@ -177,9 +175,6 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
setLibraryItems([allTracksItem, ...albumItems])
|
setLibraryItems([allTracksItem, ...albumItems])
|
||||||
} catch {
|
|
||||||
// Error is stored in albums.error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showAlbumTracks(
|
async function showAlbumTracks(
|
||||||
@@ -193,9 +188,15 @@ export function FurumiPlayer() {
|
|||||||
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
|
||||||
{ label: albumName },
|
{ label: albumName },
|
||||||
])
|
])
|
||||||
const result = await dispatch(fetchAlbumTracks(albumSlug))
|
setLibraryLoading(true)
|
||||||
if (result.meta.requestStatus === 'rejected') return
|
setLibraryError(null)
|
||||||
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
const tracks = await api('/albums/' + albumSlug)
|
||||||
|
if (!tracks) {
|
||||||
|
setLibraryLoading(false)
|
||||||
|
setLibraryError('Error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLibraryLoading(false)
|
||||||
const playAlbumItem = {
|
const playAlbumItem = {
|
||||||
key: `album-play:${albumSlug}`,
|
key: `album-play:${albumSlug}`,
|
||||||
className: 'file-item',
|
className: 'file-item',
|
||||||
@@ -205,7 +206,7 @@ export function FurumiPlayer() {
|
|||||||
void addAlbumToQueue(albumSlug, true)
|
void addAlbumToQueue(albumSlug, true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const trackItems = tracks.map((t) => {
|
const trackItems = (tracks as any[]).map((t) => {
|
||||||
const num = t.track_number ? `${t.track_number}. ` : ''
|
const num = t.track_number ? `${t.track_number}. ` : ''
|
||||||
const dur = t.duration_secs ? fmt(t.duration_secs) : ''
|
const dur = t.duration_secs ? fmt(t.duration_secs) : ''
|
||||||
return {
|
return {
|
||||||
@@ -235,6 +236,7 @@ export function FurumiPlayer() {
|
|||||||
setBreadcrumbs(parts)
|
setBreadcrumbs(parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Queue management ---
|
||||||
function addTrackToQueue(
|
function addTrackToQueue(
|
||||||
track: {
|
track: {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -245,64 +247,66 @@ export function FurumiPlayer() {
|
|||||||
},
|
},
|
||||||
playNow?: boolean,
|
playNow?: boolean,
|
||||||
) {
|
) {
|
||||||
const prevIdx = store.getState().queue.currentIndex
|
const existing = queue.findIndex((t) => t.slug === track.slug)
|
||||||
dispatch(addTrack({ track, playNow }))
|
if (existing !== -1) {
|
||||||
const q = store.getState().queue
|
if (playNow) playIndex(existing)
|
||||||
if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) {
|
return
|
||||||
playIndex(q.currentIndex)
|
}
|
||||||
|
queue.push(track)
|
||||||
|
updateQueueModel()
|
||||||
|
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||||
|
playIndex(queue.length - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
|
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
|
||||||
const result = await dispatch(fetchAlbumTracks(albumSlug))
|
const tracks = await api('/albums/' + albumSlug)
|
||||||
if (result.meta.requestStatus === 'rejected') return
|
if (!tracks || !(tracks as any[]).length) return
|
||||||
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
const list = tracks as any[]
|
||||||
if (!tracks || !tracks.length) return
|
let firstIdx = queue.length
|
||||||
const list = tracks.map((t) => ({
|
list.forEach((t) => {
|
||||||
|
if (queue.find((q) => q.slug === t.slug)) return
|
||||||
|
queue.push({
|
||||||
slug: t.slug,
|
slug: t.slug,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist_name,
|
artist: t.artist_name,
|
||||||
album_slug: t.album_slug,
|
album_slug: albumSlug,
|
||||||
duration: t.duration_secs,
|
duration: t.duration_secs,
|
||||||
}))
|
})
|
||||||
const prevIdx = store.getState().queue.currentIndex
|
})
|
||||||
dispatch(addTracksBatch({ tracks: list, playFirst }))
|
updateQueueModel()
|
||||||
const q = store.getState().queue
|
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||||
if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) {
|
|
||||||
playIndex(q.currentIndex)
|
|
||||||
}
|
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playAllArtistTracks(artistSlug: string) {
|
async function playAllArtistTracks(artistSlug: string) {
|
||||||
const result = await dispatch(fetchArtistTracks(artistSlug))
|
const tracks = await api('/artists/' + artistSlug + '/tracks')
|
||||||
if (result.meta.requestStatus === 'rejected') return
|
if (!tracks || !(tracks as any[]).length) return
|
||||||
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
|
const list = tracks as any[]
|
||||||
if (!tracks || !tracks.length) return
|
clearQueue()
|
||||||
const list = tracks.map((t) => ({
|
list.forEach((t) => {
|
||||||
|
queue.push({
|
||||||
slug: t.slug,
|
slug: t.slug,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist_name,
|
artist: t.artist_name,
|
||||||
album_slug: t.album_slug,
|
album_slug: t.album_slug,
|
||||||
duration: t.duration_secs,
|
duration: t.duration_secs,
|
||||||
}))
|
})
|
||||||
dispatch(replaceQueue({ items: list, playFromIndex: 0 }))
|
})
|
||||||
|
updateQueueModel()
|
||||||
playIndex(0)
|
playIndex(0)
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const playback = attachAudioPlayback(audio, {
|
|
||||||
onEnded: nextTrack,
|
|
||||||
onErrorSkip: nextTrack,
|
|
||||||
onToast: showToast,
|
|
||||||
})
|
|
||||||
|
|
||||||
function playIndex(i: number) {
|
function playIndex(i: number) {
|
||||||
const q = store.getState().queue
|
if (i < 0 || i >= queue.length) return
|
||||||
if (i < 0 || i >= q.items.length) return
|
queueIndex = i
|
||||||
dispatch(playAtIndex(i))
|
const track = queue[i]
|
||||||
const track = store.getState().queue.items[i]
|
audio.src = `${API}/stream/${track.slug}`
|
||||||
void playback.loadStreamForTrack(track.slug)
|
void audio.play().catch(() => {})
|
||||||
|
updateNowPlaying(track)
|
||||||
|
updateQueueModel()
|
||||||
|
setQueueScrollSignal((s) => s + 1)
|
||||||
if (window.history && window.history.replaceState) {
|
if (window.history && window.history.replaceState) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('t', track.slug)
|
url.searchParams.set('t', track.slug)
|
||||||
@@ -310,50 +314,86 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFromQueue(idx: number) {
|
function updateNowPlaying(track: QueueItem | null) {
|
||||||
const wasPlaying = store.getState().queue.currentIndex === idx
|
setNowPlayingTrack(track)
|
||||||
dispatch(removeFromQueueAt(idx))
|
if (!track) return
|
||||||
if (wasPlaying) playback.pauseAndClearSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveQueueItem(fromPos: number, toPos: number) {
|
document.title = `${track.title} — Furumi`
|
||||||
dispatch(moveQueueItemInOrder({ fromPos, toPos }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearQueuePlayback() {
|
const coverUrl = `${API}/tracks/${track.slug}/cover`
|
||||||
dispatch(clearQueue())
|
if ('mediaSession' in navigator) {
|
||||||
playback.pauseAndClearSource()
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
}
|
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||||
|
title: track.title,
|
||||||
function nextTrack() {
|
artist: track.artist || '',
|
||||||
const q = store.getState().queue
|
album: '',
|
||||||
if (!q.items.length) return
|
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
||||||
const order = selectQueueOrder(store.getState())
|
|
||||||
const pos = order.indexOf(q.currentIndex)
|
|
||||||
if (pos < order.length - 1) playIndex(order[pos + 1])
|
|
||||||
else if (q.repeatAll) {
|
|
||||||
if (q.shuffle) dispatch(rebuildShuffleOrder())
|
|
||||||
const first = selectQueueOrder(store.getState())[0]
|
|
||||||
if (first !== undefined) playIndex(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevTrack() {
|
|
||||||
const q = store.getState().queue
|
|
||||||
if (!q.items.length) return
|
|
||||||
if (playback.rewindCurrentTrackIfPastThreshold()) return
|
|
||||||
const order = selectQueueOrder(store.getState())
|
|
||||||
const pos = order.indexOf(q.currentIndex)
|
|
||||||
if (pos > 0) playIndex(order[pos - 1])
|
|
||||||
else if (q.repeatAll) playIndex(order[order.length - 1])
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePlay() {
|
|
||||||
const q = store.getState().queue
|
|
||||||
playback.togglePlay(() => {
|
|
||||||
playIndex(q.currentIndex === -1 ? 0 : q.currentIndex)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.slice())
|
||||||
|
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)
|
||||||
|
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 = {
|
queueActionsRef.current = {
|
||||||
playIndex,
|
playIndex,
|
||||||
@@ -361,14 +401,91 @@ export function FurumiPlayer() {
|
|||||||
moveQueueItem,
|
moveQueueItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
function onToggleShuffle() {
|
function clearQueue() {
|
||||||
dispatch(toggleShuffle())
|
queue = []
|
||||||
|
queueIndex = -1
|
||||||
|
shuffleOrder = []
|
||||||
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
|
updateNowPlaying(null)
|
||||||
|
document.title = 'Furumi Player'
|
||||||
|
updateQueueModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onToggleRepeat() {
|
// --- Playback controls ---
|
||||||
dispatch(toggleRepeat())
|
function togglePlay() {
|
||||||
|
if (!audio.src && queue.length) {
|
||||||
|
playIndex(queueIndex === -1 ? 0 : queueIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (audio.paused) void audio.play()
|
||||||
|
else audio.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextTrack() {
|
||||||
|
if (!queue.length) return
|
||||||
|
const order = currentOrder()
|
||||||
|
const pos = order.indexOf(queueIndex)
|
||||||
|
if (pos < order.length - 1) playIndex(order[pos + 1])
|
||||||
|
else if (repeatAll) {
|
||||||
|
if (shuffle) buildShuffleOrder()
|
||||||
|
playIndex(currentOrder()[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevTrack() {
|
||||||
|
if (!queue.length) return
|
||||||
|
if (audio.currentTime > 3) {
|
||||||
|
audio.currentTime = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const order = currentOrder()
|
||||||
|
const pos = order.indexOf(queueIndex)
|
||||||
|
if (pos > 0) playIndex(order[pos - 1])
|
||||||
|
else if (repeatAll) playIndex(order[order.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShuffle() {
|
||||||
|
shuffle = !shuffle
|
||||||
|
if (shuffle) buildShuffleOrder()
|
||||||
|
const btn = document.getElementById('btnShuffle')
|
||||||
|
btn?.classList.toggle('active', shuffle)
|
||||||
|
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
||||||
|
updateQueueModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRepeat() {
|
||||||
|
repeatAll = !repeatAll
|
||||||
|
const btn = document.getElementById('btnRepeat')
|
||||||
|
btn?.classList.toggle('active', repeatAll)
|
||||||
|
window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Seek & Volume ---
|
||||||
|
function seekTo(e: MouseEvent) {
|
||||||
|
if (!audio.duration) return
|
||||||
|
const bar = document.getElementById('progressBar') as HTMLDivElement | null
|
||||||
|
if (!bar) return
|
||||||
|
const rect = bar.getBoundingClientRect()
|
||||||
|
const pct = (e.clientX - rect.left) / rect.width
|
||||||
|
audio.currentTime = pct * audio.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
muted = !muted
|
||||||
|
audio.muted = muted
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
if (volIcon) volIcon.innerHTML = muted ? '🔇' : '🔊'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(v: number) {
|
||||||
|
audio.volume = v / 100
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
if (volIcon) volIcon.innerHTML = v === 0 ? '🔇' : '🔊'
|
||||||
|
window.localStorage.setItem('furumi_vol', String(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
function onSearch(q: string) {
|
function onSearch(q: string) {
|
||||||
if (searchTimer) {
|
if (searchTimer) {
|
||||||
window.clearTimeout(searchTimer)
|
window.clearTimeout(searchTimer)
|
||||||
@@ -378,7 +495,7 @@ export function FurumiPlayer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
searchTimer = window.setTimeout(async () => {
|
searchTimer = window.setTimeout(async () => {
|
||||||
const results = await searchTracks(q)
|
const results = await api('/search?q=' + encodeURIComponent(q))
|
||||||
if (!results || !(results as any[]).length) {
|
if (!results || !(results as any[]).length) {
|
||||||
closeSearch()
|
closeSearch()
|
||||||
return
|
return
|
||||||
@@ -402,11 +519,36 @@ export function FurumiPlayer() {
|
|||||||
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
void preloadStream(slug)
|
void api('/stream/' + slug).catch(() => null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
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 pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg: string) {
|
||||||
|
const t = document.getElementById('toast')
|
||||||
|
if (!t) return
|
||||||
|
t.textContent = msg
|
||||||
|
t.classList.add('show')
|
||||||
|
if (toastTimer) window.clearTimeout(toastTimer)
|
||||||
|
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const sidebar = document.getElementById('sidebar')
|
const sidebar = document.getElementById('sidebar')
|
||||||
const overlay = document.getElementById('sidebarOverlay')
|
const overlay = document.getElementById('sidebarOverlay')
|
||||||
@@ -414,159 +556,212 @@ export function FurumiPlayer() {
|
|||||||
overlay?.classList.toggle('show')
|
overlay?.classList.toggle('show')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMediaSeekTo = (d: { seekTime?: number }) => {
|
// --- MediaSession ---
|
||||||
if (typeof d.seekTime === 'number') {
|
|
||||||
playback.seekToTime(d.seekTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
try {
|
try {
|
||||||
navigator.mediaSession.setActionHandler('play', togglePlay)
|
navigator.mediaSession.setActionHandler('play', togglePlay)
|
||||||
navigator.mediaSession.setActionHandler('pause', togglePlay)
|
navigator.mediaSession.setActionHandler('pause', togglePlay)
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
|
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
||||||
navigator.mediaSession.setActionHandler('seekto', onMediaSeekTo as (d: any) => void)
|
navigator.mediaSession.setActionHandler('seekto', (d: any) => {
|
||||||
|
if (typeof d.seekTime === 'number') {
|
||||||
|
audio.currentTime = d.seekTime
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMenuClick = () => toggleSidebar()
|
// --- Wire DOM events that were inline in HTML ---
|
||||||
const btnMenu = document.querySelector('.btn-menu')
|
const btnMenu = document.querySelector('.btn-menu')
|
||||||
btnMenu?.addEventListener('click', onMenuClick)
|
btnMenu?.addEventListener('click', () => toggleSidebar())
|
||||||
|
|
||||||
const onSidebarOverlayClick = () => toggleSidebar()
|
|
||||||
const sidebarOverlay = document.getElementById('sidebarOverlay')
|
const sidebarOverlay = document.getElementById('sidebarOverlay')
|
||||||
sidebarOverlay?.addEventListener('click', onSidebarOverlayClick)
|
sidebarOverlay?.addEventListener('click', () => toggleSidebar())
|
||||||
|
|
||||||
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
|
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
|
||||||
const onSearchInput = (e: Event) => {
|
|
||||||
onSearch((e.target as HTMLInputElement).value)
|
|
||||||
}
|
|
||||||
const onSearchKeydown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') closeSearch()
|
|
||||||
}
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', onSearchInput)
|
searchInput.addEventListener('input', (e) => {
|
||||||
searchInput.addEventListener('keydown', onSearchKeydown)
|
onSearch((e.target as HTMLInputElement).value)
|
||||||
|
})
|
||||||
|
searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeSearch()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onShuffleClick = () => onToggleShuffle()
|
|
||||||
const onRepeatClick = () => onToggleRepeat()
|
|
||||||
const onClearClick = () => clearQueuePlayback()
|
|
||||||
const onPrevClick = () => prevTrack()
|
|
||||||
const onPlayClick = () => togglePlay()
|
|
||||||
const onNextClick = () => nextTrack()
|
|
||||||
|
|
||||||
const btnShuffle = document.getElementById('btnShuffle')
|
const btnShuffle = document.getElementById('btnShuffle')
|
||||||
btnShuffle?.addEventListener('click', onShuffleClick)
|
btnShuffle?.addEventListener('click', () => toggleShuffle())
|
||||||
const btnRepeat = document.getElementById('btnRepeat')
|
const btnRepeat = document.getElementById('btnRepeat')
|
||||||
btnRepeat?.addEventListener('click', onRepeatClick)
|
btnRepeat?.addEventListener('click', () => toggleRepeat())
|
||||||
const btnClear = document.getElementById('btnClearQueue')
|
const btnClear = document.getElementById('btnClearQueue')
|
||||||
btnClear?.addEventListener('click', onClearClick)
|
btnClear?.addEventListener('click', () => clearQueue())
|
||||||
|
|
||||||
const btnPrev = document.getElementById('btnPrev')
|
const btnPrev = document.getElementById('btnPrev')
|
||||||
btnPrev?.addEventListener('click', onPrevClick)
|
btnPrev?.addEventListener('click', () => prevTrack())
|
||||||
const btnPlay = document.getElementById('btnPlayPause')
|
const btnPlay = document.getElementById('btnPlayPause')
|
||||||
btnPlay?.addEventListener('click', onPlayClick)
|
btnPlay?.addEventListener('click', () => togglePlay())
|
||||||
const btnNext = document.getElementById('btnNext')
|
const btnNext = document.getElementById('btnNext')
|
||||||
btnNext?.addEventListener('click', onNextClick)
|
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 () => {
|
;(async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const urlSlug = url.searchParams.get('t')
|
const urlSlug = url.searchParams.get('t')
|
||||||
if (urlSlug) {
|
if (urlSlug) {
|
||||||
try {
|
const info = await api('/tracks/' + urlSlug)
|
||||||
const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
|
if (info) {
|
||||||
addTrackToQueue(
|
addTrackToQueue(
|
||||||
{
|
{
|
||||||
slug: detail.slug,
|
slug: (info as any).slug,
|
||||||
title: detail.title,
|
title: (info as any).title,
|
||||||
artist: detail.artist_name,
|
artist: (info as any).artist_name,
|
||||||
album_slug: detail.album_slug,
|
album_slug: (info as any).album_slug,
|
||||||
duration: detail.duration_secs,
|
duration: (info as any).duration_secs,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
} catch {
|
|
||||||
// fetchTrackDetail rejected — track not found or error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void showArtists()
|
void showArtists()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
// Cleanup: best-effort remove listeners on unmount
|
||||||
return () => {
|
return () => {
|
||||||
queueActionsRef.current = null
|
queueActionsRef.current = null
|
||||||
playback.dispose()
|
audio.pause()
|
||||||
btnMenu?.removeEventListener('click', onMenuClick)
|
|
||||||
sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick)
|
|
||||||
searchInput?.removeEventListener('input', onSearchInput)
|
|
||||||
searchInput?.removeEventListener('keydown', onSearchKeydown)
|
|
||||||
btnShuffle?.removeEventListener('click', onShuffleClick)
|
|
||||||
btnRepeat?.removeEventListener('click', onRepeatClick)
|
|
||||||
btnClear?.removeEventListener('click', onClearClick)
|
|
||||||
btnPrev?.removeEventListener('click', onPrevClick)
|
|
||||||
btnPlay?.removeEventListener('click', onPlayClick)
|
|
||||||
btnNext?.removeEventListener('click', onNextClick)
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
try {
|
|
||||||
navigator.mediaSession.setActionHandler('play', null)
|
|
||||||
navigator.mediaSession.setActionHandler('pause', null)
|
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', null)
|
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', null)
|
|
||||||
navigator.mediaSession.setActionHandler('seekto', null)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}, [apiRoot])
|
||||||
}
|
|
||||||
}, [dispatch])
|
|
||||||
|
|
||||||
const libraryLoading =
|
|
||||||
breadcrumbs.length === 1
|
|
||||||
? artistsLoading
|
|
||||||
: breadcrumbs.length === 2
|
|
||||||
? albumsLoading
|
|
||||||
: albumTracksLoading
|
|
||||||
|
|
||||||
const libraryError =
|
|
||||||
breadcrumbs.length === 1
|
|
||||||
? artistsError
|
|
||||||
: breadcrumbs.length === 2
|
|
||||||
? albumsError
|
|
||||||
: albumTracksError
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="furumi-root">
|
<div className="furumi-root">
|
||||||
<Header
|
<header className="header">
|
||||||
searchOpen={searchOpen}
|
<div className="header-logo">
|
||||||
searchResults={searchResults}
|
<button className="btn-menu">☰</button>
|
||||||
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
<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={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<MainPanel
|
<div className="main">
|
||||||
breadcrumbs={breadcrumbs}
|
<div className="sidebar-overlay" id="sidebarOverlay" />
|
||||||
libraryLoading={libraryLoading}
|
<aside className="sidebar" id="sidebar">
|
||||||
libraryError={libraryError}
|
<div className="sidebar-header">Library</div>
|
||||||
libraryItems={libraryItems}
|
<Breadcrumbs items={breadcrumbs} />
|
||||||
queueItemsView={queueItemsView}
|
<div className="file-list" id="fileList">
|
||||||
queueOrderView={queueOrderView}
|
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
|
||||||
queuePlayingOrigIdxView={queuePlayingOrigIdxView}
|
</div>
|
||||||
queueScrollSignal={queueScrollSignal}
|
</aside>
|
||||||
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
|
||||||
onQueueRemove={(origIdx) =>
|
<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
|
||||||
|
apiRoot={apiRoot}
|
||||||
|
queue={queueItemsView}
|
||||||
|
order={queueOrderView}
|
||||||
|
playingOrigIdx={queuePlayingOrigIdxView}
|
||||||
|
scrollSignal={queueScrollSignal}
|
||||||
|
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||||
|
onRemove={(origIdx) =>
|
||||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||||
}
|
}
|
||||||
onQueueMove={(fromPos, toPos) =>
|
onMove={(fromPos, toPos) =>
|
||||||
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PlayerBar track={nowPlayingTrack} />
|
<div className="player-bar">
|
||||||
|
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="toast" id="toast" />
|
<div className="toast" id="toast" />
|
||||||
<audio ref={audioRef} />
|
<audio id="audioEl" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { preloadStream } from './furumiApi'
|
|
||||||
import { fmt } from './utils'
|
|
||||||
|
|
||||||
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
|
||||||
|
|
||||||
/** Seconds from track start above which "previous" rewinds current track instead. */
|
|
||||||
const PREV_TRACK_REWIND_THRESHOLD_SEC = 3
|
|
||||||
|
|
||||||
export interface AudioPlaybackCallbacks {
|
|
||||||
onEnded: () => void
|
|
||||||
/** Called after a recoverable playback error (to advance queue). */
|
|
||||||
onErrorSkip: () => void
|
|
||||||
onToast: (msg: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AudioPlaybackHandle {
|
|
||||||
loadStreamForTrack(slug: string): Promise<void>
|
|
||||||
pauseAndClearSource(): void
|
|
||||||
togglePlay(whenNoSource: () => void): void
|
|
||||||
seekFromProgressBarClick(e: MouseEvent): void
|
|
||||||
toggleMute(): void
|
|
||||||
setVolume(percent: number): void
|
|
||||||
seekToTime(seconds: number): void
|
|
||||||
/** If current time is past the threshold, seeks to 0 and returns true (caller should skip prev-track logic). */
|
|
||||||
rewindCurrentTrackIfPastThreshold(): boolean
|
|
||||||
dispose(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncVolumeFromStorage(audio: HTMLAudioElement) {
|
|
||||||
try {
|
|
||||||
const v = window.localStorage.getItem('furumi_vol')
|
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
|
||||||
if (v !== null && volSlider) {
|
|
||||||
audio.volume = Number(v) / 100
|
|
||||||
volSlider.value = v
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachAudioPlayback(
|
|
||||||
audio: HTMLAudioElement,
|
|
||||||
callbacks: AudioPlaybackCallbacks,
|
|
||||||
): AudioPlaybackHandle {
|
|
||||||
let muted = false
|
|
||||||
let playbackErrorSkips = 0
|
|
||||||
|
|
||||||
syncVolumeFromStorage(audio)
|
|
||||||
|
|
||||||
function onTimeUpdate() {
|
|
||||||
if (!audio.duration) return
|
|
||||||
const fill = document.getElementById('progressFill')
|
|
||||||
const timeElapsed = document.getElementById('timeElapsed')
|
|
||||||
const timeDuration = document.getElementById('timeDuration')
|
|
||||||
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
|
|
||||||
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
|
|
||||||
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPlayPauseButtonPlaying(playing: boolean) {
|
|
||||||
const btn = document.getElementById('btnPlayPause')
|
|
||||||
if (btn) btn.innerHTML = playing ? '⏸' : '▶'
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPlaying() {
|
|
||||||
playbackErrorSkips = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPlay() {
|
|
||||||
setPlayPauseButtonPlaying(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPause() {
|
|
||||||
setPlayPauseButtonPlaying(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onError() {
|
|
||||||
callbacks.onToast('Playback error')
|
|
||||||
if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return
|
|
||||||
playbackErrorSkips += 1
|
|
||||||
callbacks.onErrorSkip()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEnded() {
|
|
||||||
callbacks.onEnded()
|
|
||||||
}
|
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
|
||||||
audio.addEventListener('ended', onEnded)
|
|
||||||
audio.addEventListener('playing', onPlaying)
|
|
||||||
audio.addEventListener('play', onPlay)
|
|
||||||
audio.addEventListener('pause', onPause)
|
|
||||||
audio.addEventListener('error', onError)
|
|
||||||
|
|
||||||
const progressBar = document.getElementById('progressBar')
|
|
||||||
const onProgressClick = (e: Event) => seekFromProgressBarClick(e as MouseEvent)
|
|
||||||
progressBar?.addEventListener('click', onProgressClick)
|
|
||||||
|
|
||||||
const volIcon = document.getElementById('volIcon')
|
|
||||||
const onVolIconClick = () => toggleMute()
|
|
||||||
volIcon?.addEventListener('click', onVolIconClick)
|
|
||||||
|
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
|
||||||
const onVolInput = (e: Event) => {
|
|
||||||
const v = Number((e.target as HTMLInputElement).value)
|
|
||||||
setVolume(v)
|
|
||||||
}
|
|
||||||
volSlider?.addEventListener('input', onVolInput)
|
|
||||||
|
|
||||||
async function loadStreamForTrack(slug: string) {
|
|
||||||
const response = await preloadStream(slug)
|
|
||||||
audio.src = URL.createObjectURL(response?.data)
|
|
||||||
await audio.play().catch(() => { })
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseAndClearSource() {
|
|
||||||
audio.pause()
|
|
||||||
audio.src = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePlay(whenNoSource: () => void) {
|
|
||||||
if (!audio.src) {
|
|
||||||
whenNoSource()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (audio.paused) void audio.play()
|
|
||||||
else audio.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
function seekFromProgressBarClick(e: MouseEvent) {
|
|
||||||
if (!audio.duration) return
|
|
||||||
const bar = document.getElementById('progressBar') as HTMLDivElement | null
|
|
||||||
if (!bar) return
|
|
||||||
const rect = bar.getBoundingClientRect()
|
|
||||||
const pct = (e.clientX - rect.left) / rect.width
|
|
||||||
audio.currentTime = pct * audio.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMute() {
|
|
||||||
muted = !muted
|
|
||||||
audio.muted = muted
|
|
||||||
const icon = document.getElementById('volIcon')
|
|
||||||
if (icon) icon.innerHTML = muted ? '🔇' : '🔊'
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVolume(percent: number) {
|
|
||||||
audio.volume = percent / 100
|
|
||||||
const icon = document.getElementById('volIcon')
|
|
||||||
if (icon) icon.innerHTML = percent === 0 ? '🔇' : '🔊'
|
|
||||||
window.localStorage.setItem('furumi_vol', String(percent))
|
|
||||||
}
|
|
||||||
|
|
||||||
function seekToTime(seconds: number) {
|
|
||||||
audio.currentTime = seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewindCurrentTrackIfPastThreshold(): boolean {
|
|
||||||
if (audio.currentTime > PREV_TRACK_REWIND_THRESHOLD_SEC) {
|
|
||||||
audio.currentTime = 0
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispose() {
|
|
||||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
|
||||||
audio.removeEventListener('ended', onEnded)
|
|
||||||
audio.removeEventListener('playing', onPlaying)
|
|
||||||
audio.removeEventListener('play', onPlay)
|
|
||||||
audio.removeEventListener('pause', onPause)
|
|
||||||
audio.removeEventListener('error', onError)
|
|
||||||
progressBar?.removeEventListener('click', onProgressClick)
|
|
||||||
volIcon?.removeEventListener('click', onVolIconClick)
|
|
||||||
volSlider?.removeEventListener('input', onVolInput)
|
|
||||||
audio.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loadStreamForTrack,
|
|
||||||
pauseAndClearSource,
|
|
||||||
togglePlay,
|
|
||||||
seekFromProgressBarClick,
|
|
||||||
toggleMute,
|
|
||||||
setVolume,
|
|
||||||
seekToTime,
|
|
||||||
rewindCurrentTrackIfPastThreshold,
|
|
||||||
dispose,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { API_ROOT } from '../furumiApi'
|
|
||||||
import type { QueueItem } from './QueueList'
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
function Cover({ src }: { src: string }) {
|
function Cover({ src }: { src: string }) {
|
||||||
@@ -13,7 +12,7 @@ function Cover({ src }: { src: string }) {
|
|||||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
|
||||||
if (!track) {
|
if (!track) {
|
||||||
return (
|
return (
|
||||||
<div className="np-info">
|
<div className="np-info">
|
||||||
@@ -32,7 +31,7 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="np-info">
|
<div className="np-info">
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { API_ROOT } from '../furumiApi'
|
|
||||||
|
|
||||||
export type QueueItem = {
|
export type QueueItem = {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -10,6 +9,7 @@ export type QueueItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type QueueListProps = {
|
type QueueListProps = {
|
||||||
|
apiRoot: string
|
||||||
queue: QueueItem[]
|
queue: QueueItem[]
|
||||||
order: number[]
|
order: number[]
|
||||||
playingOrigIdx: number
|
playingOrigIdx: number
|
||||||
@@ -43,6 +43,7 @@ function Cover({ src }: { src: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QueueList({
|
export function QueueList({
|
||||||
|
apiRoot,
|
||||||
queue,
|
queue,
|
||||||
order,
|
order,
|
||||||
playingOrigIdx,
|
playingOrigIdx,
|
||||||
@@ -77,7 +78,7 @@ export function QueueList({
|
|||||||
if (!t) return null
|
if (!t) return null
|
||||||
|
|
||||||
const isPlaying = origIdx === playingOrigIdx
|
const isPlaying = origIdx === playingOrigIdx
|
||||||
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
|
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
|
||||||
const dur = t.duration ? fmt(t.duration) : ''
|
const dur = t.duration ? fmt(t.duration) : ''
|
||||||
const isDragging = draggingPos === pos
|
const isDragging = draggingPos === pos
|
||||||
const isDragOver = dragOverPos === pos
|
const isDragOver = dragOverPos === pos
|
||||||
|
|||||||
@@ -1,49 +1,12 @@
|
|||||||
import axios from 'axios'
|
export type FurumiApiClient = (path: string) => Promise<unknown | null>
|
||||||
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
|
export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
|
||||||
export const API_ROOT = `${API_BASE}/api`
|
const API = apiRoot
|
||||||
|
|
||||||
const API_KEY = import.meta.env.VITE_API_KEY
|
return async function api(path: string) {
|
||||||
|
const r = await fetch(API + path)
|
||||||
export const furumiApi = axios.create({
|
if (!r.ok) return null
|
||||||
baseURL: API_ROOT,
|
return r.json()
|
||||||
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) {
|
|
||||||
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
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;
|
||||||
@@ -10,10 +5,6 @@ body {
|
|||||||
background-color: #f3f6fb;
|
background-color: #f3f6fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
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>
|
||||||
<Provider store={store}>
|
|
||||||
<App />
|
<App />
|
||||||
</Provider>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
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'
|
|
||||||
import queueReducer from './slices/queueSlice'
|
|
||||||
|
|
||||||
export const store = configureStore({
|
|
||||||
reducer: {
|
|
||||||
artists: artistsReducer,
|
|
||||||
albums: albumsReducer,
|
|
||||||
albumTracks: albumTracksReducer,
|
|
||||||
artistTracks: artistTracksReducer,
|
|
||||||
trackDetail: trackDetailReducer,
|
|
||||||
queue: queueReducer,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
|
||||||
import type { QueueItem } from '../../components/QueueList'
|
|
||||||
|
|
||||||
export interface QueueState {
|
|
||||||
items: QueueItem[]
|
|
||||||
currentIndex: number
|
|
||||||
shuffle: boolean
|
|
||||||
repeatAll: boolean
|
|
||||||
shuffleOrder: number[]
|
|
||||||
scrollSignal: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function readShufflePref(): boolean {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem('furumi_shuffle') === '1'
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRepeatPref(): boolean {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem('furumi_repeat') !== '0'
|
|
||||||
} catch {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShuffleOrder(state: QueueState) {
|
|
||||||
const n = state.items.length
|
|
||||||
if (n === 0) {
|
|
||||||
state.shuffleOrder = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const order = [...Array(n).keys()]
|
|
||||||
for (let i = order.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[order[i], order[j]] = [order[j], order[i]]
|
|
||||||
}
|
|
||||||
if (state.currentIndex !== -1) {
|
|
||||||
const ci = order.indexOf(state.currentIndex)
|
|
||||||
if (ci > 0) {
|
|
||||||
order.splice(ci, 1)
|
|
||||||
order.unshift(state.currentIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.shuffleOrder = order
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureShuffleOrder(state: QueueState) {
|
|
||||||
if (!state.shuffle) return
|
|
||||||
if (state.shuffleOrder.length !== state.items.length) {
|
|
||||||
buildShuffleOrder(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: QueueState = {
|
|
||||||
items: [],
|
|
||||||
currentIndex: -1,
|
|
||||||
shuffle: typeof window !== 'undefined' ? readShufflePref() : false,
|
|
||||||
repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true,
|
|
||||||
shuffleOrder: [],
|
|
||||||
scrollSignal: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueSlice = createSlice({
|
|
||||||
name: 'queue',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
addTrack(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
track: QueueItem
|
|
||||||
playNow?: boolean
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const { track, playNow } = action.payload
|
|
||||||
const existing = state.items.findIndex((t) => t.slug === track.slug)
|
|
||||||
if (existing !== -1) {
|
|
||||||
if (playNow) {
|
|
||||||
state.currentIndex = existing
|
|
||||||
state.scrollSignal += 1
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const oldLen = state.items.length
|
|
||||||
const idle = state.currentIndex === -1
|
|
||||||
state.items.push(track)
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
if (playNow || (oldLen === 0 && idle)) {
|
|
||||||
state.currentIndex = state.items.length - 1
|
|
||||||
state.scrollSignal += 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addTracksBatch(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
tracks: QueueItem[]
|
|
||||||
playFirst?: boolean
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const { tracks, playFirst } = action.payload
|
|
||||||
let firstNewIdx: number | null = null
|
|
||||||
for (const t of tracks) {
|
|
||||||
if (state.items.some((q) => q.slug === t.slug)) continue
|
|
||||||
if (firstNewIdx === null) firstNewIdx = state.items.length
|
|
||||||
state.items.push(t)
|
|
||||||
}
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
if (firstNewIdx === null) return
|
|
||||||
if (playFirst || state.currentIndex === -1) {
|
|
||||||
state.currentIndex = firstNewIdx
|
|
||||||
state.scrollSignal += 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
replaceQueue(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
items: QueueItem[]
|
|
||||||
playFromIndex?: number
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const { items, playFromIndex = 0 } = action.payload
|
|
||||||
state.items = items
|
|
||||||
state.currentIndex =
|
|
||||||
items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1
|
|
||||||
state.shuffleOrder = []
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
},
|
|
||||||
|
|
||||||
clearQueue(state) {
|
|
||||||
state.items = []
|
|
||||||
state.currentIndex = -1
|
|
||||||
state.shuffleOrder = []
|
|
||||||
state.scrollSignal += 1
|
|
||||||
},
|
|
||||||
|
|
||||||
playAtIndex(state, action: PayloadAction<number>) {
|
|
||||||
const i = action.payload
|
|
||||||
if (i < 0 || i >= state.items.length) return
|
|
||||||
state.currentIndex = i
|
|
||||||
state.scrollSignal += 1
|
|
||||||
},
|
|
||||||
|
|
||||||
removeFromQueueAt(state, action: PayloadAction<number>) {
|
|
||||||
const idx = action.payload
|
|
||||||
if (idx < 0 || idx >= state.items.length) return
|
|
||||||
|
|
||||||
if (idx === state.currentIndex) {
|
|
||||||
state.currentIndex = -1
|
|
||||||
} else if (state.currentIndex > idx) {
|
|
||||||
state.currentIndex -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
state.items.splice(idx, 1)
|
|
||||||
|
|
||||||
if (state.shuffle) {
|
|
||||||
const si = state.shuffleOrder.indexOf(idx)
|
|
||||||
if (si !== -1) state.shuffleOrder.splice(si, 1)
|
|
||||||
for (let i = 0; i < state.shuffleOrder.length; i++) {
|
|
||||||
if (state.shuffleOrder[i] > idx) state.shuffleOrder[i] -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
},
|
|
||||||
|
|
||||||
moveQueueItemInOrder(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{ fromPos: number; toPos: number }>,
|
|
||||||
) {
|
|
||||||
const { fromPos, toPos } = action.payload
|
|
||||||
if (fromPos === toPos) return
|
|
||||||
|
|
||||||
if (state.shuffle) {
|
|
||||||
const order = state.shuffleOrder
|
|
||||||
if (fromPos < 0 || fromPos >= order.length) return
|
|
||||||
if (toPos < 0 || toPos >= order.length) return
|
|
||||||
const item = order.splice(fromPos, 1)[0]
|
|
||||||
order.splice(toPos, 0, item)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = state.items
|
|
||||||
if (fromPos < 0 || fromPos >= items.length) return
|
|
||||||
if (toPos < 0 || toPos >= items.length) return
|
|
||||||
const qIdx = state.currentIndex
|
|
||||||
const item = items.splice(fromPos, 1)[0]
|
|
||||||
items.splice(toPos, 0, item)
|
|
||||||
if (qIdx === fromPos) state.currentIndex = toPos
|
|
||||||
else if (fromPos < qIdx && toPos >= qIdx) state.currentIndex -= 1
|
|
||||||
else if (fromPos > qIdx && toPos <= qIdx) state.currentIndex += 1
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleShuffle(state) {
|
|
||||||
state.shuffle = !state.shuffle
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem('furumi_shuffle', state.shuffle ? '1' : '0')
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
if (state.shuffle) buildShuffleOrder(state)
|
|
||||||
else state.shuffleOrder = []
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleRepeat(state) {
|
|
||||||
state.repeatAll = !state.repeatAll
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem('furumi_repeat', state.repeatAll ? '1' : '0')
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
rebuildShuffleOrder(state) {
|
|
||||||
if (state.shuffle) buildShuffleOrder(state)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const {
|
|
||||||
addTrack,
|
|
||||||
addTracksBatch,
|
|
||||||
replaceQueue,
|
|
||||||
clearQueue,
|
|
||||||
playAtIndex,
|
|
||||||
removeFromQueueAt,
|
|
||||||
moveQueueItemInOrder,
|
|
||||||
toggleShuffle,
|
|
||||||
toggleRepeat,
|
|
||||||
rebuildShuffleOrder,
|
|
||||||
} = queueSlice.actions
|
|
||||||
|
|
||||||
type QueueSliceRoot = { queue: QueueState }
|
|
||||||
|
|
||||||
export function selectQueueItems(state: QueueSliceRoot) {
|
|
||||||
return state.queue.items
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectQueueOrder(state: QueueSliceRoot): number[] {
|
|
||||||
const q = state.queue
|
|
||||||
if (!q.shuffle) return q.items.map((_, i) => i)
|
|
||||||
if (q.shuffleOrder.length !== q.items.length) {
|
|
||||||
return q.items.map((_, i) => i)
|
|
||||||
}
|
|
||||||
return q.shuffleOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectPlayingOrigIdx(state: QueueSliceRoot) {
|
|
||||||
return state.queue.currentIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectQueueScrollSignal(state: QueueSliceRoot) {
|
|
||||||
return state.queue.scrollSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectNowPlayingTrack(state: QueueSliceRoot): QueueItem | null {
|
|
||||||
const q = state.queue
|
|
||||||
if (q.currentIndex < 0 || q.currentIndex >= q.items.length) return null
|
|
||||||
return q.items[q.currentIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectShuffle(state: QueueSliceRoot) {
|
|
||||||
return state.queue.shuffle
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectRepeatAll(state: QueueSliceRoot) {
|
|
||||||
return state.queue.repeatAll
|
|
||||||
}
|
|
||||||
|
|
||||||
export default queueSlice.reducer
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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,4 +25,3 @@ 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,10 +39,6 @@ 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]
|
||||||
@@ -94,15 +90,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
if args.api_key.is_some() {
|
|
||||||
tracing::info!("x-api-key auth: enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = Arc::new(web::AppState {
|
let state = Arc::new(web::AppState {
|
||||||
pool,
|
pool,
|
||||||
storage_dir: Arc::new(args.storage_dir),
|
storage_dir: Arc::new(args.storage_dir),
|
||||||
oidc: oidc_state,
|
oidc: oidc_state,
|
||||||
api_key: args.api_key,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("Web player: http://{}", bind_addr);
|
tracing::info!("Web player: http://{}", bind_addr);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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,
|
||||||
@@ -94,27 +92,17 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auth middleware: requires valid SSO session cookie or x-api-key header.
|
/// Auth middleware: requires valid SSO session cookie.
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
req: Request,
|
req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 1. Check x-api-key header (if configured)
|
let oidc = match &state.oidc {
|
||||||
if let Some(ref expected) = state.api_key {
|
Some(o) => o,
|
||||||
if let Some(val) = req
|
None => return next.run(req).await, // No OIDC configured = no auth
|
||||||
.headers()
|
};
|
||||||
.get(X_API_KEY)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
{
|
|
||||||
if val == expected {
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check SSO session cookie (if OIDC configured)
|
|
||||||
if let Some(ref oidc) = state.oidc {
|
|
||||||
let cookies = req
|
let cookies = req
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::COOKIE)
|
.get(header::COOKIE)
|
||||||
@@ -129,16 +117,12 @@ pub async fn require_auth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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 if state.oidc.is_some() {
|
|
||||||
Redirect::to("/login").into_response()
|
|
||||||
} else {
|
} else {
|
||||||
// Only API key configured — no web login available
|
Redirect::to("/login").into_response()
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ 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 {
|
||||||
@@ -16,7 +13,6 @@ pub struct AppState {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub storage_dir: Arc<PathBuf>,
|
pub storage_dir: Arc<PathBuf>,
|
||||||
pub oidc: Option<Arc<auth::OidcState>>,
|
pub oidc: Option<Arc<auth::OidcState>>,
|
||||||
pub api_key: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||||
@@ -36,28 +32,21 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/", get(player_html))
|
.route("/", get(player_html))
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
|
|
||||||
let requires_auth = state.oidc.is_some();
|
let has_oidc = state.oidc.is_some();
|
||||||
|
|
||||||
let app = if requires_auth {
|
let app = if has_oidc {
|
||||||
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