Spaces:
Running
Running
| <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 ; } | |
| .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> | |