15 Commits

Author SHA1 Message Date
ab
e85ed32b7b Merge pull request 'Fix source-missing auto-merge and remove Pink Floyd examples from prompts' (#6) from DEV into main
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m11s
Publish Web Player Image / build-and-push-image (push) Successful in 1m15s
Reviewed-on: #6
2026-03-20 01:07:15 +00:00
71d5a38f21 Fix source-missing auto-merge and remove Pink Floyd examples from prompts
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m10s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m10s
Auto-merge: when ingest pipeline detects "source file missing", now checks
if the track already exists in the library by file_hash. If so, marks the
pending entry as 'merged' instead of 'error' — avoiding stale error entries
for files that were already successfully ingested in a previous run.

Prompts: replaced Pink Floyd/The Wall/Have a Cigar examples in both
normalize.txt and merge.txt with Deep Purple examples. The LLM was using
these famous artist/album/track names as fallback output when raw metadata
was empty or ambiguous, causing hallucinated metadata like
"artist: Pink Floyd, title: Have a Cigar" for completely unrelated tracks.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:05:22 +00:00
ab
e34440498c Merge pull request 'Disabled obsolete CI' (#5) from DEV into main
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m17s
Publish Web Player Image / build-and-push-image (push) Successful in 1m15s
Reviewed-on: #5
2026-03-20 00:49:45 +00:00
8d70a5133a Disabled obsolete CI
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m17s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m14s
2026-03-20 00:49:27 +00:00
ab
f873542d02 Merge pull request 'DEV' (#4) from DEV into main
Some checks failed
Publish Metadata Agent Image / build-and-push-image (push) Failing after 10s
Publish Web Player Image / build-and-push-image (push) Failing after 10s
Reviewed-on: #4
2026-03-20 00:02:49 +00:00
56760be586 Disabled obsolete CI
Some checks failed
Publish Metadata Agent Image (dev) / build-and-push-image (push) Failing after 10s
Publish Web Player Image (dev) / build-and-push-image (push) Failing after 9s
2026-03-20 00:01:30 +00:00
108c374c6d ci: update Dockerfile paths after moving to docker/ directory
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:00:42 +00:00
ab
2129dc8007 Merge pull request 'feat: added express + vite app + oidc' (#1) from feature/node-app into DEV
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m18s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m25s
Publish Server Image / build-and-push-image (push) Successful in 2m56s
Reviewed-on: #1
2026-03-19 23:44:29 +00:00
ab
3f2013e9d5 Merge pull request 'Fix phantom duplicate tracks created on Merged file ingestion' (#3) from DEV into main
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m47s
Publish Web Player Image / build-and-push-image (push) Successful in 1m41s
Publish Server Image / build-and-push-image (push) Successful in 3m1s
Reviewed-on: #3
2026-03-19 23:43:36 +00:00
cc3ef04cbe Fix phantom duplicate tracks created on Merged file ingestion
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m16s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m10s
Publish Server Image / build-and-push-image (push) Successful in 2m36s
When the mover returns MoveOutcome::Merged (destination already exists,
source deleted), approve_and_finalize was checking only file_hash to
detect duplicates. Since the second ingestion had a different hash
(different quality/mastering), it bypassed the check and created a
phantom track record pointing to an existing storage_path with the
wrong hash (of the now-deleted source file).

Added a second dedup check by storage_path: if a non-hidden track
already exists at that path, mark pending as 'merged' instead of
inserting a new track row. This prevents phantom entries for any
subsequent ingestion of a different-quality version of an already
stored file.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:37:33 +00:00
ab
7ede23ff94 Merge pull request 'Improved admin UI' (#2) from DEV into main
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m14s
Publish Web Player Image / build-and-push-image (push) Successful in 1m16s
Publish Server Image / build-and-push-image (push) Successful in 2m20s
Reviewed-on: #2
2026-03-19 15:33:26 +00:00
a730ab568c Improved admin UI
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m6s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m7s
Publish Server Image / build-and-push-image (push) Successful in 2m13s
2026-03-19 15:28:25 +00:00
Boris Cherepanov
c30a3aff5d feat: refactoring
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m10s
Publish Web Player Image / build-and-push-image (push) Successful in 1m7s
Publish Server Image / build-and-push-image (push) Successful in 2m7s
2026-03-19 18:04:13 +03:00
Boris Cherepanov
71d88bacf2 feat: refactoring modules
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m9s
Publish Web Player Image / build-and-push-image (push) Successful in 1m15s
Publish Server Image / build-and-push-image (push) Successful in 2m11s
2026-03-19 17:32:27 +03:00
5fb8821709 Fixed merge
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m7s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m11s
Publish Server Image / build-and-push-image (push) Successful in 2m12s
2026-03-19 14:16:45 +00:00
31 changed files with 2222 additions and 97 deletions

View File

@@ -41,7 +41,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.agent
file: docker/Dockerfile.agent
push: true
tags: ${{ steps.info.outputs.tags }}
build-args: |

View File

@@ -52,7 +52,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.agent
file: docker/Dockerfile.agent
push: true
tags: ${{ steps.info.outputs.tags }}
build-args: |

View File

@@ -41,7 +41,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.web-player
file: docker/Dockerfile.web-player
push: true
tags: ${{ steps.info.outputs.tags }}
build-args: |

View File

@@ -52,7 +52,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.web-player
file: docker/Dockerfile.web-player
push: true
tags: ${{ steps.info.outputs.tags }}
build-args: |

View File

@@ -51,6 +51,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.info.outputs.tags }}
build-args: |

View File

@@ -4,14 +4,14 @@ You are a music library artist merge assistant. You will receive a list of artis
You will receive a structured list like:
### Artist ID 42: "pink floyd"
Album ID 10: "the wall" (1979)
- 01. "In the Flesh?" [track_id=100]
- 02. "The Thin Ice" [track_id=101]
### Artist ID 42: "deep purple"
Album ID 10: "machine head" (1972)
- 01. "Highway Star" [track_id=100]
- 02. "Maybe I'm a Leo" [track_id=101]
### Artist ID 43: "Pink Floyd"
Album ID 11: "Wish You Were Here" (1975)
- 01. "Shine On You Crazy Diamond (Parts I-V)" [track_id=200]
### Artist ID 43: "Deep Purple"
Album ID 11: "Burn" (1974)
- 01. "Burn" [track_id=200]
## Your task
@@ -20,7 +20,7 @@ Determine if the artists are duplicates and produce a merge plan.
## Rules
### 1. Canonical artist name
- Use correct capitalization and canonical spelling (e.g., "pink floyd" → "Pink Floyd", "AC DC" → "AC/DC").
- Use correct capitalization and canonical spelling (e.g., "deep purple" → "Deep Purple", "AC DC" → "AC/DC").
- If the database already contains an artist with a well-formed name, prefer that exact form.
- If one artist has clearly more tracks or albums, their name spelling may be more authoritative.
- Fix obvious typos or casing errors.
@@ -54,7 +54,7 @@ Determine if the artists are duplicates and produce a merge plan.
You MUST respond with a single JSON object, no markdown fences, no extra text:
{"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "The Wall", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Wish You Were Here", "merge_into_album_id": null}], "notes": "..."}
{"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "Machine Head", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Burn", "merge_into_album_id": null}], "notes": "..."}
- `canonical_artist_name`: the single correct name for this artist after merging.
- `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided).

View File

@@ -3,10 +3,10 @@ You are a music metadata normalization assistant. Your job is to take raw metada
## Rules
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
- "pink floyd" → "Pink Floyd"
- "deep purple" → "Deep Purple"
- "AC DC" → "AC/DC"
- "Guns n roses" → "Guns N' Roses"
- "Led zepplin" → "Led Zeppelin" (fix common misspellings)
- "guns n roses" → "Guns N' Roses"
- "led zepplin" → "Led Zeppelin" (fix common misspellings)
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
@@ -43,12 +43,12 @@ You are a music metadata normalization assistant. Your job is to take raw metada
- Preserve original language for non-English albums.
- If the database already contains a matching album under the same artist, use the existing name exactly.
- Do not alter the creative content of album names (same principle as track titles).
- **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "The Wall" (1979) remastered in 2011 → album: "The Wall (Remastered)", year: 2011.
- **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "Paranoid" (1970) remastered in 2009 → album: "Paranoid (Remastered)", year: 2009.
4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
- Use title case for English titles.
- Preserve original language for non-English titles.
- Remove leading track numbers if present (e.g., "01 - Have a Cigar" → "Have a Cigar").
- Remove leading track numbers if present (e.g., "01 - Smoke on the Water" → "Smoke on the Water").
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content. If a title contains unusual punctuation, numbers, apostrophes, or symbols — they are intentional and must be kept as-is.
- If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names.

View File

@@ -334,18 +334,31 @@ pub async fn approve_and_finalize(
.fetch_one(pool)
.await?;
// Check if track already exists (e.g. previously approved but pending not cleaned up)
// Check if track already exists by file_hash (re-approval of same file)
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
.bind(&pt.file_hash)
.fetch_optional(pool)
.await?;
if let Some((track_id,)) = existing {
// Already finalized — just mark pending as approved
update_pending_status(pool, pending_id, "approved", None).await?;
return Ok(track_id);
}
// Check if track already exists by storage_path (Merged: different quality file landed
// at the same destination, source was deleted — don't create a phantom duplicate)
let existing_path: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM tracks WHERE storage_path = $1 AND NOT hidden"
)
.bind(storage_path)
.fetch_optional(pool)
.await?;
if let Some((track_id,)) = existing_path {
update_pending_status(pool, pending_id, "merged", None).await?;
return Ok(track_id);
}
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
let artist_id = upsert_artist(pool, artist_name).await?;
@@ -837,6 +850,12 @@ pub struct AlbumTrackRow {
pub genre: Option<String>,
}
pub async fn set_album_tracks_genre(pool: &PgPool, album_id: i64, genre: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE tracks SET genre = $2 WHERE album_id = $1")
.bind(album_id).bind(genre).execute(pool).await?;
Ok(())
}
pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> {
let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as(
"SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1"
@@ -873,6 +892,14 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result<Option<(Str
Ok(row)
}
/// Returns the storage_path of the first track in an album (for embedded cover fallback).
pub async fn get_album_first_track_path(pool: &PgPool, album_id: i64) -> Result<Option<String>, sqlx::Error> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT storage_path FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title LIMIT 1"
).bind(album_id).fetch_optional(pool).await?;
Ok(row.map(|(p,)| p))
}
pub async fn get_artist_by_id(pool: &PgPool, id: i64) -> Result<Option<Artist>, sqlx::Error> {
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
.bind(id).fetch_optional(pool).await

View File

@@ -188,8 +188,20 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
}
}
} else {
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
// Source file is gone — check if already in library by hash
let in_library: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM tracks WHERE file_hash = $1)"
)
.bind(&pt.file_hash)
.fetch_one(&state.pool).await.unwrap_or((false,));
if in_library.0 {
tracing::info!(id = %pt.id, "Source missing but track already in library — merging");
db::update_pending_status(&state.pool, pt.id, "merged", None).await?;
} else {
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
}
continue;
};

View File

@@ -147,6 +147,27 @@ async fn merge_db(
proposal: &MergeProposal,
loser_ids: &[i64],
) -> anyhow::Result<()> {
// 0. Validate proposal — ensure winner and all album IDs belong to source artists
let source_ids: Vec<i64> = loser_ids.iter().copied()
.chain(std::iter::once(proposal.winner_artist_id))
.collect();
// Verify winner_artist_id is one of the source artists
if !source_ids.contains(&proposal.winner_artist_id) {
anyhow::bail!(
"winner_artist_id {} is not among source artists {:?}",
proposal.winner_artist_id, source_ids
);
}
// Build set of valid album IDs (albums that actually belong to source artists)
let mut valid_album_ids = std::collections::HashSet::<i64>::new();
for &src_id in &source_ids {
let rows: Vec<(i64,)> = sqlx::query_as("SELECT id FROM albums WHERE artist_id = $1")
.bind(src_id).fetch_all(&mut **tx).await?;
for (id,) in rows { valid_album_ids.insert(id); }
}
// 1. Rename winner artist to canonical name
sqlx::query("UPDATE artists SET name = $2 WHERE id = $1")
.bind(proposal.winner_artist_id)
@@ -155,6 +176,15 @@ async fn merge_db(
// 2. Process album mappings from the proposal
for mapping in &proposal.album_mappings {
// Skip albums that don't belong to any source artist (LLM hallucinated IDs)
if !valid_album_ids.contains(&mapping.source_album_id) {
tracing::warn!(
album_id = mapping.source_album_id,
"Skipping album mapping: album does not belong to source artists"
);
continue;
}
// Skip if source was already processed (idempotent retry support)
let src_exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)")
.bind(mapping.source_album_id)

View File

@@ -295,13 +295,14 @@ function renderFilterBar(s) {
`;
}
function showTab(tab, btn) {
function showTab(tab, btn, noHash) {
currentTab = tab;
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
clearSelection();
const pag = document.getElementById('lib-pagination');
if (pag) pag.style.display = 'none';
if (!noHash) location.hash = tab;
if (tab === 'queue') { loadQueue(); loadStats(); }
else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); }
else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); }
@@ -312,7 +313,11 @@ function showTab(tab, btn) {
// --- Queue ---
async function loadQueue(status, keepSelection) {
currentFilter = status;
if (!keepSelection) { clearSelection(); queueOffset = 0; }
if (!keepSelection) {
clearSelection();
queueOffset = 0;
location.hash = status ? 'queue/' + status : 'queue';
}
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
const raw = await api(`/queue${qs}`) || [];
const hasMore = raw.length > queuePageSize;
@@ -359,6 +364,9 @@ function renderQueue(hasMore) {
const artist = it.norm_artist || it.raw_artist || '-';
const title = it.norm_title || it.raw_title || '-';
const album = it.norm_album || it.raw_album || '-';
const albumCoverUrl = (it.norm_album || it.raw_album) && (it.norm_artist || it.raw_artist)
? `${API}/albums/cover-by-name?artist=${encodeURIComponent(it.norm_artist||it.raw_artist||'')}&name=${encodeURIComponent(it.norm_album||it.raw_album||'')}`
: null;
const year = it.norm_year || it.raw_year || '';
const tnum = it.norm_track_number || it.raw_track_number || '';
const canApprove = it.status === 'review';
@@ -368,7 +376,7 @@ function renderQueue(hasMore) {
<td><span class="status status-${it.status}">${it.status}</span></td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}"><span style="display:inline-flex;align-items:center;gap:4px">${albumCoverUrl?`<img src="${albumCoverUrl}" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">`:''}${esc(album)}</span></td>
<td>${year}</td>
<td>${tnum}</td>
<td>${conf}</td>
@@ -1163,6 +1171,37 @@ function openTrackEditForArtist(trackId, artistId) {
openTrackEdit(trackId, () => openArtistForm(artistId));
}
// --- Album inline meta edit (from artist form) ---
async function saveAlbumMeta(albumId, artistId) {
const name = document.getElementById(`alb-name-${albumId}`)?.value?.trim();
const yearRaw = document.getElementById(`alb-year-${albumId}`)?.value;
if (!name) return;
await api(`/albums/${albumId}/edit`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, year: parseInt(yearRaw) || null, artist_id: artistId }),
});
// Update header display in place
const block = document.getElementById(`album-block-${albumId}`);
if (block) {
const nameSpan = block.querySelector('.ab-name');
if (nameSpan) nameSpan.textContent = name;
const yearSpan = block.querySelector('.ab-year');
if (yearSpan) yearSpan.textContent = yearRaw || '';
}
}
async function applyAlbumGenre(albumId) {
const genre = document.getElementById(`alb-genre-${albumId}`)?.value?.trim();
if (!genre) return;
await api(`/albums/${albumId}/genre`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ genre }),
});
document.getElementById(`alb-genre-${albumId}`).value = '';
}
// --- Helpers ---
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
@@ -1495,7 +1534,20 @@ async function openArtistForm(id) {
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
</div>`).join('');
body.innerHTML = tracks;
const albumMeta = `<div style="padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg-panel)">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<input id="alb-name-${alb.id}" value="${esc(alb.name)}" placeholder="Album name"
style="flex:2;min-width:140px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<input id="alb-year-${alb.id}" type="number" value="${alb.year??''}" placeholder="Year"
style="width:70px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<button class="btn btn-primary" style="font-size:10px;padding:3px 10px" onclick="saveAlbumMeta(${alb.id},${id})">Save</button>
<span style="color:var(--border);user-select:none">|</span>
<input id="alb-genre-${alb.id}" placeholder="Apply genre to all tracks…"
style="flex:1;min-width:130px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<button class="btn btn-edit" style="font-size:10px;padding:3px 10px" onclick="applyAlbumGenre(${alb.id})">Apply</button>
</div>
</div>`;
body.innerHTML = albumMeta + tracks;
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
}
@@ -1586,9 +1638,82 @@ async function removeAppearance(artistId, trackId, btn) {
btn.closest('.appearance-row').remove();
}
// --- Cover preview ---
(function() {
const box = document.createElement('div');
box.id = 'cover-preview';
box.style.cssText = [
'display:none', 'position:fixed', 'z-index:9999', 'pointer-events:none',
'border-radius:10px', 'overflow:hidden',
'box-shadow:0 12px 40px rgba(0,0,0,0.85)',
'border:1px solid rgba(255,255,255,0.08)',
'background:#0a0c12', 'transition:opacity 0.1s',
].join(';');
const img = document.createElement('img');
img.style.cssText = 'display:block;width:280px;height:280px;object-fit:contain';
box.appendChild(img);
document.body.appendChild(box);
let showTimer = null;
function isCoverImg(el) {
return el && el.tagName === 'IMG' && el.src && el.src.includes('/cover');
}
function place(e) {
const margin = 16, pw = 280, ph = 280;
const vw = window.innerWidth, vh = window.innerHeight;
let x = e.clientX + margin, y = e.clientY + margin;
if (x + pw > vw - 8) x = e.clientX - pw - margin;
if (y + ph > vh - 8) y = e.clientY - ph - margin;
box.style.left = x + 'px';
box.style.top = y + 'px';
}
document.addEventListener('mouseover', e => {
if (!isCoverImg(e.target)) return;
clearTimeout(showTimer);
showTimer = setTimeout(() => {
img.src = e.target.src;
box.style.display = 'block';
place(e);
}, 120);
});
document.addEventListener('mousemove', e => {
if (box.style.display === 'none') return;
if (!isCoverImg(e.target)) { clearTimeout(showTimer); box.style.display = 'none'; return; }
place(e);
});
document.addEventListener('mouseout', e => {
if (!isCoverImg(e.target)) return;
clearTimeout(showTimer);
box.style.display = 'none';
});
})();
// --- Init ---
(function restoreFromHash() {
const hash = location.hash.slice(1); // strip #
if (!hash) return;
const [tab, filter] = hash.split('/');
const validTabs = ['queue','tracks','albums','artists','merges'];
if (!validTabs.includes(tab)) return;
const btn = Array.from(document.querySelectorAll('nav button'))
.find(b => (b.getAttribute('onclick') || '').includes(`'${tab}'`));
if (!btn) return;
// Switch tab without overwriting the hash
showTab(tab, btn, true);
// For queue, also restore the filter
if (tab === 'queue' && filter) {
currentFilter = filter;
loadQueue(filter);
}
})();
loadStats();
loadQueue();
if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
setInterval(loadStats, 5000);
// Auto-refresh queue when on queue tab
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);

View File

@@ -528,6 +528,20 @@ pub async fn update_album_full(
}
}
#[derive(Deserialize)]
pub struct SetGenreBody { pub genre: String }
pub async fn set_album_tracks_genre(
State(state): State<S>,
Path(id): Path<i64>,
Json(body): Json<SetGenreBody>,
) -> impl IntoResponse {
match db::set_album_tracks_genre(&state.pool, id, &body.genre).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
#[derive(Deserialize)]
pub struct ReorderBody {
pub orders: Vec<(i64, i32)>,
@@ -544,19 +558,82 @@ pub async fn reorder_album_tracks(
}
}
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
let cover = match db::get_album_cover(&state.pool, id).await {
Ok(Some(c)) => c,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
/// Cover by artist+album name — used for queue items that may not have an album_id yet.
#[derive(Deserialize)]
pub struct CoverByNameQuery {
#[serde(default)] pub artist: String,
#[serde(default)] pub name: String,
}
pub async fn album_cover_by_name(State(state): State<S>, Query(q): Query<CoverByNameQuery>) -> impl IntoResponse {
let album_id = match db::find_album_id(&state.pool, &q.artist, &q.name).await {
Ok(Some(id)) => id,
_ => return StatusCode::NOT_FOUND.into_response(),
};
match tokio::fs::read(&cover.0).await {
Ok(bytes) => (
[(axum::http::header::CONTENT_TYPE, cover.1)],
bytes,
).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
album_cover_by_id(&state, album_id).await
}
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
album_cover_by_id(&state, id).await
}
async fn album_cover_by_id(state: &super::AppState, id: i64) -> axum::response::Response {
// 1. Try album_images table
if let Ok(Some((file_path, mime_type))) = db::get_album_cover(&state.pool, id).await {
if let Ok(bytes) = tokio::fs::read(&file_path).await {
return ([(axum::http::header::CONTENT_TYPE, mime_type)], bytes).into_response();
}
}
// 2. Fallback: extract embedded cover from first track in album
if let Ok(Some(track_path)) = db::get_album_first_track_path(&state.pool, id).await {
let path = std::path::PathBuf::from(track_path);
if path.exists() {
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
if let Ok(Some((bytes, mime))) = result {
return ([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response();
}
}
}
StatusCode::NOT_FOUND.into_response()
}
fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec<u8>, String)> {
use symphonia::core::{
formats::FormatOptions,
io::MediaSourceStream,
meta::MetadataOptions,
probe::Hint,
};
let file = std::fs::File::open(path).ok()?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions { enable_gapless: false, ..Default::default() },
&MetadataOptions::default(),
)
.ok()?;
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
if let Some(v) = rev.visuals().first() {
return Some((v.data.to_vec(), v.media_type.clone()));
}
}
if let Some(rev) = probed.format.metadata().current() {
if let Some(v) = rev.visuals().first() {
return Some((v.data.to_vec(), v.media_type.clone()));
}
}
None
}
#[derive(Deserialize)]

View File

@@ -41,10 +41,12 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/tracks/:id", get(api::get_track).put(api::update_track))
.route("/tracks/:id/hidden", put(api::set_track_hidden))
.route("/albums/search", get(api::search_albums_for_artist))
.route("/albums/cover-by-name", get(api::album_cover_by_name))
.route("/albums/:id/cover", get(api::album_cover))
.route("/albums/:id/full", get(api::get_album_full))
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
.route("/albums/:id/edit", put(api::update_album_full))
.route("/albums/:id/genre", put(api::set_album_tracks_genre))
.route("/albums/:id/hidden", put(api::set_album_hidden))
.route("/albums/:id/release_type", put(api::set_album_release_type))
.route("/albums/:id", put(api::update_album))

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { FurumiPlayer } from './FurumiPlayer'
import './App.css'
type UserProfile = {
@@ -60,74 +61,81 @@ function App() {
const loginUrl = `${apiBase}/api/login`
const logoutUrl = `${apiBase}/api/logout`
const playerApiRoot = `${apiBase}/api`
return (
<main className="page">
<section className="card">
<h1>OIDC Login</h1>
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
<>
{!loading && (user || runWithoutAuth) ? (
<FurumiPlayer apiRoot={playerApiRoot} />
) : (
<main className="page">
<section className="card">
<h1>OIDC Login</h1>
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
<div className="settings">
<label className="toggle">
<input
type="checkbox"
checked={runWithoutAuth}
onChange={(e) => {
const next = e.target.checked
setRunWithoutAuth(next)
try {
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
// ignore
}
setLoading(true)
setUser(null)
}}
/>
<span>Запускать без авторизации</span>
</label>
</div>
<div className="settings">
<label className="toggle">
<input
type="checkbox"
checked={runWithoutAuth}
onChange={(e) => {
const next = e.target.checked
setRunWithoutAuth(next)
try {
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
// ignore
}
setLoading(true)
setUser(null)
}}
/>
<span>Запускать без авторизации</span>
</label>
</div>
{loading && <p>Проверяю сессию...</p>}
{error && <p className="error">Ошибка: {error}</p>}
{loading && <p>Проверяю сессию...</p>}
{error && <p className="error">Ошибка: {error}</p>}
{!loading && runWithoutAuth && (
<p className="hint">
Режим без авторизации включён. Для входа отключи настройку выше.
</p>
)}
{!loading && !user && (
<a className="btn" href={loginUrl}>
Войти через OIDC
</a>
)}
{!loading && user && (
<div className="profile">
<p>
<strong>ID:</strong> {user.sub}
</p>
{user.name && (
<p>
<strong>Имя:</strong> {user.name}
{!loading && runWithoutAuth && (
<p className="hint">
Режим без авторизации включён. Для входа отключи настройку выше.
</p>
)}
{user.email && (
<p>
<strong>Email:</strong> {user.email}
</p>
)}
{!runWithoutAuth && (
<a className="btn ghost" href={logoutUrl}>
Выйти
{!loading && !user && (
<a className="btn" href={loginUrl}>
Войти через OIDC
</a>
)}
</div>
)}
</section>
</main>
{!loading && user && (
<div className="profile">
<p>
<strong>ID:</strong> {user.sub}
</p>
{user.name && (
<p>
<strong>Имя:</strong> {user.name}
</p>
)}
{user.email && (
<p>
<strong>Email:</strong> {user.email}
</p>
)}
{!runWithoutAuth && (
<a className="btn ghost" href={logoutUrl}>
Выйти
</a>
)}
</div>
)}
</section>
</main>
)}
</>
)
}

View File

@@ -0,0 +1,767 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css'
import { createFurumiApiClient } from './furumiApi'
import { SearchDropdown } from './components/SearchDropdown'
import { Breadcrumbs } from './components/Breadcrumbs'
import { LibraryList } from './components/LibraryList'
import { QueueList, type QueueItem } from './components/QueueList'
import { NowPlaying } from './components/NowPlaying'
type FurumiPlayerProps = {
apiRoot: string
}
type Crumb = { label: string; action?: () => void }
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<
Array<{
key: string
className: string
icon: string
name: string
detail?: string
nameClassName?: string
onClick: () => void
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
}>
>([])
const [searchResults, setSearchResults] = useState<
Array<{ result_type: string; slug: string; name: string; detail?: string }>
>([])
const [searchOpen, setSearchOpen] = useState(false)
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
const [nowPlayingTrack, setNowPlayingTrack] = useState<QueueItem | null>(null)
const [queueItemsView, setQueueItemsView] = useState<QueueItem[]>([])
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
const queueActionsRef = useRef<{
playIndex: (i: number) => void
removeFromQueue: (idx: number) => void
moveQueueItem: (fromPos: number, toPos: number) => void
} | null>(null)
useEffect(() => {
// --- Original player script adapted for React environment ---
const audio = document.getElementById('audioEl') as HTMLAudioElement
if (!audio) return
let queue: QueueItem[] = []
let queueIndex = -1
let shuffle = false
let repeatAll = true
let shuffleOrder: number[] = []
let searchTimer: number | null = null
let toastTimer: number | null = null
let muted = false
// Restore prefs
try {
const v = window.localStorage.getItem('furumi_vol')
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
if (v !== null && volSlider) {
audio.volume = Number(v) / 100
volSlider.value = v
}
const btnShuffle = document.getElementById('btnShuffle')
const btnRepeat = document.getElementById('btnRepeat')
shuffle = window.localStorage.getItem('furumi_shuffle') === '1'
repeatAll = window.localStorage.getItem('furumi_repeat') !== '0'
btnShuffle?.classList.toggle('active', shuffle)
btnRepeat?.classList.toggle('active', repeatAll)
} catch {
// ignore
}
// --- Audio events ---
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
const fill = document.getElementById('progressFill')
const timeElapsed = document.getElementById('timeElapsed')
const timeDuration = document.getElementById('timeDuration')
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
}
})
audio.addEventListener('ended', () => nextTrack())
audio.addEventListener('play', () => {
const btn = document.getElementById('btnPlayPause')
if (btn) btn.innerHTML = '&#9208;'
})
audio.addEventListener('pause', () => {
const btn = document.getElementById('btnPlayPause')
if (btn) btn.innerHTML = '&#9654;'
})
audio.addEventListener('error', () => {
showToast('Playback error')
nextTrack()
})
// --- API helper ---
const API = apiRoot
const api = createFurumiApiClient(API)
// --- Library navigation ---
async function showArtists() {
setBreadcrumb([{ label: 'Artists', action: showArtists }])
setLibraryLoading(true)
setLibraryError(null)
const artists = await api('/artists')
if (!artists) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
setLibraryItems(
(artists as any[]).map((a) => ({
key: `artist:${a.slug}`,
className: 'file-item dir',
icon: '👤',
name: a.name,
detail: `${a.album_count} albums`,
onClick: () => void showArtistAlbums(a.slug, a.name),
})),
)
}
async function showArtistAlbums(artistSlug: string, artistName: string) {
setBreadcrumb([
{ label: 'Artists', action: showArtists },
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
])
setLibraryLoading(true)
setLibraryError(null)
const albums = await api('/artists/' + artistSlug + '/albums')
if (!albums) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
const allTracksItem = {
key: `artist-all:${artistSlug}`,
className: 'file-item',
icon: '▶',
name: 'Play all tracks',
nameClassName: 'name',
onClick: () => void playAllArtistTracks(artistSlug),
}
const albumItems = (albums as any[]).map((a) => {
const year = a.year ? ` (${a.year})` : ''
return {
key: `album:${a.slug}`,
className: 'file-item dir',
icon: '💿',
name: `${a.name}${year}`,
detail: `${a.track_count} tracks`,
onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName),
button: {
title: 'Add album to queue',
onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => {
ev.stopPropagation()
void addAlbumToQueue(a.slug)
},
},
}
})
setLibraryItems([allTracksItem, ...albumItems])
}
async function showAlbumTracks(
albumSlug: string,
albumName: string,
artistSlug: string,
artistName: string,
) {
setBreadcrumb([
{ label: 'Artists', action: showArtists },
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
{ label: albumName },
])
setLibraryLoading(true)
setLibraryError(null)
const tracks = await api('/albums/' + albumSlug)
if (!tracks) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
const playAlbumItem = {
key: `album-play:${albumSlug}`,
className: 'file-item',
icon: '▶',
name: 'Play album',
onClick: () => {
void addAlbumToQueue(albumSlug, true)
},
}
const trackItems = (tracks as any[]).map((t) => {
const num = t.track_number ? `${t.track_number}. ` : ''
const dur = t.duration_secs ? fmt(t.duration_secs) : ''
return {
key: `track:${t.slug}`,
className: 'file-item',
icon: '🎵',
name: `${num}${t.title}`,
detail: dur,
onClick: () => {
addTrackToQueue(
{
slug: t.slug,
title: t.title,
artist: t.artist_name,
album_slug: albumSlug,
duration: t.duration_secs,
},
true,
)
},
}
})
setLibraryItems([playAlbumItem, ...trackItems])
}
function setBreadcrumb(parts: Crumb[]) {
setBreadcrumbs(parts)
}
// --- Queue management ---
function addTrackToQueue(
track: {
slug: string
title: string
artist: string
album_slug: string | null
duration: number | null
},
playNow?: boolean,
) {
const existing = queue.findIndex((t) => t.slug === track.slug)
if (existing !== -1) {
if (playNow) playIndex(existing)
return
}
queue.push(track)
updateQueueModel()
if (playNow || (queueIndex === -1 && queue.length === 1)) {
playIndex(queue.length - 1)
}
}
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
const tracks = await api('/albums/' + albumSlug)
if (!tracks || !(tracks as any[]).length) return
const list = tracks as any[]
let firstIdx = queue.length
list.forEach((t) => {
if (queue.find((q) => q.slug === t.slug)) return
queue.push({
slug: t.slug,
title: t.title,
artist: t.artist_name,
album_slug: albumSlug,
duration: t.duration_secs,
})
})
updateQueueModel()
if (playFirst || queueIndex === -1) playIndex(firstIdx)
showToast(`Added ${list.length} tracks`)
}
async function playAllArtistTracks(artistSlug: string) {
const tracks = await api('/artists/' + artistSlug + '/tracks')
if (!tracks || !(tracks as any[]).length) return
const list = tracks as any[]
clearQueue()
list.forEach((t) => {
queue.push({
slug: t.slug,
title: t.title,
artist: t.artist_name,
album_slug: t.album_slug,
duration: t.duration_secs,
})
})
updateQueueModel()
playIndex(0)
showToast(`Added ${list.length} tracks`)
}
function playIndex(i: number) {
if (i < 0 || i >= queue.length) return
queueIndex = i
const track = queue[i]
audio.src = `${API}/stream/${track.slug}`
void audio.play().catch(() => {})
updateNowPlaying(track)
updateQueueModel()
setQueueScrollSignal((s) => s + 1)
if (window.history && window.history.replaceState) {
const url = new URL(window.location.href)
url.searchParams.set('t', track.slug)
window.history.replaceState(null, '', url.toString())
}
}
function updateNowPlaying(track: QueueItem | null) {
setNowPlayingTrack(track)
if (!track) return
document.title = `${track.title} — Furumi`
const coverUrl = `${API}/tracks/${track.slug}/cover`
if ('mediaSession' in navigator) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
navigator.mediaSession.metadata = new window.MediaMetadata({
title: track.title,
artist: track.artist || '',
album: '',
artwork: [{ src: coverUrl, sizes: '512x512' }],
})
}
}
function currentOrder() {
if (!shuffle) return [...Array(queue.length).keys()]
if (shuffleOrder.length !== queue.length) buildShuffleOrder()
return shuffleOrder
}
function buildShuffleOrder() {
shuffleOrder = [...Array(queue.length).keys()]
for (let i = shuffleOrder.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]
}
if (queueIndex !== -1) {
const ci = shuffleOrder.indexOf(queueIndex)
if (ci > 0) {
shuffleOrder.splice(ci, 1)
shuffleOrder.unshift(queueIndex)
}
}
}
function updateQueueModel() {
const order = currentOrder()
setQueueItemsView(queue.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 = {
playIndex,
removeFromQueue,
moveQueueItem,
}
function clearQueue() {
queue = []
queueIndex = -1
shuffleOrder = []
audio.pause()
audio.src = ''
updateNowPlaying(null)
document.title = 'Furumi Player'
updateQueueModel()
}
// --- Playback controls ---
function togglePlay() {
if (!audio.src && queue.length) {
playIndex(queueIndex === -1 ? 0 : queueIndex)
return
}
if (audio.paused) void audio.play()
else audio.pause()
}
function nextTrack() {
if (!queue.length) return
const order = currentOrder()
const pos = order.indexOf(queueIndex)
if (pos < order.length - 1) playIndex(order[pos + 1])
else if (repeatAll) {
if (shuffle) buildShuffleOrder()
playIndex(currentOrder()[0])
}
}
function prevTrack() {
if (!queue.length) return
if (audio.currentTime > 3) {
audio.currentTime = 0
return
}
const order = currentOrder()
const pos = order.indexOf(queueIndex)
if (pos > 0) playIndex(order[pos - 1])
else if (repeatAll) playIndex(order[order.length - 1])
}
function toggleShuffle() {
shuffle = !shuffle
if (shuffle) buildShuffleOrder()
const btn = document.getElementById('btnShuffle')
btn?.classList.toggle('active', shuffle)
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
updateQueueModel()
}
function toggleRepeat() {
repeatAll = !repeatAll
const btn = document.getElementById('btnRepeat')
btn?.classList.toggle('active', repeatAll)
window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0')
}
// --- Seek & Volume ---
function seekTo(e: MouseEvent) {
if (!audio.duration) return
const bar = document.getElementById('progressBar') as HTMLDivElement | null
if (!bar) return
const rect = bar.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
audio.currentTime = pct * audio.duration
}
function toggleMute() {
muted = !muted
audio.muted = muted
const volIcon = document.getElementById('volIcon')
if (volIcon) volIcon.innerHTML = muted ? '&#128263;' : '&#128266;'
}
function setVolume(v: number) {
audio.volume = v / 100
const volIcon = document.getElementById('volIcon')
if (volIcon) volIcon.innerHTML = v === 0 ? '&#128263;' : '&#128266;'
window.localStorage.setItem('furumi_vol', String(v))
}
// --- Search ---
function onSearch(q: string) {
if (searchTimer) {
window.clearTimeout(searchTimer)
}
if (q.length < 2) {
closeSearch()
return
}
searchTimer = window.setTimeout(async () => {
const results = await api('/search?q=' + encodeURIComponent(q))
if (!results || !(results as any[]).length) {
closeSearch()
return
}
setSearchResults(results as any[])
setSearchOpen(true)
}, 250)
}
function closeSearch() {
setSearchOpen(false)
setSearchResults([])
}
function onSearchSelect(type: string, slug: string) {
closeSearch()
if (type === 'artist') void showArtistAlbums(slug, '')
else if (type === 'album') void addAlbumToQueue(slug, true)
else if (type === 'track') {
addTrackToQueue(
{ slug, title: '', artist: '', album_slug: null, duration: null },
true,
)
void api('/stream/' + slug).catch(() => null)
}
}
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() {
const sidebar = document.getElementById('sidebar')
const overlay = document.getElementById('sidebarOverlay')
sidebar?.classList.toggle('open')
overlay?.classList.toggle('show')
}
// --- MediaSession ---
if ('mediaSession' in navigator) {
try {
navigator.mediaSession.setActionHandler('play', togglePlay)
navigator.mediaSession.setActionHandler('pause', togglePlay)
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
navigator.mediaSession.setActionHandler('seekto', (d: any) => {
if (typeof d.seekTime === 'number') {
audio.currentTime = d.seekTime
}
})
} catch {
// ignore
}
}
// --- Wire DOM events that were inline in HTML ---
const btnMenu = document.querySelector('.btn-menu')
btnMenu?.addEventListener('click', () => toggleSidebar())
const sidebarOverlay = document.getElementById('sidebarOverlay')
sidebarOverlay?.addEventListener('click', () => toggleSidebar())
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
if (searchInput) {
searchInput.addEventListener('input', (e) => {
onSearch((e.target as HTMLInputElement).value)
})
searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') closeSearch()
})
}
const btnShuffle = document.getElementById('btnShuffle')
btnShuffle?.addEventListener('click', () => toggleShuffle())
const btnRepeat = document.getElementById('btnRepeat')
btnRepeat?.addEventListener('click', () => toggleRepeat())
const btnClear = document.getElementById('btnClearQueue')
btnClear?.addEventListener('click', () => clearQueue())
const btnPrev = document.getElementById('btnPrev')
btnPrev?.addEventListener('click', () => prevTrack())
const btnPlay = document.getElementById('btnPlayPause')
btnPlay?.addEventListener('click', () => togglePlay())
const btnNext = document.getElementById('btnNext')
btnNext?.addEventListener('click', () => nextTrack())
const progressBar = document.getElementById('progressBar')
progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent))
const volIcon = document.getElementById('volIcon')
volIcon?.addEventListener('click', () => toggleMute())
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
if (volSlider) {
volSlider.addEventListener('input', (e) => {
const v = Number((e.target as HTMLInputElement).value)
setVolume(v)
})
}
const clearQueueBtn = document.getElementById('btnClearQueue')
clearQueueBtn?.addEventListener('click', () => clearQueue())
// --- Init ---
;(async () => {
const url = new URL(window.location.href)
const urlSlug = url.searchParams.get('t')
if (urlSlug) {
const info = await api('/tracks/' + urlSlug)
if (info) {
addTrackToQueue(
{
slug: (info as any).slug,
title: (info as any).title,
artist: (info as any).artist_name,
album_slug: (info as any).album_slug,
duration: (info as any).duration_secs,
},
true,
)
}
}
void showArtists()
})()
// Cleanup: best-effort remove listeners on unmount
return () => {
queueActionsRef.current = null
audio.pause()
}
}, [apiRoot])
return (
<div className="furumi-root">
<header className="header">
<div className="header-logo">
<button className="btn-menu">&#9776;</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={(type, slug) => searchSelectRef.current(type, slug)}
/>
</div>
</div>
</header>
<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
apiRoot={apiRoot}
queue={queueItemsView}
order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal}
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
onRemove={(origIdx) =>
queueActionsRef.current?.removeFromQueue(origIdx)
}
onMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
}
/>
</div>
</section>
</div>
<div className="player-bar">
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</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">
&#128266;
</span>
<input
type="range"
className="volume-slider"
id="volSlider"
min={0}
max={100}
defaultValue={80}
/>
</div>
</div>
<div className="toast" id="toast" />
<audio id="audioEl" />
</div>
)
}

View File

@@ -0,0 +1,30 @@
type Crumb = {
label: string
action?: () => void
}
type BreadcrumbsProps = {
items: Crumb[]
}
export function Breadcrumbs({ items }: BreadcrumbsProps) {
if (!items.length) return null
return (
<div className="breadcrumb">
{items.map((item, index) => {
const isLast = index === items.length - 1
return (
<span key={`${item.label}-${index}`}>
{!isLast && item.action ? (
<span onClick={item.action}>{item.label}</span>
) : (
<span>{item.label}</span>
)}
{!isLast ? ' / ' : ''}
</span>
)
})}
</div>
)
}

View File

@@ -0,0 +1,54 @@
import type { MouseEvent } from 'react'
type LibraryListButton = {
title: string
onClick: (ev: MouseEvent<HTMLButtonElement>) => void
}
type LibraryListItem = {
key: string
className: string
icon: string
name: string
detail?: string
nameClassName?: string
onClick: () => void
button?: LibraryListButton
}
type LibraryListProps = {
loading: boolean
error: string | null
items: LibraryListItem[]
}
export function LibraryList({ loading, error, items }: LibraryListProps) {
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</div>
)
}
if (error) {
return <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>
}
return (
<>
{items.map((item) => (
<div key={item.key} className={item.className} onClick={item.onClick}>
<span className="icon">{item.icon}</span>
<span className={item.nameClassName ?? 'name'}>{item.name}</span>
{item.detail ? <span className="detail">{item.detail}</span> : null}
{item.button ? (
<button className="add-btn" title={item.button.title} onClick={item.button.onClick}>
&#10133;
</button>
) : null}
</div>
))}
</>
)
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import type { QueueItem } from './QueueList'
function Cover({ src }: { src: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
}
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
if (!track) {
return (
<div className="np-info">
<div className="np-cover" id="npCover">
&#127925;
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
Nothing playing
</div>
<div className="np-artist" id="npArtist">
&mdash;
</div>
</div>
</div>
)
}
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
return (
<div className="np-info">
<div className="np-cover" id="npCover">
<Cover src={coverUrl} />
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
{track.title}
</div>
<div className="np-artist" id="npArtist">
{track.artist || '—'}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react'
export type QueueItem = {
slug: string
title: string
artist: string
album_slug: string | null
duration: number | null
}
type QueueListProps = {
apiRoot: string
queue: QueueItem[]
order: number[]
playingOrigIdx: number
scrollSignal: number
onPlay: (origIdx: number) => void
onRemove: (origIdx: number) => void
onMove: (fromPos: number, toPos: number) => void
}
function pad(n: number) {
return String(n).padStart(2, '0')
}
function fmt(secs: number) {
if (!secs || Number.isNaN(secs)) return '0:00'
const s = Math.floor(secs)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h > 0) return `${h}:${pad(m % 60)}:${pad(s % 60)}`
return `${m}:${pad(s % 60)}`
}
function Cover({ src }: { src: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
}
export function QueueList({
apiRoot,
queue,
order,
playingOrigIdx,
scrollSignal,
onPlay,
onRemove,
onMove,
}: QueueListProps) {
const playingRef = useRef<HTMLDivElement | null>(null)
const [draggingPos, setDraggingPos] = useState<number | null>(null)
const [dragOverPos, setDragOverPos] = useState<number | null>(null)
useEffect(() => {
if (playingRef.current) {
playingRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, [playingOrigIdx, scrollSignal])
if (!queue.length) {
return (
<div className="queue-empty">
<div className="empty-icon">&#127925;</div>
<div>Select an album to start</div>
</div>
)
}
return (
<>
{order.map((origIdx, pos) => {
const t = queue[origIdx]
if (!t) return null
const isPlaying = origIdx === playingOrigIdx
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
const dur = t.duration ? fmt(t.duration) : ''
const isDragging = draggingPos === pos
const isDragOver = dragOverPos === pos
return (
<div
key={`${t.slug}:${pos}`}
ref={isPlaying ? playingRef : null}
className={`queue-item${isPlaying ? ' playing' : ''}${isDragging ? ' dragging' : ''}${
isDragOver ? ' drag-over' : ''
}`}
draggable
onClick={() => onPlay(origIdx)}
onDragStart={(e) => {
setDraggingPos(pos)
e.dataTransfer?.setData('text/plain', String(pos))
}}
onDragEnd={() => {
setDraggingPos(null)
setDragOverPos(null)
}}
onDragOver={(e) => {
e.preventDefault()
}}
onDragEnter={() => {
setDragOverPos(pos)
}}
onDragLeave={() => {
setDragOverPos((cur) => (cur === pos ? null : cur))
}}
onDrop={(e) => {
e.preventDefault()
setDragOverPos(null)
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
if (!Number.isNaN(from)) onMove(from, pos)
setDraggingPos(null)
}}
>
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
<div className="qi-cover">
{coverSrc ? <Cover src={coverSrc} /> : <>&#127925;</>}
</div>
<div className="qi-info">
<div className="qi-title">{t.title}</div>
<div className="qi-artist">{t.artist || ''}</div>
</div>
<span className="qi-dur">{dur}</span>
<button
className="qi-remove"
onClick={(e) => {
e.stopPropagation()
onRemove(origIdx)
}}
>
&#10005;
</button>
</div>
)
})}
</>
)
}

View File

@@ -0,0 +1,30 @@
type SearchResultItem = {
result_type: string
slug: string
name: string
detail?: string
}
type SearchDropdownProps = {
isOpen: boolean
results: SearchResultItem[]
onSelect: (type: string, slug: string) => void
}
export function SearchDropdown({ isOpen, results, onSelect }: SearchDropdownProps) {
return (
<div className={`search-dropdown${isOpen ? ' open' : ''}`}>
{results.map((r) => (
<div
key={`${r.result_type}:${r.slug}`}
className="search-result"
onClick={() => onSelect(r.result_type, r.slug)}
>
<span className="sr-type">{r.result_type}</span>
{r.name}
{r.detail ? <span className="sr-detail">{r.detail}</span> : null}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,754 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
.furumi-root,
.furumi-root * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.furumi-root {
height: 100%;
display: flex;
flex-direction: column;
font-family: 'Inter', system-ui, sans-serif;
}
:root {
--bg-base: #0a0c12;
--bg-panel: #111520;
--bg-card: #161d2e;
--bg-hover: #1e2740;
--bg-active: #252f4a;
--border: #1f2c45;
--accent: #7c6af7;
--accent-dim: #5a4fcf;
--accent-glow: rgba(124, 106, 247, 0.3);
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--success: #34d399;
--danger: #f87171;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 10;
}
.header-logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 1.1rem;
}
.header-logo svg {
width: 22px;
height: 22px;
}
.header-version {
font-size: 0.7rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.25rem;
font-weight: 500;
text-decoration: none;
}
.btn-menu {
display: none;
background: none;
border: none;
color: var(--text);
font-size: 1.2rem;
cursor: pointer;
padding: 0.1rem 0.5rem;
margin-right: 0.2rem;
border-radius: 4px;
}
.search-wrap {
position: relative;
}
.search-wrap input {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px 6px 30px;
color: var(--text);
font-size: 13px;
width: 220px;
font-family: inherit;
}
.search-wrap::before {
content: '🔍';
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 6px 6px;
max-height: 300px;
overflow-y: auto;
z-index: 50;
display: none;
}
.search-dropdown.open {
display: block;
}
.search-result {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
border-bottom: 1px solid var(--border);
}
.search-result:hover {
background: var(--bg-hover);
}
.search-result .sr-type {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
margin-right: 6px;
}
.search-result .sr-detail {
font-size: 11px;
color: var(--text-muted);
margin-left: 4px;
}
.main {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
background: var(--bg-base);
color: var(--text);
}
.sidebar-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 20;
}
.sidebar-overlay.show {
display: block;
}
.sidebar {
width: 280px;
min-width: 200px;
max-width: 400px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border-right: 1px solid var(--border);
overflow: hidden;
resize: horizontal;
}
.sidebar-header {
padding: 0.85rem 1rem 0.6rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.breadcrumb {
padding: 0.5rem 1rem;
font-size: 0.78rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.breadcrumb span {
color: var(--accent);
cursor: pointer;
}
.breadcrumb span:hover {
text-decoration: underline;
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 0.3rem 0;
}
.file-list::-webkit-scrollbar {
width: 4px;
}
.file-list::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.file-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 1rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-dim);
user-select: none;
transition: background 0.12s;
}
.file-item:hover {
background: var(--bg-hover);
color: var(--text);
}
.file-item.dir {
color: var(--accent);
}
.file-item .icon {
font-size: 0.95rem;
flex-shrink: 0;
opacity: 0.8;
}
.file-item .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-item .detail {
font-size: 0.7rem;
color: var(--text-muted);
flex-shrink: 0;
}
.file-item .add-btn {
opacity: 0;
font-size: 0.75rem;
background: var(--bg-hover);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.2rem 0.4rem;
cursor: pointer;
flex-shrink: 0;
}
.file-item:hover .add-btn {
opacity: 1;
}
.file-item .add-btn:hover {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.queue-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-base);
}
.queue-header {
padding: 0.85rem 1.25rem 0.6rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.queue-actions {
display: flex;
gap: 0.5rem;
}
.queue-btn {
font-size: 0.7rem;
padding: 0.2rem 0.55rem;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
}
.queue-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.queue-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.queue-list {
flex: 1;
overflow-y: auto;
padding: 0.3rem 0;
}
.queue-list::-webkit-scrollbar {
width: 4px;
}
.queue-list::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.queue-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1.25rem;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.12s;
}
.queue-item:hover {
background: var(--bg-hover);
}
.queue-item.playing {
background: var(--bg-active);
border-left-color: var(--accent);
}
.queue-item.playing .qi-title {
color: var(--accent);
}
.queue-item .qi-index {
font-size: 0.75rem;
color: var(--text-muted);
width: 1.5rem;
text-align: right;
flex-shrink: 0;
}
.queue-item.playing .qi-index::before {
content: '▶';
font-size: 0.6rem;
color: var(--accent);
}
.queue-item .qi-cover {
width: 36px;
height: 36px;
border-radius: 5px;
background: var(--bg-card);
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.queue-item .qi-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.queue-item .qi-info {
flex: 1;
overflow: hidden;
}
.queue-item .qi-title {
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-item .qi-artist {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-item .qi-dur {
font-size: 0.75rem;
color: var(--text-muted);
margin-left: auto;
margin-right: 0.5rem;
}
.qi-remove {
background: none;
border: none;
font-size: 0.9rem;
color: var(--text-muted);
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
opacity: 0;
}
.queue-item:hover .qi-remove {
opacity: 1;
}
.qi-remove:hover {
background: rgba(248, 113, 113, 0.15);
color: var(--danger);
}
.queue-item.dragging {
opacity: 0.5;
}
.queue-item.drag-over {
border-top: 2px solid var(--accent);
margin-top: -2px;
}
.queue-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.875rem;
gap: 0.5rem;
padding: 2rem;
}
.queue-empty .empty-icon {
font-size: 2.5rem;
opacity: 0.3;
}
.player-bar {
background: var(--bg-panel);
border-top: 1px solid var(--border);
padding: 0.9rem 1.5rem;
flex-shrink: 0;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
gap: 1rem;
}
.np-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.np-cover {
width: 44px;
height: 44px;
border-radius: 6px;
background: var(--bg-card);
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.np-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.np-text {
min-width: 0;
}
.np-title {
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.np-artist {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.ctrl-btns {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ctrl-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0.35rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.ctrl-btn:hover {
color: var(--text);
background: var(--bg-hover);
}
.ctrl-btn.active {
color: var(--accent);
}
.ctrl-btn-main {
width: 38px;
height: 38px;
background: var(--accent);
color: #fff !important;
font-size: 1.1rem;
box-shadow: 0 0 14px var(--accent-glow);
}
.ctrl-btn-main:hover {
background: var(--accent-dim) !important;
}
.progress-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
}
.time {
font-size: 0.7rem;
color: var(--text-muted);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
min-width: 2.5rem;
text-align: center;
}
.progress-bar {
flex: 1;
height: 4px;
background: var(--bg-hover);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
pointer-events: none;
}
.progress-fill::after {
content: '';
position: absolute;
right: -5px;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
opacity: 0;
transition: opacity 0.15s;
}
.progress-bar:hover .progress-fill::after {
opacity: 1;
}
.volume-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.vol-icon {
font-size: 0.9rem;
color: var(--text-muted);
cursor: pointer;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
border-radius: 2px;
background: var(--bg-hover);
cursor: pointer;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.toast {
position: fixed;
bottom: 90px;
right: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 1rem;
font-size: 0.8rem;
color: var(--text-dim);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
opacity: 0;
transform: translateY(8px);
transition: all 0.25s;
pointer-events: none;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.btn-menu {
display: inline-block;
}
.header {
padding: 0.75rem 1rem;
}
.sidebar {
position: absolute;
top: 0;
bottom: 0;
left: -100%;
width: 85%;
max-width: 320px;
z-index: 30;
transition: left 0.3s;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.6);
}
.sidebar.open {
left: 0;
}
.player-bar {
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.volume-row {
display: none;
}
.search-wrap input {
width: 140px;
}
}

View File

@@ -0,0 +1,12 @@
export type FurumiApiClient = (path: string) => Promise<unknown | null>
export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
const API = apiRoot
return async function api(path: string) {
const r = await fetch(API + path)
if (!r.ok) return null
return r.json()
}
}