mp3-player / index.html
ArtelTaleb's picture
feat: add Buy Me a Coffee button in app + README badge
15b5489 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Required for ffmpeg.wasm SharedArrayBuffer support on static hosting -->
<script src="https://cdn.jsdelivr.net/npm/coi-serviceworker@0.1.7/coi-serviceworker.min.js"></script>
<title>MP3 Player</title>
<style>
/* ─── FONTS & CSS RESET ──────────────────────────────────────────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; }
/* A pixel font brings the old LCD vibe. Fallback to monospace if unavailable */
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap');
body {
background: #1a1a1a;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* ─── IPOD BODY ──────────────────────────────────────────────────────────────── */
.ipod-body {
width: 380px;
height: 700px;
background: #ffffff;
border-radius: 40px;
box-shadow:
0 0 0 2px #d0d0d0 inset,
0 20px 40px rgba(0,0,0,0.6),
0 -5px 15px rgba(255,255,255,0.8) inset,
0 5px 10px rgba(0,0,0,0.1);
position: relative;
padding: 30px;
display: flex;
flex-direction: column;
align-items: center;
}
/* Black rim around the screen */
.screen-bezel {
width: 100%;
height: 340px;
background: #000000;
border-radius: 12px;
padding: 10px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2) inset;
margin-bottom: 40px;
}
/* ─── LCD SCREEN ─────────────────────────────────────────────────────────────── */
.screen {
width: 100%;
height: 100%;
background: #e8e8e8; /* Default white-ish grayscale background */
border-radius: 4px;
overflow: hidden;
position: relative;
font-family: 'VT323', monospace; /* Pixelated retro font */
color: #000;
display: flex;
flex-direction: column;
}
/* Screen Scanlines / Grayscale Filter effect */
.screen::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(rgba(0,0,0,0.03) 50%, transparent 50%);
background-size: 100% 4px;
pointer-events: none;
z-index: 100;
}
/* Status Bar */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
border-bottom: 2px solid #000;
font-size: 18px;
font-weight: bold;
letter-spacing: 1px;
background: #fff; /* Slight contrast in the B&W motif */
}
/* ─── NOW PLAYING BLOCK ──────────────────────────────────────────────────────── */
.now-playing {
padding: 8px;
border-bottom: 2px solid #000;
background: #fff;
}
.np-label { font-size: 14px; text-transform: uppercase; font-family: 'VT323', monospace; }
.np-title {
font-size: 20px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'VT323', monospace;
margin-top: 2px;
}
.np-artist { font-size: 16px; font-family: 'VT323', monospace; color: #333; }
/* Progress bar */
.progress-wrap {
margin-top: 6px;
height: 6px;
background: #ccc;
border: 1px solid #000;
cursor: pointer;
position: relative;
}
.progress-fill { height: 100%; background: #000; width: 0%; pointer-events: none; }
.np-time {
display: flex;
justify-content: space-between;
font-size: 14px;
font-family: 'VT323', monospace;
margin-top: 2px;
}
/* ─── PLAYLIST ────────────────────────────────────────────────────────────────── */
.playlist {
flex: 1;
overflow-y: auto;
font-family: 'VT323', monospace;
background: #e8e8e8;
}
.pl-item {
display: flex;
align-items: center;
padding: 3px 6px;
font-size: 16px;
border-bottom: 1px solid #ccc;
cursor: pointer;
overflow: hidden;
}
.pl-item.active {
background: #000;
color: #fff;
}
.pl-index { width: 22px; flex-shrink: 0; font-size: 13px; opacity: 0.6; }
.pl-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ─── SCREEN FOOTER ──────────────────────────────────────────────────────────── */
.screen-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 6px;
font-size: 16px;
font-family: 'VT323', monospace;
background: #fff;
border-top: 2px solid #000;
flex-shrink: 0;
}
.footer-btn {
background: #000;
color: #fff;
padding: 1px 7px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
/* ─── DRAG OVERLAY ───────────────────────────────────────────────────────────── */
.drop-overlay {
display: none;
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
border-radius: 40px;
z-index: 200;
align-items: center;
justify-content: center;
flex-direction: column;
color: #fff;
font-family: 'VT323', monospace;
font-size: 28px;
pointer-events: none;
}
.drop-overlay.active { display: flex; }
/* Drop Overlay */
.screen-overlay {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
z-index: 50;
cursor: pointer;
}
.screen-overlay h2 { font-size: 24px; margin-bottom: 10px; }
.screen-overlay p { font-size: 16px; }
#file-input { display: none; }
/* ─── CLICK WHEEL ────────────────────────────────────────────────────────────── */
.wheel-container {
width: 250px;
height: 250px;
border-radius: 50%;
background: #111;
box-shadow:
0 2px 5px rgba(0,0,0,0.5),
0 0 0 1px #333 inset;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.wheel-label {
position: absolute;
color: #fff;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: bold;
font-size: 14px;
letter-spacing: 1px;
pointer-events: none;
}
.w-top { top: 25px; }
.w-bottom { bottom: 25px; font-size: 16px; } /* Play/Pause icon */
.w-left { left: 25px; font-size: 18px; } /* Rewind */
.w-right { right: 25px; font-size: 18px; } /* FF */
.wheel-center {
width: 90px;
height: 90px;
background: #fff;
border-radius: 50%;
box-shadow:
0 2px 4px rgba(0,0,0,0.3) inset,
0 0 0 1px #d0d0d0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s;
}
.wheel-center:active {
transform: scale(0.95);
background: #f0f0f0;
}
/* Clickable zones over the black wheel */
.zone {
position: absolute;
width: 60px;
height: 60px;
cursor: pointer;
border-radius: 50%;
z-index: 10;
}
.z-top { top: 0; left: 50%; transform: translateX(-50%); }
.z-bottom { bottom: 0; left: 50%; transform: translateX(-50%); }
.z-left { left: 0; top: 50%; transform: translateY(-50%); }
.z-right { right: 0; top: 50%; transform: translateY(-50%); }
/* ─── MOBILE CONTROLS (desktop: hidden) ─────────────────────────────────────── */
.mobile-controls { display: none; width: 100%; }
.mobile-main-btns {
display: flex;
justify-content: space-around;
align-items: center;
margin-bottom: 12px;
}
.m-btn {
min-width: 48px; min-height: 48px;
background: #000; color: #fff;
border: none; border-radius: 12px;
font-size: 22px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: transform 0.1s;
}
.m-btn:active { transform: scale(0.92); }
.m-btn-play {
min-width: 60px; min-height: 60px;
border-radius: 50%; font-size: 26px;
}
.mobile-secondary-btns {
display: flex;
justify-content: space-around;
}
.m-btn-sm {
min-width: 48px; min-height: 40px;
background: #eee; color: #000;
border: none; border-radius: 10px; font-size: 16px;
font-family: 'VT323', monospace; letter-spacing: 1px;
}
.m-btn-sm:active { transform: scale(0.92); }
/* ─── PLAYLIST DRAWER TAB (desktop: hidden) ──────────────────────────────────── */
.playlist-drawer-tab {
display: none;
width: 100%;
background: #eee;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
padding: 8px;
text-align: center;
font-family: 'VT323', monospace;
font-size: 18px;
font-weight: bold;
letter-spacing: 1px;
cursor: pointer;
user-select: none;
}
/* ─── PLAYLIST DRAWER ────────────────────────────────────────────────────────── */
.playlist-drawer {
display: none;
position: fixed;
inset: 0;
z-index: 300;
pointer-events: none;
}
.playlist-drawer.open { pointer-events: all; }
.drawer-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.4);
opacity: 0;
transition: opacity 0.3s;
}
.playlist-drawer.open .drawer-overlay { opacity: 1; }
.drawer-panel {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 70vh;
background: #000;
border-radius: 12px 12px 0 0;
padding: 6px;
transform: translateY(100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
}
.playlist-drawer.open .drawer-panel { transform: translateY(0); }
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px 6px;
font-family: 'VT323', monospace;
font-size: 18px;
color: #fff;
border-bottom: 1px solid #333;
margin-bottom: 6px;
cursor: pointer;
}
.playlist-lcd {
flex: 1;
background: #e8e8e8;
border-radius: 4px;
overflow-y: auto;
font-family: 'VT323', monospace;
font-size: 18px;
position: relative;
}
.playlist-lcd::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(rgba(0,0,0,0.03) 50%, transparent 50%);
background-size: 100% 4px;
pointer-events: none;
z-index: 10;
}
/* ─── MOBILE RESPONSIVE ──────────────────────────────────────────────────────── */
@media (max-width: 480px) {
body { align-items: flex-start; background: #fff; }
.ipod-body {
width: 100%;
min-height: 100vh;
border-radius: 0;
padding: 12px 12px 0;
box-shadow: none;
background: #fff;
}
.screen-bezel {
height: 210px;
margin-bottom: 16px;
}
.wheel-container { display: none; }
.drop-overlay { display: none !important; }
.mobile-controls { display: block; padding: 0 8px; }
.playlist-drawer-tab { display: block; margin-top: 12px; }
.playlist-drawer { display: block; }
}
</style>
</head>
<body>
<div class="ipod-body">
<!-- SCREEN -->
<div class="screen-bezel">
<div class="screen">
<!-- Drop overlay (visible quand drag sur iPod body) -->
<div class="screen-overlay" id="overlay" onclick="document.getElementById('file-input').click()">
<h2>MP3 PLAYER</h2>
<p>Click Center Button<br>or Drop Audio</p>
</div>
<input type="file" id="file-input" accept="audio/*,video/*,.wav,.flac,.aiff,.aif,.wma,.opus,.ogg,.mp3,.m4a,.mkv,.mov,.avi,.mp4,.webm" multiple onchange="importFiles(event)">
<!-- Status bar -->
<div class="status-bar">
<span id="clock">00:00</span>
<span>PLAYER</span>
<span id="shuffle-icon"></span>
</div>
<!-- Now Playing -->
<div class="now-playing">
<div class="np-label">NOW PLAYING:</div>
<div class="np-title" id="np-title"></div>
<div class="np-artist" id="np-artist"></div>
<div class="progress-wrap" id="progress-wrap">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="np-time">
<span id="np-elapsed">0:00</span>
<span id="np-position">1 / 0</span>
<span id="np-duration">0:00</span>
</div>
</div>
<!-- Playlist -->
<div class="playlist" id="playlist"></div>
<!-- Footer -->
<div class="screen-footer">
<span class="footer-btn" id="btn-menu" onclick="toggleShuffle()">MENU</span>
<span id="footer-count">0 / 0</span>
<span class="footer-btn" onclick="document.getElementById('file-input').click()">+ADD</span>
<a class="footer-btn" href="https://buymeacoffee.com/artel3d" target="_blank" rel="noopener" title="Buy me a coffee"></a>
</div>
</div>
</div>
<!-- MOBILE CONTROLS (hidden on desktop via CSS) -->
<div class="mobile-controls" id="mobile-controls">
<div class="mobile-main-btns">
<button class="m-btn" onclick="prevTrack()" aria-label="Précédent"></button>
<button class="m-btn m-btn-play" id="m-play-btn" onclick="togglePlay()" aria-label="Lecture/Pause"></button>
<button class="m-btn" onclick="nextTrack()" aria-label="Suivant"></button>
</div>
<div class="mobile-secondary-btns">
<button class="m-btn m-btn-sm" onclick="toggleShuffle()" aria-label="Shuffle"></button>
<button class="m-btn m-btn-sm" onclick="document.getElementById('file-input').click()" aria-label="Ajouter">+ADD</button>
</div>
</div>
<!-- PLAYLIST DRAWER TAB (hidden on desktop via CSS) -->
<div class="playlist-drawer-tab" id="drawer-tab" onclick="toggleDrawer()">
▲ PLAYLIST · <span id="drawer-count">0</span> PISTES
</div>
<!-- CLICK WHEEL -->
<div class="wheel-container">
<span class="wheel-label w-top">MENU</span>
<span class="wheel-label w-bottom">▶/II</span>
<span class="wheel-label w-left">I◀◀</span>
<span class="wheel-label w-right">▶▶I</span>
<!-- Interaction Zones -->
<div class="zone z-top" onclick="toggleShuffle()"></div> <!-- Top: Shuffle -->
<div class="zone z-bottom" onclick="togglePlay()"></div> <!-- Bottom: Play/Pause -->
<div class="zone z-left" onclick="prevTrack()"></div> <!-- Left: Previous -->
<div class="zone z-right" onclick="nextTrack()"></div> <!-- Right: Next -->
<div class="wheel-center" onclick="document.getElementById('file-input').click()"></div>
</div>
<div class="drop-overlay" id="drop-overlay">
<div>DROP HERE</div>
<div style="font-size:18px;margin-top:8px;">+ ajouter à la playlist</div>
</div>
<!-- PLAYLIST DRAWER (mobile only, fixed position) -->
<div class="playlist-drawer" id="playlist-drawer">
<div class="drawer-overlay" onclick="toggleDrawer()"></div>
<div class="drawer-panel">
<div class="drawer-header" onclick="toggleDrawer()">
<span>PLAYLIST</span>
<span>▼ FERMER</span>
</div>
<div class="playlist-lcd" id="playlist-lcd"></div>
</div>
</div>
</div>
<script type="module">
// ─── STATE ────────────────────────────────────────────────────────────────────
const state = {
tracks: [], // [{ name, artist, title, buffer }]
currentIndex: -1,
isPlaying: false,
shuffle: false,
shuffleOrder: [], // indices mélangés
shufflePos: 0, // position dans shuffleOrder
};
let audioCtx = null;
let sourceNode = null;
let startTime = 0;
let pauseOffset = 0;
let animFrame = null;
// ─── FFMPEG ───────────────────────────────────────────────────────────────────
let ffmpegInstance = null;
let ffmpegLoading = false;
async function getFFmpeg() {
if (ffmpegInstance) return ffmpegInstance;
if (ffmpegLoading) {
// Attendre que le chargement en cours se termine
while (ffmpegLoading) await new Promise(r => setTimeout(r, 100));
return ffmpegInstance;
}
ffmpegLoading = true;
showLcdMessage('LOADING FFMPEG...');
try {
const { FFmpeg } = await import('https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/esm/index.js');
const { toBlobURL } = await import('https://unpkg.com/@ffmpeg/util@0.12.1/dist/esm/index.js');
const ffmpeg = new FFmpeg();
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
ffmpeg.on('progress', ({ progress }) => {
showLcdMessage('LOADING... ' + Math.round(progress * 100) + '%');
});
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
ffmpegInstance = ffmpeg;
} finally {
ffmpegLoading = false;
}
if (!ffmpegInstance) throw new Error('FFmpeg failed to load');
return ffmpegInstance;
}
async function transcodeToArrayBuffer(file) {
const { fetchFile } = await import('https://unpkg.com/@ffmpeg/util@0.12.1/dist/esm/index.js');
const ffmpeg = await getFFmpeg();
const ext = (file.name.match(/\.[^.]+$/) || ['.bin'])[0];
const inputName = 'input' + ext;
const disposeProgress = ffmpeg.on('progress', ({ progress }) => {
showLcdMessage('DECODING... ' + Math.round(progress * 100) + '%');
});
await ffmpeg.writeFile(inputName, await fetchFile(file));
let data;
try {
await ffmpeg.exec(['-i', inputName, '-vn', '-ar', '44100', '-ac', '2', '-f', 'wav', 'output.wav']);
data = await ffmpeg.readFile('output.wav');
} finally {
disposeProgress();
try { await ffmpeg.deleteFile(inputName); } catch (_) {}
try { await ffmpeg.deleteFile('output.wav'); } catch (_) {}
}
return data.buffer;
}
function showLcdMessage(msg) {
document.getElementById('np-title').textContent = msg;
document.getElementById('np-artist').textContent = '';
}
// ─── UTILITAIRES ──────────────────────────────────────────────────────────────
function parseName(filename) {
const base = filename.replace(/\.[^/.]+$/, '');
const sep = base.indexOf(' - ');
if (sep !== -1) {
return { artist: base.slice(0, sep).trim(), title: base.slice(sep + 3).trim() };
}
return { artist: '', title: base.trim() };
}
function formatTime(sec) {
if (!isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
// Horloge
setInterval(() => {
const d = new Date();
document.getElementById('clock').textContent =
d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
}, 1000);
// ─── IMPORT ───────────────────────────────────────────────────────────────────
async function addTracksToPlaylist(files) {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
for (const file of files) {
showLcdMessage('READING: ' + file.name.slice(0, 20));
let buffer;
try {
// Couche 1 : décodage natif direct (résultat réutilisé, pas de double-décodage)
buffer = await audioCtx.decodeAudioData(await file.arrayBuffer());
} catch (_) {
// Couche 2 : fallback ffmpeg.wasm
try {
buffer = await audioCtx.decodeAudioData(await transcodeToArrayBuffer(file));
} catch (err) {
console.error('Cannot decode:', file.name, err);
showLcdMessage('ERROR: ' + file.name.slice(0, 18));
await new Promise(r => setTimeout(r, 1500));
continue;
}
}
const { artist, title } = parseName(file.name);
state.tracks.push({ name: file.name, artist, title, buffer });
}
document.getElementById('overlay').style.display = 'none';
renderPlaylist();
if (state.currentIndex === -1 && state.tracks.length > 0) { loadTrack(0); audioPlay(); }
if (state.shuffle) state.shuffleOrder = buildShuffleOrder();
}
async function importFiles(event) {
const files = Array.from(event.target.files);
if (!files.length) return;
await addTracksToPlaylist(files);
event.target.value = '';
}
// ─── AUDIO ENGINE ─────────────────────────────────────────────────────────────
function loadTrack(index) {
if (index < 0 || index >= state.tracks.length) return;
audioStop();
state.currentIndex = index;
pauseOffset = 0;
updateNowPlaying();
renderPlaylist();
}
function audioPlay() {
if (!audioCtx || state.currentIndex < 0) return;
if (audioCtx.state === 'suspended') audioCtx.resume();
cancelAnimationFrame(animFrame);
if (sourceNode) { try { sourceNode.disconnect(); } catch(e){} }
sourceNode = audioCtx.createBufferSource();
sourceNode.buffer = state.tracks[state.currentIndex].buffer;
sourceNode.connect(audioCtx.destination);
startTime = audioCtx.currentTime - pauseOffset;
sourceNode.start(0, pauseOffset);
state.isPlaying = true;
const mb = document.getElementById('m-play-btn');
if (mb) mb.textContent = '⏸';
sourceNode.onended = onTrackEnded;
tickProgress();
}
function audioPause() {
if (!state.isPlaying) return;
pauseOffset = audioCtx.currentTime - startTime;
if (sourceNode) {
sourceNode.onended = null;
try { sourceNode.stop(); } catch(e){}
}
state.isPlaying = false;
const mb = document.getElementById('m-play-btn');
if (mb) mb.textContent = '▶';
cancelAnimationFrame(animFrame);
}
function audioStop() {
if (sourceNode) {
sourceNode.onended = null;
try { sourceNode.stop(); } catch(e){}
}
state.isPlaying = false;
const mb = document.getElementById('m-play-btn');
if (mb) mb.textContent = '▶';
pauseOffset = 0;
cancelAnimationFrame(animFrame);
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('np-elapsed').textContent = '0:00';
}
function togglePlay() {
if (state.currentIndex < 0) return;
if (state.isPlaying) audioPause(); else audioPlay();
}
// ─── NAVIGATION ───────────────────────────────────────────────────────────────
function onTrackEnded() {
if (!state.isPlaying) return;
state.isPlaying = false;
nextTrack();
}
function nextTrack() {
if (!state.tracks.length) return;
if (state.shuffle) {
state.shufflePos++;
if (state.shufflePos >= state.shuffleOrder.length) {
state.currentIndex = state.shuffleOrder[state.shuffleOrder.length - 1];
audioStop();
updateNowPlaying();
renderPlaylist();
return;
}
loadTrack(state.shuffleOrder[state.shufflePos]);
} else {
if (state.currentIndex >= state.tracks.length - 1) {
audioStop();
return;
}
loadTrack(state.currentIndex + 1);
}
audioPlay();
}
function prevTrack() {
if (!state.tracks.length) return;
const elapsed = state.isPlaying ? audioCtx.currentTime - startTime : pauseOffset;
if (elapsed > 3) {
pauseOffset = 0;
if (state.isPlaying) audioPause();
audioPlay();
return;
}
if (state.shuffle) {
state.shufflePos = Math.max(0, state.shufflePos - 1);
loadTrack(state.shuffleOrder[state.shufflePos]);
} else {
loadTrack(Math.max(0, state.currentIndex - 1));
}
audioPlay();
}
// ─── UI ───────────────────────────────────────────────────────────────────────
function renderPlaylist() {
const buildItems = (container, onClickFn) => {
container.innerHTML = '';
state.tracks.forEach((t, i) => {
const row = document.createElement('div');
row.className = 'pl-item' + (i === state.currentIndex ? ' active' : '');
const idx = document.createElement('span');
idx.className = 'pl-index';
idx.textContent = String(i + 1).padStart(2, '0');
const nm = document.createElement('span');
nm.className = 'pl-name';
nm.textContent = (t.title || t.name) + (t.artist ? ' — ' + t.artist : '');
row.appendChild(idx);
row.appendChild(nm);
row.onclick = () => onClickFn(i);
container.appendChild(row);
});
const active = container.querySelector('.active');
if (active) active.scrollIntoView({ block: 'nearest' });
};
// Desktop playlist (inside screen)
buildItems(document.getElementById('playlist'), (i) => {
loadTrack(i); audioPlay();
});
// Mobile drawer playlist
buildItems(document.getElementById('playlist-lcd'), (i) => {
toggleDrawer();
loadTrack(i); audioPlay();
});
// Drawer tab counter
const count = state.tracks.length;
document.getElementById('drawer-count').textContent = count;
// Footer count
document.getElementById('footer-count').textContent =
count ? `${state.currentIndex + 1} / ${count}` : '0 / 0';
}
function updateNowPlaying() {
if (state.currentIndex < 0 || state.currentIndex >= state.tracks.length) return;
const t = state.tracks[state.currentIndex];
document.getElementById('np-title').textContent = t.title || t.name;
document.getElementById('np-artist').textContent = t.artist || '';
document.getElementById('np-duration').textContent = formatTime(t.buffer.duration);
document.getElementById('np-position').textContent =
`${state.currentIndex + 1} / ${state.tracks.length}`;
document.getElementById('footer-count').textContent =
`${state.currentIndex + 1} / ${state.tracks.length}`;
}
function tickProgress() {
if (!state.isPlaying) return;
const t = state.tracks[state.currentIndex];
if (!t) return;
const elapsed = audioCtx.currentTime - startTime;
const pct = Math.min(100, (elapsed / t.buffer.duration) * 100);
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('np-elapsed').textContent = formatTime(elapsed);
animFrame = requestAnimationFrame(tickProgress);
}
document.getElementById('progress-wrap').addEventListener('click', (e) => {
if (state.currentIndex < 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const duration = state.tracks[state.currentIndex].buffer.duration;
pauseOffset = ratio * duration;
if (state.isPlaying) { audioPause(); audioPlay(); }
else {
document.getElementById('progress-fill').style.width = (ratio * 100) + '%';
document.getElementById('np-elapsed').textContent = formatTime(pauseOffset);
}
});
// ─── DRAG & DROP ──────────────────────────────────────────────────────────────
const ipodBody = document.querySelector('.ipod-body');
const dropOverlay = document.getElementById('drop-overlay');
ipodBody.addEventListener('dragover', (e) => {
e.preventDefault();
dropOverlay.classList.add('active');
});
ipodBody.addEventListener('dragleave', (e) => {
// Ne masquer que si on quitte vraiment l'iPod (pas juste un enfant)
if (!ipodBody.contains(e.relatedTarget)) {
dropOverlay.classList.remove('active');
}
});
ipodBody.addEventListener('drop', async (e) => {
e.preventDefault();
dropOverlay.classList.remove('active');
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
await addTracksToPlaylist(files);
});
// ─── SHUFFLE ──────────────────────────────────────────────────────────────────
function buildShuffleOrder() {
const order = state.tracks.map((_, i) => i);
// Fisher-Yates
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
// Mettre la piste courante en premier si elle existe
if (state.currentIndex >= 0) {
const ci = order.indexOf(state.currentIndex);
if (ci !== -1) { [order[0], order[ci]] = [order[ci], order[0]]; }
}
return order;
}
function toggleShuffle() {
state.shuffle = !state.shuffle;
if (state.shuffle) {
state.shuffleOrder = buildShuffleOrder();
state.shufflePos = 0;
}
document.getElementById('shuffle-icon').textContent = state.shuffle ? '⇄' : '';
}
// ─── DRAWER ───────────────────────────────────────────────────────────────────
function toggleDrawer() {
const drawer = document.getElementById('playlist-drawer');
drawer.classList.toggle('open');
}
// ─── EXPOSE FUNCTIONS TO HTML onclick HANDLERS ───────────────────────────────
window.toggleDrawer = toggleDrawer;
window.toggleShuffle = toggleShuffle;
window.togglePlay = togglePlay;
window.prevTrack = prevTrack;
window.nextTrack = nextTrack;
window.importFiles = importFiles;
</script>
</body>
</html>