2026-03-17 13:49:03 +00:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="en">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
|
<title>Furumi Player</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
|
|
|
|
|
|
|
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
|
|
|
|
|
|
: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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html, body { height: 100%; overflow: hidden; }
|
|
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
|
font-family: 'Inter', system-ui, sans-serif;
|
|
|
|
|
|
background: var(--bg-base);
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ─── Header ─── */
|
|
|
|
|
|
.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.5rem;
|
|
|
|
|
|
font-size: 1.15rem; font-weight: 700; color: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
.header-logo svg { width: 22px; height: 22px; }
|
|
|
|
|
|
.btn-logout {
|
|
|
|
|
|
font-size: 0.78rem; color: var(--text-muted); background: none;
|
|
|
|
|
|
border: 1px solid var(--border); border-radius: 6px;
|
|
|
|
|
|
padding: 0.3rem 0.75rem; cursor: pointer; transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.btn-logout:hover { border-color: var(--danger); color: var(--danger); }
|
2026-03-17 15:17:30 +00:00
|
|
|
|
.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; transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.btn-menu:hover { background: var(--bg-hover); }
|
2026-03-17 13:49:03 +00:00
|
|
|
|
|
|
|
|
|
|
/* ─── Main layout ─── */
|
|
|
|
|
|
.main {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow: hidden;
|
2026-03-17 15:17:30 +00:00
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Mobile Overlay */
|
|
|
|
|
|
.sidebar-overlay {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
|
|
|
|
|
background: rgba(0,0,0,0.6); z-index: 20;
|
2026-03-17 13:49:03 +00:00
|
|
|
|
}
|
2026-03-17 15:17:30 +00:00
|
|
|
|
.sidebar-overlay.show { display: block; }
|
2026-03-17 13:49:03 +00:00
|
|
|
|
|
|
|
|
|
|
/* ─── File browser ─── */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.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-track { background: transparent; }
|
|
|
|
|
|
.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;
|
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
|
transition: background 0.12s;
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.file-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
|
|
|
|
.file-item.dir { color: var(--accent); }
|
|
|
|
|
|
.file-item.dir:hover { 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 .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;
|
|
|
|
|
|
transition: all 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.file-item:hover .add-btn { opacity: 1; }
|
|
|
|
|
|
.file-item .add-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
/* ─── Queue ─── */
|
|
|
|
|
|
.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; transition: all 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.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-track { background: transparent; }
|
|
|
|
|
|
.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;
|
|
|
|
|
|
transition: background 0.12s;
|
|
|
|
|
|
border-left: 2px solid transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
.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.72rem; color: var(--text-muted); flex-shrink: 0; }
|
|
|
|
|
|
.queue-item .qi-remove {
|
|
|
|
|
|
opacity: 0; font-size: 0.8rem; color: var(--text-muted);
|
|
|
|
|
|
background: none; border: none; cursor: pointer; padding: 2px 5px;
|
|
|
|
|
|
border-radius: 4px; transition: all 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.queue-item:hover .qi-remove { opacity: 1; }
|
|
|
|
|
|
.queue-item .qi-remove:hover { color: var(--danger); }
|
|
|
|
|
|
.queue-item.dragging { opacity: 0.5; background: var(--bg-active); }
|
|
|
|
|
|
.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 ─── */
|
|
|
|
|
|
.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; transition: all 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
position: relative; transition: width 0.1s linear;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* scrollbar global */
|
|
|
|
|
|
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
|
|
|
|
|
|
|
|
|
|
/* loading spinner */
|
|
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
|
.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 */
|
|
|
|
|
|
.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); }
|
2026-03-17 15:17:30 +00:00
|
|
|
|
|
|
|
|
|
|
/* ─── Responsive (Mobile) ─── */
|
|
|
|
|
|
@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 cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
|
|
box-shadow: 4px 0 20px rgba(0,0,0,0.6);
|
|
|
|
|
|
}
|
|
|
|
|
|
.sidebar.open { left: 0; }
|
|
|
|
|
|
.queue-panel { flex: 1; min-width: 0; }
|
|
|
|
|
|
.player-bar {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
grid-template-rows: auto auto auto;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.np-info { display: grid; grid-template-columns: auto 1fr; text-align: left; }
|
|
|
|
|
|
.volume-row { display: none; /* Hide volume on mobile to save space, rely on hardware buttons */ }
|
|
|
|
|
|
}
|
2026-03-17 13:49:03 +00:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
|
<header class="header">
|
|
|
|
|
|
<div class="header-logo">
|
2026-03-17 15:17:30 +00:00
|
|
|
|
<button class="btn-menu" id="btnMenu" onclick="toggleSidebar()">☰</button>
|
2026-03-17 13:49:03 +00:00
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
|
|
<circle cx="9" cy="18" r="3"/><circle cx="18" cy="15" r="3"/>
|
|
|
|
|
|
<path d="M12 18V6l9-3v3"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
Furumi Player
|
|
|
|
|
|
</div>
|
2026-03-17 15:17:30 +00:00
|
|
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
|
|
|
|
<span style="font-size: 0.8rem; color: var(--text-dim);">
|
|
|
|
|
|
<span style="opacity: 0.6; margin-right: 0.2rem;">👤</span>
|
|
|
|
|
|
<!-- USERNAME_PLACEHOLDER -->
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button class="btn-logout" onclick="logout()">Sign out</button>
|
|
|
|
|
|
</div>
|
2026-03-17 13:49:03 +00:00
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Main -->
|
|
|
|
|
|
<div class="main">
|
2026-03-17 15:17:30 +00:00
|
|
|
|
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
|
2026-03-17 13:49:03 +00:00
|
|
|
|
<!-- Sidebar: file browser -->
|
2026-03-17 15:17:30 +00:00
|
|
|
|
<aside class="sidebar" id="sidebar">
|
2026-03-17 13:49:03 +00:00
|
|
|
|
<div class="sidebar-header">📁 Library</div>
|
|
|
|
|
|
<div class="breadcrumb" id="breadcrumb">/ <span onclick="navigate('')">root</span></div>
|
|
|
|
|
|
<div class="file-list" id="fileList">
|
|
|
|
|
|
<div style="padding:2rem;text-align:center;color:var(--text-muted)"><div class="spinner"></div></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Queue -->
|
|
|
|
|
|
<section class="queue-panel">
|
|
|
|
|
|
<div class="queue-header">
|
|
|
|
|
|
<span>Queue</span>
|
|
|
|
|
|
<div class="queue-actions">
|
|
|
|
|
|
<button class="queue-btn active" id="btnShuffle" onclick="toggleShuffle()" title="Shuffle">⇄ Shuffle</button>
|
|
|
|
|
|
<button class="queue-btn active" id="btnRepeat" onclick="toggleRepeat()" title="Repeat">↻ Repeat</button>
|
|
|
|
|
|
<button class="queue-btn" onclick="clearQueue()" title="Clear queue">✕ Clear</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="queue-list" id="queueList">
|
|
|
|
|
|
<div class="queue-empty" id="queueEmpty">
|
|
|
|
|
|
<div class="empty-icon">🎵</div>
|
|
|
|
|
|
<div>Click files to add to queue</div>
|
|
|
|
|
|
<div style="font-size:0.75rem;margin-top:0.25rem">Double-click a folder to add all tracks</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Player bar -->
|
|
|
|
|
|
<div class="player-bar">
|
|
|
|
|
|
<!-- Now playing -->
|
|
|
|
|
|
<div class="np-info">
|
|
|
|
|
|
<div class="np-cover" id="npCover">🎵</div>
|
|
|
|
|
|
<div class="np-text">
|
|
|
|
|
|
<div class="np-title" id="npTitle">Nothing playing</div>
|
|
|
|
|
|
<div class="np-artist" id="npArtist">—</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Controls -->
|
|
|
|
|
|
<div class="controls">
|
|
|
|
|
|
<div class="ctrl-btns">
|
|
|
|
|
|
<button class="ctrl-btn" onclick="prevTrack()" title="Previous">⏮</button>
|
|
|
|
|
|
<button class="ctrl-btn ctrl-btn-main" id="btnPlayPause" onclick="togglePlay()" title="Play/Pause">▶</button>
|
|
|
|
|
|
<button class="ctrl-btn" onclick="nextTrack()" title="Next">⏭</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="progress-row">
|
|
|
|
|
|
<span class="time" id="timeElapsed">0:00</span>
|
|
|
|
|
|
<div class="progress-bar" id="progressBar" onclick="seekTo(event)" title="Seek position">
|
|
|
|
|
|
<div class="progress-fill" id="progressFill" style="width:0%"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="time" id="timeDuration">0:00</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Volume -->
|
|
|
|
|
|
<div class="volume-row">
|
|
|
|
|
|
<span class="vol-icon" onclick="toggleMute()" id="volIcon" title="Toggle Mute">🔊</span>
|
|
|
|
|
|
<input type="range" class="volume-slider" id="volSlider" min="0" max="100" value="80"
|
|
|
|
|
|
title="Volume" oninput="setVolume(this.value)">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<audio id="audioEl"></audio>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
const audio = document.getElementById('audioEl');
|
|
|
|
|
|
let queue = []; // [{path, name, meta}]
|
|
|
|
|
|
let queueIndex = -1;
|
|
|
|
|
|
let shuffle = false;
|
|
|
|
|
|
let repeatAll = true;
|
|
|
|
|
|
let shuffleOrder = [];
|
|
|
|
|
|
let currentPath = '';
|
|
|
|
|
|
let isSeeking = false;
|
|
|
|
|
|
let metaCache = {};
|
|
|
|
|
|
|
|
|
|
|
|
// Restore prefs
|
|
|
|
|
|
(function() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const v = localStorage.getItem('furumi_vol');
|
|
|
|
|
|
if (v !== null) { audio.volume = v / 100; document.getElementById('volSlider').value = v; }
|
|
|
|
|
|
shuffle = localStorage.getItem('furumi_shuffle') === '1';
|
|
|
|
|
|
repeatAll = localStorage.getItem('furumi_repeat') !== '0';
|
|
|
|
|
|
updateShuffleUI();
|
|
|
|
|
|
updateRepeatUI();
|
|
|
|
|
|
} catch(e) {}
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Audio events ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
audio.addEventListener('timeupdate', () => {
|
|
|
|
|
|
if (!isSeeking && audio.duration) {
|
|
|
|
|
|
const pct = (audio.currentTime / audio.duration) * 100;
|
|
|
|
|
|
document.getElementById('progressFill').style.width = pct + '%';
|
|
|
|
|
|
document.getElementById('timeElapsed').textContent = fmt(audio.currentTime);
|
|
|
|
|
|
document.getElementById('timeDuration').textContent = fmt(audio.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
audio.addEventListener('ended', () => nextTrack());
|
|
|
|
|
|
audio.addEventListener('play', () => { document.getElementById('btnPlayPause').textContent = '⏸'; });
|
|
|
|
|
|
audio.addEventListener('pause', () => { document.getElementById('btnPlayPause').textContent = '▶'; });
|
|
|
|
|
|
audio.addEventListener('error', () => { showToast('Failed to play track'); nextTrack(); });
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Browse ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async function navigate(path) {
|
|
|
|
|
|
currentPath = path;
|
|
|
|
|
|
updateBreadcrumb(path);
|
|
|
|
|
|
const listEl = document.getElementById('fileList');
|
|
|
|
|
|
listEl.innerHTML = '<div style="padding:2rem;text-align:center;color:var(--text-muted)"><div class="spinner"></div></div>';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/browse?path=' + encodeURIComponent(path));
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (!res.ok) { listEl.innerHTML = `<div style="padding:1rem;color:var(--danger)">${data.error||'Error'}</div>`; return; }
|
|
|
|
|
|
renderFileList(data.entries, path);
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
listEl.innerHTML = '<div style="padding:1rem;color:var(--danger)">Network error</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderFileList(entries, basePath) {
|
|
|
|
|
|
const listEl = document.getElementById('fileList');
|
|
|
|
|
|
if (!entries.length) {
|
|
|
|
|
|
listEl.innerHTML = '<div style="padding:1.5rem;text-align:center;color:var(--text-muted);font-size:0.85rem">Empty folder</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
listEl.innerHTML = '';
|
|
|
|
|
|
entries.forEach(e => {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'file-item ' + (e.type === 'dir' ? 'dir' : '');
|
|
|
|
|
|
const itemPath = basePath ? basePath + '/' + e.name : e.name;
|
|
|
|
|
|
|
|
|
|
|
|
if (e.type === 'dir') {
|
|
|
|
|
|
div.innerHTML = `<span class="icon">📁</span><span class="name">${esc(e.name)}</span>
|
|
|
|
|
|
<button class="add-btn" title="Add folder to queue">➕</button>`;
|
|
|
|
|
|
div.querySelector('.add-btn').addEventListener('click', ev => {
|
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
|
addFolderToQueue(itemPath);
|
|
|
|
|
|
});
|
|
|
|
|
|
div.addEventListener('click', () => navigate(itemPath));
|
|
|
|
|
|
div.addEventListener('dblclick', () => addFolderToQueue(itemPath));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
div.innerHTML = `<span class="icon">🎵</span><span class="name">${esc(e.name)}</span>
|
|
|
|
|
|
<button class="add-btn add-next" title="Play next">▶️</button>
|
|
|
|
|
|
<button class="add-btn add-end" title="Add to end">➕</button>`;
|
|
|
|
|
|
|
|
|
|
|
|
div.querySelector('.add-next').addEventListener('click', ev => {
|
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
|
addNextProtocol(itemPath, e.name);
|
|
|
|
|
|
});
|
|
|
|
|
|
div.querySelector('.add-end').addEventListener('click', ev => {
|
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
|
addToQueue(itemPath, e.name);
|
|
|
|
|
|
showToast('Added to queue');
|
|
|
|
|
|
});
|
|
|
|
|
|
div.addEventListener('click', () => {
|
|
|
|
|
|
addToQueue(itemPath, e.name, true);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
listEl.appendChild(div);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateBreadcrumb(path) {
|
|
|
|
|
|
const el = document.getElementById('breadcrumb');
|
|
|
|
|
|
if (!path) { el.innerHTML = '/ <span onclick="navigate(\'\')">root</span>'; return; }
|
|
|
|
|
|
const parts = path.split('/');
|
|
|
|
|
|
let html = '/ <span onclick="navigate(\'\')">root</span>';
|
|
|
|
|
|
let acc = '';
|
|
|
|
|
|
parts.forEach((p, i) => {
|
|
|
|
|
|
acc = acc ? acc + '/' + p : p;
|
|
|
|
|
|
const cap = acc;
|
|
|
|
|
|
if (i < parts.length - 1) html += ` / <span onclick="navigate('${cap}')">${esc(p)}</span>`;
|
|
|
|
|
|
else html += ` / ${esc(p)}`;
|
|
|
|
|
|
});
|
|
|
|
|
|
el.innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function addFolderToQueue(folderPath) {
|
|
|
|
|
|
showToast('Loading folder…');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/browse?path=' + encodeURIComponent(folderPath));
|
|
|
|
|
|
if (!res.ok) throw new Error('API error');
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const files = (data.entries || []).filter(e => e.type === 'file');
|
|
|
|
|
|
|
|
|
|
|
|
// Process additions in a batch to avoid UI race conditions
|
|
|
|
|
|
files.forEach(f => {
|
|
|
|
|
|
const p = folderPath ? folderPath + '/' + f.name : f.name;
|
|
|
|
|
|
addToQueue(p, f.name, false, true); // true = skipRender
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
renderQueue(); // render once after the batch
|
|
|
|
|
|
|
|
|
|
|
|
// Auto-start if it was empty
|
|
|
|
|
|
if (queueIndex === -1 && queue.length > 0) {
|
|
|
|
|
|
playIndex(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showToast(`Added ${files.length} tracks`);
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
showToast('Error loading folder');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Queue ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
function addNextProtocol(path, name) {
|
|
|
|
|
|
const existing = queue.findIndex(t => t.path === path);
|
|
|
|
|
|
if (existing !== -1) return; // already in queue
|
|
|
|
|
|
|
|
|
|
|
|
const track = { path, name, meta: null };
|
|
|
|
|
|
let newIdx;
|
|
|
|
|
|
|
|
|
|
|
|
if (queueIndex === -1 || queue.length === 0) {
|
|
|
|
|
|
// nothing playing, just add and play
|
|
|
|
|
|
queue.push(track);
|
|
|
|
|
|
newIdx = queue.length - 1;
|
|
|
|
|
|
fetchMeta(path, newIdx);
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
playIndex(newIdx);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// insert right after current playing
|
|
|
|
|
|
newIdx = queueIndex + 1;
|
|
|
|
|
|
queue.splice(newIdx, 0, track);
|
|
|
|
|
|
|
|
|
|
|
|
// update shuffle order if needed
|
|
|
|
|
|
if (shuffle) {
|
|
|
|
|
|
shuffleOrder.splice(shuffleOrder.indexOf(queueIndex) + 1, 0, newIdx);
|
|
|
|
|
|
// adjust indices for tracks shifted to the right
|
|
|
|
|
|
for (let i = 0; i < shuffleOrder.length; i++) {
|
|
|
|
|
|
if (shuffleOrder[i] >= newIdx && shuffleOrder[i] !== newIdx) {
|
|
|
|
|
|
shuffleOrder[i]++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fetchMeta(path, newIdx);
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addToQueue(path, name, playNow = false, skipRender = false) {
|
|
|
|
|
|
const existing = queue.findIndex(t => t.path === path);
|
|
|
|
|
|
if (existing !== -1 && !playNow) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (existing === -1) {
|
|
|
|
|
|
queue.push({ path, name, meta: null });
|
|
|
|
|
|
const idx = queue.length - 1;
|
|
|
|
|
|
fetchMeta(path, idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const idx = existing !== -1 ? existing : queue.length - 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (!skipRender) renderQueue();
|
|
|
|
|
|
|
|
|
|
|
|
if (playNow) {
|
|
|
|
|
|
if (queueIndex === -1) {
|
|
|
|
|
|
playIndex(idx);
|
|
|
|
|
|
} // else already playing, just queued
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (queueIndex === -1 && !playNow && !skipRender) {
|
|
|
|
|
|
// Auto-start if first item
|
|
|
|
|
|
playIndex(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function playIndex(i) {
|
|
|
|
|
|
if (i < 0 || i >= queue.length) return;
|
|
|
|
|
|
queueIndex = i;
|
|
|
|
|
|
const track = queue[i];
|
|
|
|
|
|
const url = '/api/stream/' + track.path;
|
|
|
|
|
|
audio.src = url;
|
|
|
|
|
|
audio.play().catch(() => {});
|
|
|
|
|
|
updateNowPlaying(track);
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
scrollQueueToActive();
|
|
|
|
|
|
loadMeta(track);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateNowPlaying(track) {
|
|
|
|
|
|
document.getElementById('npTitle').textContent = track.meta?.title || displayName(track.name);
|
|
|
|
|
|
document.getElementById('npArtist').textContent = track.meta?.artist || '—';
|
|
|
|
|
|
if (track.meta?.cover_base64) {
|
|
|
|
|
|
document.getElementById('npCover').innerHTML = `<img src="${track.meta.cover_base64}" alt="cover">`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.getElementById('npCover').textContent = '🎵';
|
|
|
|
|
|
}
|
|
|
|
|
|
document.title = (track.meta?.title || displayName(track.name)) + ' — Furumi Player';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadMeta(track) {
|
|
|
|
|
|
if (track.meta) { updateNowPlaying(track); return; }
|
|
|
|
|
|
const cached = metaCache[track.path];
|
|
|
|
|
|
if (cached) { track.meta = cached; updateNowPlaying(track); return; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/meta/' + track.path);
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const meta = await res.json();
|
|
|
|
|
|
metaCache[track.path] = meta;
|
|
|
|
|
|
track.meta = meta;
|
|
|
|
|
|
updateNowPlaying(track);
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchMeta(path, idx) {
|
|
|
|
|
|
const cached = metaCache[path];
|
|
|
|
|
|
if (cached) { queue[idx].meta = cached; renderQueue(); return; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/meta/' + path);
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const meta = await res.json();
|
|
|
|
|
|
metaCache[path] = meta;
|
|
|
|
|
|
if (queue[idx]) { queue[idx].meta = meta; }
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 15:17:30 +00:00
|
|
|
|
function toggleSidebar() {
|
|
|
|
|
|
const sidebar = document.getElementById('sidebar');
|
|
|
|
|
|
const overlay = document.getElementById('sidebarOverlay');
|
|
|
|
|
|
sidebar.classList.toggle('open');
|
|
|
|
|
|
overlay.classList.toggle('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 13:49:03 +00:00
|
|
|
|
function renderQueue() {
|
|
|
|
|
|
const listEl = document.getElementById('queueList');
|
|
|
|
|
|
|
|
|
|
|
|
if (!queue.length) {
|
|
|
|
|
|
listEl.innerHTML = `
|
|
|
|
|
|
<div class="queue-empty" id="queueEmpty">
|
|
|
|
|
|
<div class="empty-icon">🎵</div>
|
|
|
|
|
|
<div>Click files to add to queue</div>
|
|
|
|
|
|
<div style="font-size:0.75rem;margin-top:0.25rem">Double-click a folder to add all tracks</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const order = currentOrder();
|
|
|
|
|
|
listEl.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
order.forEach((origIdx, pos) => {
|
|
|
|
|
|
const track = queue[origIdx];
|
|
|
|
|
|
const isPlaying = origIdx === queueIndex;
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'queue-item' + (isPlaying ? ' playing' : '');
|
|
|
|
|
|
div.dataset.origIdx = origIdx;
|
|
|
|
|
|
|
|
|
|
|
|
const cover = track.meta?.cover_base64
|
|
|
|
|
|
? `<img src="${track.meta.cover_base64}" alt="">`
|
|
|
|
|
|
: '🎵';
|
|
|
|
|
|
const title = track.meta?.title || displayName(track.name);
|
|
|
|
|
|
const artist = track.meta?.artist || '';
|
|
|
|
|
|
const dur = track.meta?.duration_secs != null ? fmt(track.meta.duration_secs) : '';
|
|
|
|
|
|
const idxDisplay = isPlaying ? '' : (pos + 1);
|
|
|
|
|
|
|
|
|
|
|
|
div.innerHTML = `
|
|
|
|
|
|
<span class="qi-index">${idxDisplay}</span>
|
|
|
|
|
|
<div class="qi-cover">${cover}</div>
|
|
|
|
|
|
<div class="qi-info">
|
|
|
|
|
|
<div class="qi-title">${esc(title)}</div>
|
|
|
|
|
|
<div class="qi-artist">${esc(artist)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="qi-dur">${dur}</span>
|
|
|
|
|
|
<button class="qi-remove" title="Remove track" onclick="removeFromQueue(${origIdx}, event)">✕</button>
|
|
|
|
|
|
`;
|
|
|
|
|
|
div.addEventListener('click', () => playIndex(origIdx));
|
|
|
|
|
|
|
|
|
|
|
|
// Drag & Drop for reordering
|
|
|
|
|
|
div.draggable = true;
|
|
|
|
|
|
div.addEventListener('dragstart', e => {
|
|
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
|
e.dataTransfer.setData('text/plain', pos);
|
|
|
|
|
|
div.classList.add('dragging');
|
|
|
|
|
|
});
|
|
|
|
|
|
div.addEventListener('dragend', () => {
|
|
|
|
|
|
div.classList.remove('dragging');
|
|
|
|
|
|
document.querySelectorAll('.queue-item').forEach(el => el.classList.remove('drag-over'));
|
|
|
|
|
|
});
|
|
|
|
|
|
div.addEventListener('dragover', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
|
|
});
|
|
|
|
|
|
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 fromPos = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
|
|
|
|
|
if (!isNaN(fromPos)) moveQueueItem(fromPos, pos);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
listEl.appendChild(div);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scrollQueueToActive() {
|
|
|
|
|
|
const el = document.querySelector('.queue-item.playing');
|
|
|
|
|
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeFromQueue(origIdx, ev) {
|
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
|
queue.splice(origIdx, 1);
|
|
|
|
|
|
if (queueIndex === origIdx) { queueIndex = -1; audio.pause(); audio.src = ''; }
|
|
|
|
|
|
else if (queueIndex > origIdx) queueIndex--;
|
|
|
|
|
|
|
|
|
|
|
|
if (shuffle) {
|
|
|
|
|
|
const sidx = shuffleOrder.indexOf(origIdx);
|
|
|
|
|
|
if (sidx !== -1) shuffleOrder.splice(sidx, 1);
|
|
|
|
|
|
for (let i = 0; i < shuffleOrder.length; i++) {
|
|
|
|
|
|
if (shuffleOrder[i] > origIdx) shuffleOrder[i]--;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function moveQueueItem(fromPos, toPos) {
|
|
|
|
|
|
if (fromPos === toPos) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (shuffle) {
|
|
|
|
|
|
const item = shuffleOrder.splice(fromPos, 1)[0];
|
|
|
|
|
|
shuffleOrder.splice(toPos, 0, item);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const item = queue.splice(fromPos, 1)[0];
|
|
|
|
|
|
queue.splice(toPos, 0, item);
|
|
|
|
|
|
|
|
|
|
|
|
if (queueIndex === fromPos) {
|
|
|
|
|
|
queueIndex = toPos;
|
|
|
|
|
|
} else if (fromPos < queueIndex && toPos >= queueIndex) {
|
|
|
|
|
|
queueIndex--;
|
|
|
|
|
|
} else if (fromPos > queueIndex && toPos <= queueIndex) {
|
|
|
|
|
|
queueIndex++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearQueue() {
|
|
|
|
|
|
queue = []; queueIndex = -1; shuffleOrder = [];
|
|
|
|
|
|
audio.pause(); audio.src = '';
|
|
|
|
|
|
document.getElementById('npTitle').textContent = 'Nothing playing';
|
|
|
|
|
|
document.getElementById('npArtist').textContent = '—';
|
|
|
|
|
|
document.getElementById('npCover').textContent = '🎵';
|
|
|
|
|
|
document.title = 'Furumi Player';
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Playback controls ────────────────────────────────────────────────────────
|
|
|
|
|
|
function togglePlay() {
|
|
|
|
|
|
if (!audio.src) { if (queue.length) playIndex(queueIndex === -1 ? 0 : queueIndex); return; }
|
|
|
|
|
|
if (audio.paused) 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();
|
|
|
|
|
|
updateShuffleUI();
|
|
|
|
|
|
localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0');
|
|
|
|
|
|
renderQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
function updateShuffleUI() {
|
|
|
|
|
|
document.getElementById('btnShuffle').classList.toggle('active', shuffle);
|
|
|
|
|
|
}
|
|
|
|
|
|
function toggleRepeat() {
|
|
|
|
|
|
repeatAll = !repeatAll;
|
|
|
|
|
|
updateRepeatUI();
|
|
|
|
|
|
localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0');
|
|
|
|
|
|
}
|
|
|
|
|
|
function updateRepeatUI() {
|
|
|
|
|
|
document.getElementById('btnRepeat').classList.toggle('active', repeatAll);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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]];
|
|
|
|
|
|
}
|
|
|
|
|
|
// Put current track first in shuffle order
|
|
|
|
|
|
if (queueIndex !== -1) {
|
|
|
|
|
|
const ci = shuffleOrder.indexOf(queueIndex);
|
|
|
|
|
|
if (ci > 0) { shuffleOrder.splice(ci, 1); shuffleOrder.unshift(queueIndex); }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function currentOrder() {
|
|
|
|
|
|
if (!shuffle) return [...Array(queue.length).keys()];
|
|
|
|
|
|
if (shuffleOrder.length !== queue.length) buildShuffleOrder();
|
|
|
|
|
|
return shuffleOrder;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Seek & Volume ────────────────────────────────────────────────────────────
|
|
|
|
|
|
function seekTo(e) {
|
|
|
|
|
|
if (!audio.duration) return;
|
|
|
|
|
|
const bar = document.getElementById('progressBar');
|
|
|
|
|
|
const rect = bar.getBoundingClientRect();
|
|
|
|
|
|
const pct = (e.clientX - rect.left) / rect.width;
|
|
|
|
|
|
audio.currentTime = pct * audio.duration;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let muted = false, volBeforeMute = 80;
|
|
|
|
|
|
function toggleMute() {
|
|
|
|
|
|
muted = !muted;
|
|
|
|
|
|
audio.muted = muted;
|
|
|
|
|
|
document.getElementById('volIcon').textContent = muted ? '🔇' : '🔊';
|
|
|
|
|
|
}
|
|
|
|
|
|
function setVolume(v) {
|
|
|
|
|
|
audio.volume = v / 100;
|
|
|
|
|
|
document.getElementById('volIcon').textContent = v == 0 ? '🔇' : '🔊';
|
|
|
|
|
|
localStorage.setItem('furumi_vol', v);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
function fmt(secs) {
|
|
|
|
|
|
if (!secs || 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) { return String(n).padStart(2, '0'); }
|
|
|
|
|
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
|
|
|
|
function displayName(filename) {
|
|
|
|
|
|
return filename.replace(/\.[^.]+$/, '').replace(/_/g, ' ').replace(/^\d+[\s.-]+/, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let toastTimer;
|
|
|
|
|
|
function showToast(msg) {
|
|
|
|
|
|
const t = document.getElementById('toast');
|
|
|
|
|
|
t.textContent = msg;
|
|
|
|
|
|
t.classList.add('show');
|
|
|
|
|
|
clearTimeout(toastTimer);
|
|
|
|
|
|
toastTimer = setTimeout(() => t.classList.remove('show'), 2500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function logout() {
|
|
|
|
|
|
window.location.href = '/logout';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
navigate('');
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|