From 50320c05c6fc68563d4151831228de7f5789ef43 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Thu, 16 Jul 2026 18:45:39 +0300 Subject: [PATCH] added player --- .gitignore | 3 +- Cargo.lock | 1 + Cargo.toml | 7 +- README.md | 1 + src/audio.rs | 456 +++++++++++++++++++++++++++++++++++++++++++ src/federation.rs | 98 +++++++++- src/main.rs | 7 + src/routes/tracks.rs | 12 ++ static/index.html | 73 ++++++- 9 files changed, 645 insertions(+), 13 deletions(-) create mode 100644 src/audio.rs diff --git a/.gitignore b/.gitignore index 44edd10..43055c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ *.sqlite3 *.sqlite3-shm *.sqlite3-wal -*federation/ +*.federation/ +.cargo/config.toml diff --git a/Cargo.lock b/Cargo.lock index 614d1cb..c7483e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -996,6 +996,7 @@ dependencies = [ "serde_json", "sqlx", "tokio", + "tokio-util", "tracing", "tracing-subscriber", ] diff --git a/Cargo.toml b/Cargo.toml index 4991d1a..8e8cfb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,9 @@ description = "Localhost music library index manager: REST API over a local SQLi [dependencies] axum = "0.8" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } -# P2P federation: publishes the library index into a DHT and searches other -# peers' libraries. Local path dependency on the frid workspace. -music-dht = { path = "../../frid/crates/music-dht" } +# P2P federation: publishes the library index into a DHT, searches other +# peers' libraries and streams audio between peers. +music-dht = { git = "https://gt.hexor.cy/ab/frid.git" } sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -17,3 +17,4 @@ chrono = "0.4" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tokio-util = { version = "0.7", features = ["io"] } diff --git a/README.md b/README.md index 063fe6d..a4e2142 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ cargo run | `FURUMI_FD_DB` | `furumi-fd.sqlite3` | Path to the SQLite database file (created if missing) | | `FURUMI_FD_LISTEN` | `127.0.0.1:8321` | Listen address | | `FURUMI_FD_FEDERATION_DIR` | `.federation` | Directory for the federation identity and DHT state | +| `FURUMI_FD_MEDIA_ROOT` | *(unset)* | Root of the audio library; `media_files.file_path` resolves against it. Unset ⇒ playback is disabled, the index API keeps working. Files are only ever **read**. | No authentication — localhost only by default. diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..0d76433 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,456 @@ +//! Audio playback support. +//! +//! Local tracks are served over HTTP (with Range support) straight from the +//! media root; federated tracks are streamed from their owner over a raw +//! `music-dht` byte stream on the [`AUDIO_ALPN`] protocol. Files are only +//! ever read, never written or deleted. +//! +//! The peer protocol is deliberately simple: the requester sends one JSON +//! line ([`AudioRequest`]), the owner answers with one JSON line +//! ([`AudioResponseHeader`]) followed by the raw file bytes from the +//! requested offset. + +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::OnceLock; +use std::task::{Context, Poll}; + +use axum::body::Body; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::Response; +use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor}; +use serde::{Deserialize, Serialize}; +use sqlx::{Row, SqlitePool}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, ReadBuf}; +use tokio_util::io::ReaderStream; + +use crate::error::{ApiError, ApiResult}; + +/// ALPN of the peer-to-peer audio streaming protocol. +pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1"; + +/// Maximum size of a JSON protocol line (request or response header). +const MAX_PROTOCOL_LINE: usize = 4096; + +/// Root directory of the local audio library (`FURUMI_FD_MEDIA_ROOT`). +/// +/// `media_files.file_path` values are resolved relative to it. `None` when +/// the variable is not set — playback is then disabled, the index API keeps +/// working. +pub fn media_root() -> Option<&'static Path> { + static ROOT: OnceLock> = OnceLock::new(); + ROOT.get_or_init(|| { + std::env::var("FURUMI_FD_MEDIA_ROOT") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }) + .as_deref() +} + +fn media_root_required() -> ApiResult<&'static Path> { + media_root().ok_or_else(|| { + ApiError::BadRequest( + "media root is not configured; set FURUMI_FD_MEDIA_ROOT to enable playback" + .to_string(), + ) + }) +} + +// --------------------------------------------------------------------------- +// Local track resolution +// --------------------------------------------------------------------------- + +/// A playable local track: where its audio lives and what it is. +pub struct LocalAudio { + pub path: PathBuf, + pub mime_type: String, +} + +fn guess_mime(path: &Path) -> &'static str { + match path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "mp3" => "audio/mpeg", + "flac" => "audio/flac", + "ogg" | "oga" => "audio/ogg", + "opus" => "audio/opus", + "wav" => "audio/wav", + "m4a" | "mp4" | "alac" => "audio/mp4", + "aac" => "audio/aac", + "aiff" | "aif" => "audio/aiff", + _ => "application/octet-stream", + } +} + +fn local_audio(file_path: &str, mime_type: &str) -> ApiResult { + let root = media_root_required()?; + let relative = file_path.trim().trim_start_matches('/'); + if relative.is_empty() || relative.split('/').any(|part| part == "..") { + return Err(ApiError::BadRequest(format!( + "media file has an unsafe path: {file_path}" + ))); + } + let path = root.join(relative); + let mime = if mime_type.trim().is_empty() { + guess_mime(&path).to_string() + } else { + mime_type.to_string() + }; + Ok(LocalAudio { + path, + mime_type: mime, + }) +} + +/// Resolves a local track id to its audio file on disk. +pub async fn resolve_local_track(pool: &SqlitePool, track_id: i64) -> ApiResult { + let row = sqlx::query( + "SELECT m.file_path, m.mime_type FROM tracks t + JOIN media_files m ON m.id = t.audio_file_id + WHERE t.id = ?", + ) + .bind(track_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| ApiError::not_found("track", track_id))?; + local_audio(&row.get::(0), &row.get::(1)) +} + +/// Resolves a federated [`ItemId`] back to a local track of this instance. +/// +/// The id is derived from the owner and the track's local key, so the owner +/// can always recompute it for its own library. +pub async fn resolve_item( + pool: &SqlitePool, + own: EndpointId, + item_id: ItemId, +) -> ApiResult> { + let rows = sqlx::query( + "SELECT t.id, m.file_path, m.mime_type FROM tracks t + JOIN media_files m ON m.id = t.audio_file_id + WHERE t.is_hidden = 0", + ) + .fetch_all(pool) + .await?; + for row in rows { + let track_id: i64 = row.get(0); + let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{track_id}")); + if derived == item_id { + return Ok(Some(local_audio( + &row.get::(1), + &row.get::(2), + )?)); + } + } + Ok(None) +} + +// --------------------------------------------------------------------------- +// HTTP Range handling and responses +// --------------------------------------------------------------------------- + +/// Extracts the start offset of a `Range` header. +/// +/// Playback only ever needs `bytes=N-` (browsers seek by requesting an open +/// range); an explicit end is accepted but the response still runs to the +/// end of the file. +pub fn range_start(headers: &HeaderMap) -> Option { + let value = headers.get(header::RANGE)?.to_str().ok()?; + let spec = value.strip_prefix("bytes=")?; + let start = spec.split(['-', ',']).next()?; + start.parse().ok() +} + +fn audio_response( + body: Body, + mime_type: &str, + total_size: u64, + offset: u64, + ranged: bool, +) -> ApiResult { + if offset > 0 && offset >= total_size { + return Ok(Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{total_size}")) + .body(Body::empty()) + .unwrap()); + } + let mut builder = Response::builder() + .header(header::CONTENT_TYPE, mime_type) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CONTENT_LENGTH, total_size - offset) + .header(header::CACHE_CONTROL, "no-store"); + builder = if ranged { + builder.status(StatusCode::PARTIAL_CONTENT).header( + header::CONTENT_RANGE, + format!("bytes {offset}-{}/{total_size}", total_size - 1), + ) + } else { + builder.status(StatusCode::OK) + }; + builder + .body(body) + .map_err(|err| ApiError::Internal(err.into())) +} + +/// Serves a local audio file over HTTP, honoring an optional Range offset. +pub async fn serve_local(local: LocalAudio, headers: &HeaderMap) -> ApiResult { + let mut file = tokio::fs::File::open(&local.path).await.map_err(|err| { + ApiError::NotFound(format!( + "audio file {} is not readable: {err}", + local.path.display() + )) + })?; + let total_size = file + .metadata() + .await + .map_err(|err| ApiError::Internal(err.into()))? + .len(); + let offset = range_start(headers); + let start = offset.unwrap_or(0).min(total_size); + if start > 0 { + file.seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|err| ApiError::Internal(err.into()))?; + } + audio_response( + Body::from_stream(ReaderStream::new(file)), + &local.mime_type, + total_size, + offset.unwrap_or(0), + offset.is_some(), + ) +} + +// --------------------------------------------------------------------------- +// Peer-to-peer audio protocol +// --------------------------------------------------------------------------- + +/// First (and only) line sent by the requesting peer. +#[derive(Debug, Serialize, Deserialize)] +struct AudioRequest { + /// Hex-encoded [`ItemId`] of the track, as returned by federated search. + item_id: String, + /// Byte offset to start streaming from (Range passthrough). + offset: u64, +} + +/// First line sent back by the owner; raw file bytes follow on success. +#[derive(Debug, Serialize, Deserialize)] +struct AudioResponseHeader { + ok: bool, + #[serde(default)] + error: Option, + #[serde(default)] + mime_type: String, + #[serde(default)] + total_size: u64, + #[serde(default)] + offset: u64, +} + +pub fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +pub fn hex_decode_item_id(value: &str) -> Option { + if value.len() != 64 { + return None; + } + let mut bytes = [0u8; 32]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?; + } + Some(ItemId::from_bytes(bytes)) +} + +/// Reads one `\n`-terminated line from the stream, bounded by +/// [`MAX_PROTOCOL_LINE`]. +async fn read_line(reader: &mut R) -> anyhow::Result> { + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = reader.read(&mut byte).await?; + if n == 0 { + anyhow::bail!("stream ended before the protocol line was complete"); + } + if byte[0] == b'\n' { + return Ok(line); + } + line.push(byte[0]); + if line.len() > MAX_PROTOCOL_LINE { + anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes"); + } + } +} + +async fn write_line( + writer: &mut W, + value: &impl Serialize, +) -> anyhow::Result<()> { + let mut line = serde_json::to_vec(value)?; + line.push(b'\n'); + writer.write_all(&line).await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Serving side: answer audio requests from other peers +// --------------------------------------------------------------------------- + +/// Runs the accept loop of the audio protocol until the acceptor closes. +/// +/// Every visible (non-hidden) track of the local index is downloadable by +/// every peer of the network — the libraries of all participants are equal. +pub async fn serve_peers(mut acceptor: StreamAcceptor, pool: SqlitePool, own: EndpointId) { + while let Some(stream) = acceptor.accept().await { + let pool = pool.clone(); + tokio::spawn(async move { + let peer = stream.peer_id; + if let Err(err) = serve_one(stream, pool, own).await { + tracing::warn!(peer = %peer, "audio stream failed: {err:#}"); + } + }); + } +} + +async fn serve_one(mut stream: ByteStream, pool: SqlitePool, own: EndpointId) -> anyhow::Result<()> { + let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?; + tracing::info!( + peer = %stream.peer_id, + item = %request.item_id, + offset = request.offset, + "peer requested audio" + ); + + let resolved = match hex_decode_item_id(&request.item_id) { + Some(item_id) => match resolve_item(&pool, own, item_id).await { + Ok(local) => local.ok_or_else(|| "track not found in the library".to_string()), + Err(err) => Err(format!("library lookup failed: {err:?}")), + }, + None => Err("malformed item_id".to_string()), + }; + let local = match resolved { + Ok(local) => local, + Err(message) => return refuse(stream, message).await, + }; + + let mut file = match tokio::fs::File::open(&local.path).await { + Ok(file) => file, + Err(err) => return refuse(stream, format!("audio file is not readable: {err}")).await, + }; + let total_size = file.metadata().await?.len(); + let offset = request.offset.min(total_size); + if offset > 0 { + file.seek(std::io::SeekFrom::Start(offset)).await?; + } + write_line( + &mut stream.send, + &AudioResponseHeader { + ok: true, + error: None, + mime_type: local.mime_type, + total_size, + offset, + }, + ) + .await?; + tokio::io::copy(&mut file, &mut stream.send).await?; + stream.send.finish()?; + // Wait until the peer read everything (or gave up) before dropping the + // stream, otherwise the tail of the file is lost. + let _ = stream.send.stopped().await; + Ok(()) +} + +/// Sends a refusal header and waits until the peer read it before dropping +/// the stream (and with it the connection). +async fn refuse(mut stream: ByteStream, message: String) -> anyhow::Result<()> { + write_line( + &mut stream.send, + &AudioResponseHeader { + ok: false, + error: Some(message.clone()), + mime_type: String::new(), + total_size: 0, + offset: 0, + }, + ) + .await?; + stream.send.finish()?; + let _ = stream.send.stopped().await; + anyhow::bail!("refused audio request: {message}"); +} + +// --------------------------------------------------------------------------- +// Requesting side: proxy a remote track to the local browser +// --------------------------------------------------------------------------- + +/// Adapter that keeps the whole [`ByteStream`] (and with it the underlying +/// connection) alive while the HTTP response body is being streamed. +struct StreamBody(ByteStream); + +impl AsyncRead for StreamBody { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.0.recv).poll_read(cx, buf) + } +} + +/// Fetches a track from `owner` over the federation and returns it as an +/// HTTP response, passing the Range offset through to the peer. +pub async fn serve_remote( + service: &MusicDhtService, + owner: EndpointId, + item_id_hex: &str, + headers: &HeaderMap, +) -> ApiResult { + let offset = range_start(headers); + let mut stream = service + .open_stream(owner, AUDIO_ALPN) + .await + .map_err(|err| ApiError::BadRequest(format!("cannot reach the owner peer: {err}")))?; + write_line( + &mut stream.send, + &AudioRequest { + item_id: item_id_hex.to_string(), + offset: offset.unwrap_or(0), + }, + ) + .await + .map_err(ApiError::Internal)?; + stream + .send + .finish() + .map_err(|err| ApiError::Internal(err.into()))?; + let header_line = read_line(&mut stream.recv).await.map_err(ApiError::Internal)?; + let header: AudioResponseHeader = + serde_json::from_slice(&header_line).map_err(|err| ApiError::Internal(err.into()))?; + if !header.ok { + return Err(ApiError::BadRequest(format!( + "peer refused the stream: {}", + header.error.unwrap_or_else(|| "unknown error".to_string()) + ))); + } + let mime = if header.mime_type.is_empty() { + "application/octet-stream".to_string() + } else { + header.mime_type + }; + audio_response( + Body::from_stream(ReaderStream::new(StreamBody(stream))), + &mime, + header.total_size, + header.offset, + offset.is_some(), + ) +} diff --git a/src/federation.rs b/src/federation.rs index 6d962c2..e9cfcb2 100644 --- a/src/federation.rs +++ b/src/federation.rs @@ -19,7 +19,8 @@ use axum::extract::{Query, State}; use axum::routing::{get, post, put}; use axum::{Json, Router}; use music_dht::{ - ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, RendezvousConfig, + EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, + PeerTicket, RendezvousConfig, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -134,6 +135,8 @@ impl Federation { .network_id(NetworkId::from_name(&network_name)) // Peers of the network find each other knowing only its name. .rendezvous(RendezvousConfig::default()) + // Peers stream each other's audio over this protocol. + .stream_protocol(crate::audio::AUDIO_ALPN) .build() .map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?; let (service, mut events) = MusicDhtService::start(config) @@ -146,6 +149,16 @@ impl Federation { "federation started" ); + // Serve audio requests from other peers of the network. + let audio_acceptor = service + .stream_acceptor(crate::audio::AUDIO_ALPN) + .map_err(|err| anyhow::anyhow!("failed to take the audio acceptor: {err}"))?; + let audio_task = tokio::spawn(crate::audio::serve_peers( + audio_acceptor, + self.pool.clone(), + service.endpoint_id(), + )); + // Drain DHT events into the log; the channel is bounded and must be // consumed. let event_task = tokio::spawn(async move { @@ -167,7 +180,7 @@ impl Federation { *guard = Some(Running { service, network_name, - tasks: vec![event_task, sync_task], + tasks: vec![event_task, sync_task, audio_task], }); self.set_error(None); Ok(()) @@ -366,6 +379,7 @@ async fn collect_library(pool: &SqlitePool) -> anyhow::Result> { fn item_to_json(item: &LibraryItem) -> Value { json!({ + "id": crate::audio::hex_encode(item.id.as_bytes()), "kind": item.kind.as_str(), "name": item.name, "artist_names": item.artist_names, @@ -387,6 +401,86 @@ pub fn router() -> Router> { .route("/federation/settings", put(put_settings)) .route("/federation/search", get(search)) .route("/federation/sync", post(sync_now)) + .route("/federation/stream", get(stream_remote)) + .route("/federation/ticket", get(get_ticket)) + .route("/federation/connect", post(connect_ticket)) +} + +impl Federation { + /// A clone of the running DHT service, or an error when federation is + /// off. + async fn service(&self) -> ApiResult> { + let guard = self.running.lock().await; + guard + .as_ref() + .map(|running| running.service.clone()) + .ok_or_else(|| ApiError::BadRequest("federation is not running".to_string())) + } +} + +#[derive(Debug, Deserialize)] +struct StreamParams { + /// Endpoint id of the owning peer, as returned by federated search. + owner: String, + /// Hex item id of the track, as returned by federated search. + item_id: String, +} + +/// Plays a federated track: proxies the audio bytes from the owning peer. +/// A track owned by this very instance is served straight from disk. +async fn stream_remote( + State(fed): State>, + Query(params): Query, + headers: axum::http::HeaderMap, +) -> ApiResult { + let service = fed.service().await?; + let owner: EndpointId = params + .owner + .parse() + .map_err(|_| ApiError::BadRequest(format!("malformed owner id '{}'", params.owner)))?; + if owner == service.endpoint_id() { + let item_id = crate::audio::hex_decode_item_id(¶ms.item_id) + .ok_or_else(|| ApiError::BadRequest("malformed item_id".to_string()))?; + let local = crate::audio::resolve_item(&fed.pool, owner, item_id) + .await? + .ok_or_else(|| ApiError::NotFound("track not found in the library".to_string()))?; + return crate::audio::serve_local(local, &headers).await; + } + crate::audio::serve_remote(&service, owner, ¶ms.item_id, &headers).await +} + +/// Returns this peer's connection ticket (manual peering, e.g. between two +/// local instances without waiting for rendezvous discovery). +async fn get_ticket(State(fed): State>) -> ApiResult> { + let service = fed.service().await?; + let ticket = service + .ticket() + .await + .map_err(|err| ApiError::BadRequest(format!("cannot create a ticket: {err}")))?; + Ok(Json(json!({ "ticket": ticket.to_string() }))) +} + +#[derive(Debug, Deserialize)] +struct ConnectBody { + ticket: String, +} + +/// Connects to another peer by its ticket. +async fn connect_ticket( + State(fed): State>, + Json(body): Json, +) -> ApiResult> { + let service = fed.service().await?; + let ticket: PeerTicket = body + .ticket + .trim() + .parse() + .map_err(|err| ApiError::BadRequest(format!("malformed ticket: {err}")))?; + let peer = service + .connect(ticket) + .await + .map_err(|err| ApiError::BadRequest(format!("connect failed: {err}")))?; + Ok(Json(json!({ "connected": peer.to_string() }))) } async fn get_status(State(fed): State>) -> ApiResult> { diff --git a/src/main.rs b/src/main.rs index a067ef9..7c176e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod audio; mod error; mod federation; mod routes; @@ -32,6 +33,12 @@ async fn main() -> anyhow::Result<()> { .await?; schema::init(&pool).await?; tracing::info!("database ready at {db_path}"); + match audio::media_root() { + Some(root) => tracing::info!("media root: {} (read-only)", root.display()), + None => tracing::warn!( + "FURUMI_FD_MEDIA_ROOT is not set — playback is disabled, the index API works as usual" + ), + } // Federation state (identity, DHT replicas) lives outside the main db. let federation_dir = std::env::var("FURUMI_FD_FEDERATION_DIR") diff --git a/src/routes/tracks.rs b/src/routes/tracks.rs index 1af53dd..abe264f 100644 --- a/src/routes/tracks.rs +++ b/src/routes/tracks.rs @@ -13,6 +13,18 @@ pub fn router() -> Router { .route("/{id}", get(get_one).patch(update).delete(delete_one)) .route("/{id}/artists", put(set_artists)) .route("/{id}/genres", put(set_genres)) + .route("/{id}/audio", get(stream_audio)) +} + +/// Streams the track's audio file from the local media root (read-only, +/// with Range support for seeking). +async fn stream_audio( + State(pool): State, + Path(id): Path, + headers: axum::http::HeaderMap, +) -> ApiResult { + let local = crate::audio::resolve_local_track(&pool, id).await?; + crate::audio::serve_local(local, &headers).await } #[derive(Debug, Serialize, sqlx::FromRow)] diff --git a/static/index.html b/static/index.html index 9bdcca5..db5e24f 100644 --- a/static/index.html +++ b/static/index.html @@ -61,10 +61,18 @@ td.actions { text-align: right; white-space: nowrap; } td .muted { color: var(--muted); } #toast { - position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: var(--danger); color: #fff; padding: 10px 18px; border-radius: 8px; - display: none; max-width: 80vw; + display: none; max-width: 80vw; z-index: 10; } + #player { + position: fixed; bottom: 0; left: 0; right: 0; z-index: 9; + display: flex; align-items: center; gap: 14px; + padding: 8px 20px; background: var(--panel); border-top: 1px solid var(--border); + } + #player-title { min-width: 200px; max-width: 40vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #player audio { flex: 1; max-width: 640px; height: 36px; } + main { padding-bottom: 90px; } #toast.ok { background: #3f9d5f; } dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); @@ -76,7 +84,7 @@ -

furumi-fd

менеджер музыкальной библиотеки (только индекс — файлы не трогает)
+

furumi-fd

менеджер музыкальной библиотеки (индекс + прослушивание; файлы никогда не изменяются)