Compare commits
13 Commits
71d88bacf2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e85ed32b7b | |||
| 71d5a38f21 | |||
| e34440498c | |||
| 8d70a5133a | |||
| f873542d02 | |||
| 56760be586 | |||
| 108c374c6d | |||
| 2129dc8007 | |||
| 3f2013e9d5 | |||
| cc3ef04cbe | |||
| 7ede23ff94 | |||
| a730ab568c | |||
|
|
c30a3aff5d |
@@ -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: |
|
||||
|
||||
2
.github/workflows/docker-publish-agent.yml
vendored
2
.github/workflows/docker-publish-agent.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.agent
|
||||
file: docker/Dockerfile.agent
|
||||
push: true
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
build-args: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
2
.github/workflows/docker-publish-player.yml
vendored
2
.github/workflows/docker-publish-player.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web-player
|
||||
file: docker/Dockerfile.web-player
|
||||
push: true
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
build-args: |
|
||||
|
||||
@@ -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: |
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
@@ -35,18 +37,24 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
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: Array<{
|
||||
slug: string
|
||||
title: string
|
||||
artist: string
|
||||
album_slug: string | null
|
||||
duration: number | null
|
||||
}> = []
|
||||
let queue: QueueItem[] = []
|
||||
let queueIndex = -1
|
||||
let shuffle = false
|
||||
let repeatAll = true
|
||||
@@ -245,7 +253,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
return
|
||||
}
|
||||
queue.push(track)
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||
playIndex(queue.length - 1)
|
||||
}
|
||||
@@ -266,7 +274,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
duration: t.duration_secs,
|
||||
})
|
||||
})
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||
showToast(`Added ${list.length} tracks`)
|
||||
}
|
||||
@@ -285,7 +293,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
duration: t.duration_secs,
|
||||
})
|
||||
})
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
playIndex(0)
|
||||
showToast(`Added ${list.length} tracks`)
|
||||
}
|
||||
@@ -297,8 +305,8 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
audio.src = `${API}/stream/${track.slug}`
|
||||
void audio.play().catch(() => {})
|
||||
updateNowPlaying(track)
|
||||
renderQueue()
|
||||
scrollQueueToActive()
|
||||
updateQueueModel()
|
||||
setQueueScrollSignal((s) => s + 1)
|
||||
if (window.history && window.history.replaceState) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('t', track.slug)
|
||||
@@ -306,24 +314,13 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNowPlaying(track: (typeof queue)[number] | null) {
|
||||
const npTitle = document.getElementById('npTitle')
|
||||
const npArtist = document.getElementById('npArtist')
|
||||
if (!track) {
|
||||
if (npTitle) npTitle.textContent = 'Nothing playing'
|
||||
if (npArtist) npArtist.textContent = '—'
|
||||
return
|
||||
}
|
||||
if (npTitle) npTitle.textContent = track.title
|
||||
if (npArtist) npArtist.textContent = track.artist || '—'
|
||||
function updateNowPlaying(track: QueueItem | null) {
|
||||
setNowPlayingTrack(track)
|
||||
if (!track) return
|
||||
|
||||
document.title = `${track.title} — Furumi`
|
||||
|
||||
const cover = document.getElementById('npCover')
|
||||
const coverUrl = `${API}/tracks/${track.slug}/cover`
|
||||
if (cover) {
|
||||
cover.innerHTML = `<img src="${coverUrl}" alt="" onerror="this.parentElement.innerHTML='🎵'">`
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||
@@ -356,77 +353,11 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
const el = document.getElementById('queueList')
|
||||
if (!el) return
|
||||
if (!queue.length) {
|
||||
el.innerHTML =
|
||||
'<div class="queue-empty"><div class="empty-icon">🎵</div><div>Select an album to start</div></div>'
|
||||
return
|
||||
}
|
||||
function updateQueueModel() {
|
||||
const order = currentOrder()
|
||||
el.innerHTML = ''
|
||||
order.forEach((origIdx, pos) => {
|
||||
const t = queue[origIdx]
|
||||
const isPlaying = origIdx === queueIndex
|
||||
const div = document.createElement('div')
|
||||
div.className = 'queue-item' + (isPlaying ? ' playing' : '')
|
||||
|
||||
const coverSrc = t.album_slug ? `${API}/tracks/${t.slug}/cover` : ''
|
||||
const coverHtml = coverSrc
|
||||
? `<img src="${coverSrc}" alt="" onerror="this.parentElement.innerHTML='🎵'">`
|
||||
: '🎵'
|
||||
const dur = t.duration ? fmt(t.duration) : ''
|
||||
|
||||
div.innerHTML = `
|
||||
<span class="qi-index">${isPlaying ? '' : pos + 1}</span>
|
||||
<div class="qi-cover">${coverHtml}</div>
|
||||
<div class="qi-info"><div class="qi-title">${esc(
|
||||
t.title,
|
||||
)}</div><div class="qi-artist">${esc(t.artist || '')}</div></div>
|
||||
<span class="qi-dur">${dur}</span>
|
||||
<button class="qi-remove">✕</button>
|
||||
`
|
||||
div.addEventListener('click', () => playIndex(origIdx))
|
||||
|
||||
const removeBtn = div.querySelector('.qi-remove') as HTMLButtonElement | null
|
||||
if (removeBtn) {
|
||||
removeBtn.onclick = (ev) => {
|
||||
ev.stopPropagation()
|
||||
removeFromQueue(origIdx)
|
||||
}
|
||||
}
|
||||
|
||||
div.draggable = true
|
||||
div.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer?.setData('text/plain', String(pos))
|
||||
div.classList.add('dragging')
|
||||
})
|
||||
div.addEventListener('dragend', () => {
|
||||
div.classList.remove('dragging')
|
||||
el
|
||||
.querySelectorAll('.queue-item')
|
||||
.forEach((x) => x.classList.remove('drag-over'))
|
||||
})
|
||||
div.addEventListener('dragover', (e) => {
|
||||
e.preventDefault()
|
||||
})
|
||||
div.addEventListener('dragenter', () => div.classList.add('drag-over'))
|
||||
div.addEventListener('dragleave', () => div.classList.remove('drag-over'))
|
||||
div.addEventListener('drop', (e) => {
|
||||
e.preventDefault()
|
||||
div.classList.remove('drag-over')
|
||||
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
|
||||
if (!Number.isNaN(from)) moveQueueItem(from, pos)
|
||||
})
|
||||
|
||||
el.appendChild(div)
|
||||
})
|
||||
}
|
||||
|
||||
function scrollQueueToActive() {
|
||||
const el = document.querySelector('.queue-item.playing') as HTMLElement | null
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
setQueueItemsView(queue.slice())
|
||||
setQueueOrderView(order.slice())
|
||||
setQueuePlayingOrigIdxView(queueIndex)
|
||||
}
|
||||
|
||||
function removeFromQueue(idx: number) {
|
||||
@@ -446,7 +377,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
if (shuffleOrder[i] > idx) shuffleOrder[i]--
|
||||
}
|
||||
}
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
function moveQueueItem(from: number, to: number) {
|
||||
@@ -461,7 +392,13 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
else if (from < queueIndex && to >= queueIndex) queueIndex--
|
||||
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
||||
}
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
queueActionsRef.current = {
|
||||
playIndex,
|
||||
removeFromQueue,
|
||||
moveQueueItem,
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
@@ -472,7 +409,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
audio.src = ''
|
||||
updateNowPlaying(null)
|
||||
document.title = 'Furumi Player'
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
// --- Playback controls ---
|
||||
@@ -514,7 +451,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
const btn = document.getElementById('btnShuffle')
|
||||
btn?.classList.toggle('active', shuffle)
|
||||
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
@@ -603,15 +540,6 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function esc(s: string | null | undefined) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function showToast(msg: string) {
|
||||
const t = document.getElementById('toast')
|
||||
if (!t) return
|
||||
@@ -716,6 +644,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
|
||||
// Cleanup: best-effort remove listeners on unmount
|
||||
return () => {
|
||||
queueActionsRef.current = null
|
||||
audio.pause()
|
||||
}
|
||||
}, [apiRoot])
|
||||
@@ -771,28 +700,26 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="queue-list" id="queueList">
|
||||
<div className="queue-empty">
|
||||
<div className="empty-icon">🎵</div>
|
||||
<div>Select an album to start</div>
|
||||
</div>
|
||||
<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">
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
🎵
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
Nothing playing
|
||||
</div>
|
||||
<div className="np-artist" id="npArtist">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
|
||||
<div className="controls">
|
||||
<div className="ctrl-btns">
|
||||
<button className="ctrl-btn" id="btnPrev">
|
||||
|
||||
52
furumi-node-player/client/src/components/NowPlaying.tsx
Normal file
52
furumi-node-player/client/src/components/NowPlaying.tsx
Normal 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 <>🎵</>
|
||||
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">
|
||||
🎵
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
Nothing playing
|
||||
</div>
|
||||
<div className="np-artist" id="npArtist">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const coverUrl = `${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>
|
||||
)
|
||||
}
|
||||
|
||||
144
furumi-node-player/client/src/components/QueueList.tsx
Normal file
144
furumi-node-player/client/src/components/QueueList.tsx
Normal 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 <>🎵</>
|
||||
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">🎵</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} /> : <>🎵</>}
|
||||
</div>
|
||||
<div className="qi-info">
|
||||
<div className="qi-title">{t.title}</div>
|
||||
<div className="qi-artist">{t.artist || ''}</div>
|
||||
</div>
|
||||
<span className="qi-dur">{dur}</span>
|
||||
<button
|
||||
className="qi-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(origIdx)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user