Files
furumi-ng/furumi-server/src/web/player.html
2026-03-17 17:21:15 +00:00

1102 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.75rem; font-weight: 700; font-size: 1.1rem; }
.header-logo svg { width: 22px; height: 22px; color: var(--primary); }
.header-version {
font-size: 0.7rem;
color: var(--text-muted);
text-decoration: none;
background: rgba(255,255,255,0.05);
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.25rem;
font-weight: 500;
transition: all 0.2s;
}
.header-version:hover {
color: var(--primary);
background: rgba(124, 106, 247, 0.15);
}
.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); }
.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); }
/* ─── Main layout ─── */
.main {
display: flex;
flex: 1;
overflow: hidden;
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;
}
.sidebar-overlay.show { display: block; }
/* ─── 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.75rem; color: var(--text-muted); margin-left: auto; margin-right: 0.5rem; }
.qi-remove, .qi-locate {
background: none; border: none; font-size: 0.9rem;
color: var(--text-muted); cursor: pointer; padding: 0.3rem;
border-radius: 4px; transition: all 0.2s; opacity: 0;
}
.queue-item:hover .qi-remove, .queue-item:hover .qi-locate { opacity: 1; }
.qi-remove:hover { background: rgba(248,113,113,0.15); color: var(--danger); }
.qi-locate:hover { background: rgba(124,106,247,0.15); color: var(--primary); }
.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); }
/* ─── Cover Preview ─── */
.cover-preview-overlay {
position: fixed;
bottom: 80px;
left: 1.5rem;
width: 300px;
height: 300px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.7);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: translateY(10px) scale(0.95);
pointer-events: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 50;
}
.cover-preview-overlay.show {
opacity: 1;
transform: translateY(0) scale(1);
}
.cover-preview-overlay img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ─── 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 */ }
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-logo">
<button class="btn-menu" id="btnMenu" onclick="toggleSidebar()"></button>
<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
<a href="https://gt.hexor.cy/ab/furumi-ng" class="header-version" target="_blank">v<!-- VERSION_PLACEHOLDER --></a>
</div>
<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>
</header>
<!-- Main -->
<div class="main">
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<!-- Sidebar: file browser -->
<aside class="sidebar" id="sidebar">
<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" onmouseenter="showCoverPreview(true)" onmouseleave="showCoverPreview(false)">🎵</div>
<div class="cover-preview-overlay" id="coverPreview"></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) {
const title = track.meta?.title || displayName(track.name);
const artist = track.meta?.artist || '—';
const coverBase64 = track.meta?.cover_base64;
document.getElementById('npTitle').textContent = title;
document.getElementById('npArtist').textContent = artist;
if (coverBase64) {
document.getElementById('npCover').innerHTML = `<img src="${coverBase64}" alt="cover">`;
} else {
document.getElementById('npCover').textContent = '🎵';
}
document.title = title + ' — Furumi Player';
// Sync URL
history.replaceState(null, "", "?t=" + encodeURIComponent(track.path));
// Update OS Media Session (Hardware keys & OS Player UI)
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: title,
artist: artist !== '—' ? artist : '',
album: track.meta?.album || 'Furumi',
artwork: coverBase64 ? [{ src: coverBase64, sizes: '512x512' }] : []
});
}
// Update Preview
const preview = document.getElementById('coverPreview');
if (coverBase64) {
preview.innerHTML = `<img src="${coverBase64}" alt="cover full">`;
} else {
preview.innerHTML = '';
}
}
function showCoverPreview(show) {
const preview = document.getElementById('coverPreview');
const hasImage = preview.querySelector('img');
if (show && hasImage) {
preview.classList.add('show');
} else {
preview.classList.remove('show');
}
}
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;
if (queue[idx]) { queue[idx].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) {}
}
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('show');
}
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-locate" title="Go to folder" onclick="locateTrack(${origIdx}, event)">📂</button>
<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) {
if (ev) ev.stopPropagation();
const isPlaying = origIdx === queueIndex;
if (isPlaying) {
queueIndex = -1;
audio.pause();
audio.src = '';
updateNowPlaying();
} else if (queueIndex > origIdx) {
queueIndex--;
}
queue.splice(origIdx, 1);
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 locateTrack(idx, ev) {
if (ev) ev.stopPropagation();
const track = queue[idx];
if (!track) return;
const parts = track.path.split('/');
parts.pop(); // remove filename
const folder = parts.join('/');
navigate(folder);
if (window.innerWidth <= 768) {
const sidebar = document.getElementById('sidebar');
if (!sidebar.classList.contains('open')) toggleSidebar();
}
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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 ─────────────────────────────────────────────────────────────────────
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', togglePlay);
navigator.mediaSession.setActionHandler('pause', togglePlay);
navigator.mediaSession.setActionHandler('previoustrack', prevTrack);
navigator.mediaSession.setActionHandler('nexttrack', nextTrack);
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.fastSeek && 'fastSeek' in audio) {
audio.fastSeek(details.seekTime);
return;
}
audio.currentTime = details.seekTime;
});
}
// Deep linking on load
const urlParams = new URLSearchParams(window.location.search);
const trackPath = urlParams.get('t');
if (trackPath) {
const parts = trackPath.split('/');
const name = parts[parts.length - 1];
// Navigate to folder
const folderParts = [...parts];
folderParts.pop(); // remove name
navigate(folderParts.join('/'));
addToQueue(trackPath, name, true);
}
navigate('');
</script>
</body>
</html>