Compare commits
2 Commits
cc3ef04cbe
...
feature/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c30a3aff5d | ||
|
|
71d88bacf2 |
@@ -334,31 +334,18 @@ pub async fn approve_and_finalize(
|
|||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Check if track already exists by file_hash (re-approval of same file)
|
// Check if track already exists (e.g. previously approved but pending not cleaned up)
|
||||||
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
||||||
.bind(&pt.file_hash)
|
.bind(&pt.file_hash)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some((track_id,)) = existing {
|
if let Some((track_id,)) = existing {
|
||||||
|
// Already finalized — just mark pending as approved
|
||||||
update_pending_status(pool, pending_id, "approved", None).await?;
|
update_pending_status(pool, pending_id, "approved", None).await?;
|
||||||
return Ok(track_id);
|
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_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
||||||
let artist_id = upsert_artist(pool, artist_name).await?;
|
let artist_id = upsert_artist(pool, artist_name).await?;
|
||||||
|
|
||||||
@@ -850,12 +837,6 @@ pub struct AlbumTrackRow {
|
|||||||
pub genre: Option<String>,
|
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> {
|
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(
|
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"
|
"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"
|
||||||
@@ -892,14 +873,6 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result<Option<(Str
|
|||||||
Ok(row)
|
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> {
|
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")
|
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
|
||||||
.bind(id).fetch_optional(pool).await
|
.bind(id).fetch_optional(pool).await
|
||||||
|
|||||||
@@ -295,14 +295,13 @@ function renderFilterBar(s) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(tab, btn, noHash) {
|
function showTab(tab, btn) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
const pag = document.getElementById('lib-pagination');
|
const pag = document.getElementById('lib-pagination');
|
||||||
if (pag) pag.style.display = 'none';
|
if (pag) pag.style.display = 'none';
|
||||||
if (!noHash) location.hash = tab;
|
|
||||||
if (tab === 'queue') { loadQueue(); loadStats(); }
|
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 === '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(); }
|
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(); }
|
||||||
@@ -313,11 +312,7 @@ function showTab(tab, btn, noHash) {
|
|||||||
// --- Queue ---
|
// --- Queue ---
|
||||||
async function loadQueue(status, keepSelection) {
|
async function loadQueue(status, keepSelection) {
|
||||||
currentFilter = status;
|
currentFilter = status;
|
||||||
if (!keepSelection) {
|
if (!keepSelection) { clearSelection(); queueOffset = 0; }
|
||||||
clearSelection();
|
|
||||||
queueOffset = 0;
|
|
||||||
location.hash = status ? 'queue/' + status : 'queue';
|
|
||||||
}
|
|
||||||
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
|
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
|
||||||
const raw = await api(`/queue${qs}`) || [];
|
const raw = await api(`/queue${qs}`) || [];
|
||||||
const hasMore = raw.length > queuePageSize;
|
const hasMore = raw.length > queuePageSize;
|
||||||
@@ -364,9 +359,6 @@ function renderQueue(hasMore) {
|
|||||||
const artist = it.norm_artist || it.raw_artist || '-';
|
const artist = it.norm_artist || it.raw_artist || '-';
|
||||||
const title = it.norm_title || it.raw_title || '-';
|
const title = it.norm_title || it.raw_title || '-';
|
||||||
const album = it.norm_album || it.raw_album || '-';
|
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 year = it.norm_year || it.raw_year || '';
|
||||||
const tnum = it.norm_track_number || it.raw_track_number || '';
|
const tnum = it.norm_track_number || it.raw_track_number || '';
|
||||||
const canApprove = it.status === 'review';
|
const canApprove = it.status === 'review';
|
||||||
@@ -376,7 +368,7 @@ function renderQueue(hasMore) {
|
|||||||
<td><span class="status status-${it.status}">${it.status}</span></td>
|
<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_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_title')" title="${esc(it.raw_title||'')}">${esc(title)}</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 class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td>
|
||||||
<td>${year}</td>
|
<td>${year}</td>
|
||||||
<td>${tnum}</td>
|
<td>${tnum}</td>
|
||||||
<td>${conf}</td>
|
<td>${conf}</td>
|
||||||
@@ -1171,37 +1163,6 @@ function openTrackEditForArtist(trackId, artistId) {
|
|||||||
openTrackEdit(trackId, () => openArtistForm(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 ---
|
// --- Helpers ---
|
||||||
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
||||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
||||||
@@ -1534,20 +1495,7 @@ async function openArtistForm(id) {
|
|||||||
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
|
<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>
|
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
const albumMeta = `<div style="padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg-panel)">
|
body.innerHTML = tracks;
|
||||||
<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');
|
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1638,82 +1586,9 @@ async function removeAppearance(artistId, trackId, btn) {
|
|||||||
btn.closest('.appearance-row').remove();
|
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 ---
|
// --- 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();
|
loadStats();
|
||||||
if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
|
loadQueue();
|
||||||
setInterval(loadStats, 5000);
|
setInterval(loadStats, 5000);
|
||||||
// Auto-refresh queue when on queue tab
|
// Auto-refresh queue when on queue tab
|
||||||
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
||||||
|
|||||||
@@ -528,20 +528,6 @@ 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)]
|
#[derive(Deserialize)]
|
||||||
pub struct ReorderBody {
|
pub struct ReorderBody {
|
||||||
pub orders: Vec<(i64, i32)>,
|
pub orders: Vec<(i64, i32)>,
|
||||||
@@ -558,82 +544,19 @@ pub async fn reorder_album_tracks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(),
|
|
||||||
};
|
|
||||||
album_cover_by_id(&state, album_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
||||||
album_cover_by_id(&state, id).await
|
let cover = match db::get_album_cover(&state.pool, id).await {
|
||||||
}
|
Ok(Some(c)) => c,
|
||||||
|
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||||
async fn album_cover_by_id(state: &super::AppState, id: i64) -> axum::response::Response {
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
// 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,
|
|
||||||
};
|
};
|
||||||
|
match tokio::fs::read(&cover.0).await {
|
||||||
let file = std::fs::File::open(path).ok()?;
|
Ok(bytes) => (
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
[(axum::http::header::CONTENT_TYPE, cover.1)],
|
||||||
|
bytes,
|
||||||
let mut hint = Hint::new();
|
).into_response(),
|
||||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
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)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -41,12 +41,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
||||||
.route("/tracks/:id/hidden", put(api::set_track_hidden))
|
.route("/tracks/:id/hidden", put(api::set_track_hidden))
|
||||||
.route("/albums/search", get(api::search_albums_for_artist))
|
.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/cover", get(api::album_cover))
|
||||||
.route("/albums/:id/full", get(api::get_album_full))
|
.route("/albums/:id/full", get(api::get_album_full))
|
||||||
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
||||||
.route("/albums/:id/edit", put(api::update_album_full))
|
.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/hidden", put(api::set_album_hidden))
|
||||||
.route("/albums/:id/release_type", put(api::set_album_release_type))
|
.route("/albums/:id/release_type", put(api::set_album_release_type))
|
||||||
.route("/albums/:id", put(api::update_album))
|
.route("/albums/:id", put(api::update_album))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { FurumiPlayer } from './FurumiPlayer'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
@@ -60,74 +61,81 @@ function App() {
|
|||||||
|
|
||||||
const loginUrl = `${apiBase}/api/login`
|
const loginUrl = `${apiBase}/api/login`
|
||||||
const logoutUrl = `${apiBase}/api/logout`
|
const logoutUrl = `${apiBase}/api/logout`
|
||||||
|
const playerApiRoot = `${apiBase}/api`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<>
|
||||||
<section className="card">
|
{!loading && (user || runWithoutAuth) ? (
|
||||||
<h1>OIDC Login</h1>
|
<FurumiPlayer apiRoot={playerApiRoot} />
|
||||||
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
) : (
|
||||||
|
<main className="page">
|
||||||
|
<section className="card">
|
||||||
|
<h1>OIDC Login</h1>
|
||||||
|
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
||||||
|
|
||||||
<div className="settings">
|
<div className="settings">
|
||||||
<label className="toggle">
|
<label className="toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={runWithoutAuth}
|
checked={runWithoutAuth}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.checked
|
const next = e.target.checked
|
||||||
setRunWithoutAuth(next)
|
setRunWithoutAuth(next)
|
||||||
try {
|
try {
|
||||||
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
|
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
|
||||||
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
|
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>Запускать без авторизации</span>
|
<span>Запускать без авторизации</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p>Проверяю сессию...</p>}
|
{loading && <p>Проверяю сессию...</p>}
|
||||||
{error && <p className="error">Ошибка: {error}</p>}
|
{error && <p className="error">Ошибка: {error}</p>}
|
||||||
|
|
||||||
{!loading && runWithoutAuth && (
|
{!loading && runWithoutAuth && (
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
Режим без авторизации включён. Для входа отключи настройку выше.
|
Режим без авторизации включён. Для входа отключи настройку выше.
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !user && (
|
|
||||||
<a className="btn" href={loginUrl}>
|
|
||||||
Войти через OIDC
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && user && (
|
|
||||||
<div className="profile">
|
|
||||||
<p>
|
|
||||||
<strong>ID:</strong> {user.sub}
|
|
||||||
</p>
|
|
||||||
{user.name && (
|
|
||||||
<p>
|
|
||||||
<strong>Имя:</strong> {user.name}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{user.email && (
|
|
||||||
<p>
|
{!loading && !user && (
|
||||||
<strong>Email:</strong> {user.email}
|
<a className="btn" href={loginUrl}>
|
||||||
</p>
|
Войти через OIDC
|
||||||
)}
|
|
||||||
{!runWithoutAuth && (
|
|
||||||
<a className="btn ghost" href={logoutUrl}>
|
|
||||||
Выйти
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
{!loading && user && (
|
||||||
</section>
|
<div className="profile">
|
||||||
</main>
|
<p>
|
||||||
|
<strong>ID:</strong> {user.sub}
|
||||||
|
</p>
|
||||||
|
{user.name && (
|
||||||
|
<p>
|
||||||
|
<strong>Имя:</strong> {user.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{user.email && (
|
||||||
|
<p>
|
||||||
|
<strong>Email:</strong> {user.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!runWithoutAuth && (
|
||||||
|
<a className="btn ghost" href={logoutUrl}>
|
||||||
|
Выйти
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
767
furumi-node-player/client/src/FurumiPlayer.tsx
Normal file
767
furumi-node-player/client/src/FurumiPlayer.tsx
Normal 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 = '⏸'
|
||||||
|
})
|
||||||
|
audio.addEventListener('pause', () => {
|
||||||
|
const btn = document.getElementById('btnPlayPause')
|
||||||
|
if (btn) btn.innerHTML = '▶'
|
||||||
|
})
|
||||||
|
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 ? '🔇' : '🔊'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(v: number) {
|
||||||
|
audio.volume = v / 100
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
if (volIcon) volIcon.innerHTML = v === 0 ? '🔇' : '🔊'
|
||||||
|
window.localStorage.setItem('furumi_vol', String(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
function onSearch(q: string) {
|
||||||
|
if (searchTimer) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
if (q.length < 2) {
|
||||||
|
closeSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
const results = await 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">☰</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">
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn" id="btnNext">
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="progress-row">
|
||||||
|
<span className="time" id="timeElapsed">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
<div className="progress-bar" id="progressBar">
|
||||||
|
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
|
||||||
|
</div>
|
||||||
|
<span className="time" id="timeDuration">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="volume-row">
|
||||||
|
<span className="vol-icon" id="volIcon">
|
||||||
|
🔊
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="volume-slider"
|
||||||
|
id="volSlider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
defaultValue={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toast" id="toast" />
|
||||||
|
<audio id="audioEl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
30
furumi-node-player/client/src/components/Breadcrumbs.tsx
Normal file
30
furumi-node-player/client/src/components/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
type Crumb = {
|
||||||
|
label: string
|
||||||
|
action?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type BreadcrumbsProps = {
|
||||||
|
items: Crumb[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||||
|
if (!items.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="breadcrumb">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1
|
||||||
|
return (
|
||||||
|
<span key={`${item.label}-${index}`}>
|
||||||
|
{!isLast && item.action ? (
|
||||||
|
<span onClick={item.action}>{item.label}</span>
|
||||||
|
) : (
|
||||||
|
<span>{item.label}</span>
|
||||||
|
)}
|
||||||
|
{!isLast ? ' / ' : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
furumi-node-player/client/src/components/LibraryList.tsx
Normal file
54
furumi-node-player/client/src/components/LibraryList.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { MouseEvent } from 'react'
|
||||||
|
|
||||||
|
type LibraryListButton = {
|
||||||
|
title: string
|
||||||
|
onClick: (ev: MouseEvent<HTMLButtonElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryListItem = {
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: LibraryListButton
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryListProps = {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
items: LibraryListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryList({ loading, error, items }: LibraryListProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.key} className={item.className} onClick={item.onClick}>
|
||||||
|
<span className="icon">{item.icon}</span>
|
||||||
|
<span className={item.nameClassName ?? 'name'}>{item.name}</span>
|
||||||
|
{item.detail ? <span className="detail">{item.detail}</span> : null}
|
||||||
|
{item.button ? (
|
||||||
|
<button className="add-btn" title={item.button.title} onClick={item.button.onClick}>
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
30
furumi-node-player/client/src/components/SearchDropdown.tsx
Normal file
30
furumi-node-player/client/src/components/SearchDropdown.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
type SearchResultItem = {
|
||||||
|
result_type: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchDropdownProps = {
|
||||||
|
isOpen: boolean
|
||||||
|
results: SearchResultItem[]
|
||||||
|
onSelect: (type: string, slug: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchDropdown({ isOpen, results, onSelect }: SearchDropdownProps) {
|
||||||
|
return (
|
||||||
|
<div className={`search-dropdown${isOpen ? ' open' : ''}`}>
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`${r.result_type}:${r.slug}`}
|
||||||
|
className="search-result"
|
||||||
|
onClick={() => onSelect(r.result_type, r.slug)}
|
||||||
|
>
|
||||||
|
<span className="sr-type">{r.result_type}</span>
|
||||||
|
{r.name}
|
||||||
|
{r.detail ? <span className="sr-detail">{r.detail}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
754
furumi-node-player/client/src/furumi-player.css
Normal file
754
furumi-node-player/client/src/furumi-player.css
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
.furumi-root,
|
||||||
|
.furumi-root * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furumi-root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-base: #0a0c12;
|
||||||
|
--bg-panel: #111520;
|
||||||
|
--bg-card: #161d2e;
|
||||||
|
--bg-hover: #1e2740;
|
||||||
|
--bg-active: #252f4a;
|
||||||
|
--border: #1f2c45;
|
||||||
|
--accent: #7c6af7;
|
||||||
|
--accent-dim: #5a4fcf;
|
||||||
|
--accent-glow: rgba(124, 106, 247, 0.3);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--success: #34d399;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-version {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-menu {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px 6px 30px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 220px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap::before {
|
||||||
|
content: '🔍';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result .sr-type {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result .sr-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
resize: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 0.85rem 1rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span {
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.dir {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .detail {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .add-btn {
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover .add-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .add-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
padding: 0.85rem 1.25rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.55rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing {
|
||||||
|
background: var(--bg-active);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing .qi-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-index {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 1.5rem;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.playing .qi-index::before {
|
||||||
|
content: '▶';
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-cover {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item .qi-dur {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qi-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover .qi-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qi-remove:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.drag-over {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty .empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-cover {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-artist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-main {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff !important;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
box-shadow: 0 0 14px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-main:hover {
|
||||||
|
background: var(--accent-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover .progress-fill::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 1.5rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: all 0.25s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.btn-menu {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 85%;
|
||||||
|
max-width: 320px;
|
||||||
|
z-index: 30;
|
||||||
|
transition: left 0.3s;
|
||||||
|
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
furumi-node-player/client/src/furumiApi.ts
Normal file
12
furumi-node-player/client/src/furumiApi.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user