Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Audiomax Player</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --primary-color: #8B5CF6; | |
| /* A nice purple for the main accent */ | |
| --primary-hover-color: #7C3AED; | |
| --background-color: #1d2b3a; | |
| --surface-color: #2a3447; | |
| --text-color: #f5f5f7; | |
| --text-muted-color: #a0aec0; | |
| --border-color: rgba(255, 255, 255, 0.12); | |
| --error-color: #EF4444; | |
| --border-radius-lg: 24px; | |
| --border-radius-md: 14px; | |
| --transition-speed: 0.4s; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| margin: 0; | |
| padding: 1.5rem; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| background-color: var(--background-color); | |
| transition: background-color var(--transition-speed); | |
| color: var(--text-color); | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| .container { | |
| width: 100%; | |
| max-width: 400px; | |
| /* Increased max-width */ | |
| background: var(--surface-color); | |
| border-radius: var(--border-radius-lg); | |
| box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25); | |
| border: 1px solid var(--border-color); | |
| transition: background-color var(--transition-speed); | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| /* --- NOTIFICATION --- */ | |
| #notification { | |
| position: absolute; | |
| top: 0; | |
| left: 1.5rem; | |
| right: 1.5rem; | |
| background-color: var(--primary-color); | |
| color: white; | |
| padding: 1rem; | |
| border-radius: 0 0 var(--border-radius-md) var(--border-radius-md); | |
| text-align: center; | |
| font-weight: 600; | |
| transform: translateY(-120%); | |
| transition: transform 0.4s ease-in-out; | |
| z-index: 150; | |
| } | |
| #notification.show { | |
| transform: translateY(0); | |
| } | |
| #notification.error { | |
| background-color: var(--error-color); | |
| } | |
| /* --- VIEW TRANSITIONS --- */ | |
| .view { | |
| transition: opacity var(--transition-speed), visibility var(--transition-speed); | |
| } | |
| .view:not(.visible) { | |
| opacity: 0; | |
| visibility: hidden; | |
| display: none; | |
| } | |
| .view.visible { | |
| opacity: 1; | |
| visibility: visible; | |
| display: block; | |
| } | |
| /* UPLOAD SECTION */ | |
| .upload-section { | |
| padding: 2.5rem; | |
| } | |
| .upload-header { | |
| text-align: center; | |
| } | |
| .upload-header h1 { | |
| margin: 0 0 0.5rem; | |
| font-size: 2.5rem; | |
| font-weight: 700; | |
| } | |
| .upload-header p { | |
| margin-bottom: 2rem; | |
| color: var(--text-muted-color); | |
| font-size: 1rem; | |
| } | |
| .upload-drop-zone { | |
| cursor: pointer; | |
| text-align: center; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100%; | |
| padding: 2.5rem; | |
| border: 2px dashed var(--border-color); | |
| border-radius: var(--border-radius-md); | |
| transition: all 0.2s ease-in-out; | |
| } | |
| .upload-drop-zone.dragover { | |
| border-color: var(--primary-color); | |
| background-color: rgba(139, 92, 246, 0.1); | |
| } | |
| .upload-drop-zone svg { | |
| width: 48px; | |
| height: 48px; | |
| margin-bottom: 1rem; | |
| fill: var(--primary-color); | |
| } | |
| .upload-drop-zone span { | |
| font-weight: 600; | |
| font-size: 1.1rem; | |
| display: block; | |
| } | |
| .upload-drop-zone .subtext { | |
| font-size: 0.85rem; | |
| color: var(--text-muted-color); | |
| margin-top: 0.25rem; | |
| } | |
| #file-input { | |
| display: none; | |
| } | |
| .loader { | |
| border: 4px solid var(--border-color); | |
| border-top: 4px solid var(--primary-color); | |
| border-radius: 50%; | |
| width: 40px; | |
| height: 40px; | |
| animation: spin 1s linear infinite; | |
| margin: 2rem auto; | |
| display: none; | |
| } | |
| /* HISTORY SECTION */ | |
| .history-section { | |
| margin-top: 2.5rem; | |
| } | |
| .history-section h2 { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: var(--text-muted-color); | |
| text-align: center; | |
| margin-bottom: 1.5rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .history-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 1rem; | |
| justify-items: center; | |
| } | |
| .history-item { | |
| width: 90px; | |
| height: 90px; | |
| border-radius: var(--border-radius-md); | |
| background-color: rgba(255, 255, 255, 0.05); | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); | |
| overflow: hidden; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| position: relative; | |
| } | |
| .history-item img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .history-item .title { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| background: rgba(0, 0, 0, 0.6); | |
| backdrop-filter: blur(2px); | |
| color: white; | |
| font-size: 0.7rem; | |
| padding: 4px 6px; | |
| text-align: center; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| /* PLAYER SECTION */ | |
| .player-header { | |
| display: flex; | |
| align-items: center; | |
| padding: 0.8rem 1rem 0; | |
| } | |
| .player-header button { | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| opacity: 0.7; | |
| transition: opacity 0.2s; | |
| padding: 0.5rem; | |
| } | |
| .player-header button:hover { | |
| opacity: 1; | |
| } | |
| .player-header button svg { | |
| width: 24px; | |
| height: 24px; | |
| fill: var(--text-color); | |
| } | |
| .main-player { | |
| padding: 0 2rem 1.5rem; | |
| } | |
| #artwork-placeholder { | |
| width: 75%; | |
| max-width: 240px; | |
| aspect-ratio: 1 / 1; | |
| margin: 0.5rem auto 1.5rem; | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: var(--border-radius-md); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); | |
| } | |
| #artwork-placeholder img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| border-radius: var(--border-radius-md); | |
| } | |
| #artwork-placeholder svg { | |
| width: 60px; | |
| height: 60px; | |
| opacity: 0.5; | |
| fill: var(--text-color); | |
| } | |
| #current-track { | |
| text-align: center; | |
| font-size: 1.4rem; | |
| font-weight: 600; | |
| margin-bottom: 0.5rem; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .time-display { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| color: var(--text-muted-color); | |
| margin: 0.5rem 0 1.5rem; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 3px; | |
| outline: none; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| input[type="range"]:hover { | |
| height: 8px; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| background: var(--primary-color); | |
| border-radius: 50%; | |
| border: 2px solid var(--surface-color); | |
| } | |
| .playback-controls { | |
| text-align: center; | |
| margin: 1.5rem 0; | |
| } | |
| .playback-controls button { | |
| background: transparent; | |
| border: none; | |
| border-radius: 50%; | |
| width: 50px; | |
| height: 50px; | |
| display: inline-grid; | |
| place-items: center; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| vertical-align: middle; | |
| } | |
| .playback-controls button svg { | |
| fill: var(--text-color); | |
| transition: fill 0.2s; | |
| } | |
| .playback-controls button:hover:not(:disabled) { | |
| background-color: rgba(255, 255, 255, 0.05); | |
| } | |
| #play-pause-btn { | |
| width: 70px; | |
| height: 70px; | |
| background-color: var(--primary-color); | |
| margin: 0 1rem; | |
| } | |
| #play-pause-btn:hover { | |
| background-color: var(--primary-hover-color); | |
| } | |
| #play-pause-btn svg { | |
| fill: white; | |
| width: 32px; | |
| height: 32px; | |
| } | |
| #prev-btn svg, | |
| #next-btn svg { | |
| width: 28px; | |
| height: 28px; | |
| } | |
| .speed-control-group { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.8rem; | |
| margin-top: 1rem; | |
| } | |
| .speed-btn { | |
| font-size: 1.4rem; | |
| line-height: 1; | |
| font-weight: bold; | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 50%; | |
| border: 1px solid var(--border-color); | |
| background: transparent; | |
| color: var(--text-muted-color); | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .speed-btn:disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| .speed-btn:hover:not(:disabled) { | |
| background: var(--primary-color); | |
| color: white; | |
| border-color: var(--primary-color); | |
| } | |
| #speed-label { | |
| text-align: center; | |
| font-weight: 600; | |
| font-size: 1.1rem; | |
| min-width: 60px; | |
| } | |
| .playlist-section { | |
| max-height: 220px; | |
| overflow-y: auto; | |
| border-top: 1px solid var(--border-color); | |
| } | |
| #playlist { | |
| list-style: none; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| #playlist li { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 1rem 1.5rem; | |
| cursor: pointer; | |
| transition: background-color 0.2s; | |
| border-bottom: 1px solid var(--border-color); | |
| } | |
| #playlist li:last-child { | |
| border-bottom: none; | |
| } | |
| #playlist li:hover { | |
| background-color: rgba(255, 255, 255, 0.04); | |
| } | |
| #playlist li.active { | |
| background-color: rgba(139, 92, 246, 0.08); | |
| color: var(--text-color); | |
| } | |
| .playlist-track-info { | |
| display: flex; | |
| align-items: center; | |
| overflow: hidden; | |
| padding-right: 1rem; | |
| } | |
| .now-playing-icon { | |
| display: flex; | |
| gap: 2px; | |
| width: 16px; | |
| height: 16px; | |
| margin-right: 12px; | |
| align-items: flex-end; | |
| display: none; | |
| } | |
| #playlist li.active .now-playing-icon { | |
| display: flex; | |
| } | |
| @keyframes bounce { | |
| 0%, | |
| 100% { | |
| transform: scaleY(0.4); | |
| } | |
| 50% { | |
| transform: scaleY(1); | |
| } | |
| } | |
| .now-playing-icon .bar { | |
| width: 3px; | |
| height: 100%; | |
| background: var(--primary-color); | |
| animation: bounce 1.2s ease-in-out infinite; | |
| } | |
| .now-playing-icon .bar:nth-child(2) { | |
| animation-delay: -1.0s; | |
| } | |
| .now-playing-icon .bar:nth-child(3) { | |
| animation-delay: -0.8s; | |
| } | |
| .playlist-track-title { | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| font-weight: 500; | |
| } | |
| #playlist li.active .playlist-track-title { | |
| font-weight: 700; | |
| color: var(--primary-color); | |
| } | |
| .playlist-track-duration { | |
| font-size: 0.85rem; | |
| color: var(--text-muted-color); | |
| font-weight: 500; | |
| white-space: nowrap; | |
| } | |
| /* MODAL */ | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(0, 0, 0, 0.4); | |
| backdrop-filter: blur(5px); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| opacity: 0; | |
| visibility: hidden; | |
| transition: all 0.3s; | |
| z-index: 100; | |
| } | |
| .modal-overlay.visible { | |
| opacity: 1; | |
| visibility: visible; | |
| } | |
| .modal-content { | |
| background: var(--surface-color); | |
| border-radius: var(--border-radius-lg); | |
| padding: 2rem; | |
| text-align: center; | |
| max-width: 320px; | |
| transform: scale(0.9); | |
| transition: all 0.3s; | |
| } | |
| .modal-overlay.visible .modal-content { | |
| transform: scale(1); | |
| } | |
| .modal-content h3 { | |
| margin-top: 0; | |
| } | |
| .modal-content p { | |
| color: var(--text-muted-color); | |
| margin-bottom: 2rem; | |
| } | |
| .modal-buttons { | |
| display: flex; | |
| gap: 1rem; | |
| } | |
| .modal-buttons button { | |
| flex: 1; | |
| padding: 0.8rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| border-radius: var(--border-radius-md); | |
| transition: all 0.2s; | |
| } | |
| .modal-buttons .modal-secondary-btn { | |
| background: transparent; | |
| border: 1px solid var(--border-color); | |
| color: var(--text-color); | |
| } | |
| .modal-buttons .modal-secondary-btn:hover { | |
| background: rgba(255, 255, 255, 0.05); | |
| } | |
| .modal-buttons .modal-primary-btn { | |
| background: var(--primary-color); | |
| color: white; | |
| border: none; | |
| } | |
| .modal-buttons .modal-primary-btn:hover { | |
| background: var(--primary-hover-color); | |
| } | |
| @keyframes spin { | |
| 100% { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div id="notification"></div> | |
| <!-- UPLOAD VIEW --> | |
| <div class="upload-section view visible" id="upload-section"> | |
| <div class="upload-header"> | |
| <h1>Audiomax</h1> | |
| <p>Listen at your own pace.</p> | |
| </div> | |
| <label for="file-input" class="upload-drop-zone" id="upload-drop-zone"> | |
| <svg viewBox="0 0 24 24"> | |
| <path | |
| d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z" /> | |
| </svg> | |
| <span>Choose Audiobook</span> | |
| <span class="subtext">Click or drag & drop a .zip or audio file</span> | |
| </label> | |
| <input type="file" id="file-input" accept=".zip,.mp3,.wav,.ogg,.m4a,.flac"> | |
| <div class="loader" id="loader"></div> | |
| <div class="history-section" id="history-section"> | |
| <h2>Recently Played</h2> | |
| <div class="history-grid" id="history-grid"> | |
| <!-- History items will be injected here --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- PLAYER VIEW --> | |
| <div class="player-section view" id="player-section"> | |
| <div class="player-header"> | |
| <button id="back-btn" title="Back to Upload"> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="main-player"> | |
| <div id="artwork-placeholder"> | |
| <!-- Artwork img or placeholder svg will be injected here --> | |
| </div> | |
| <h2 id="current-track">Track Title</h2> | |
| <div class="progress-container"> | |
| <input type="range" id="progress-bar" value="0" step="0.1"> | |
| <div class="time-display"> | |
| <span id="current-time">0:00</span> | |
| <span id="total-duration">0:00</span> | |
| </div> | |
| </div> | |
| <div class="playback-controls"> | |
| <button id="prev-btn" title="Previous"><svg viewBox="0 0 24 24"> | |
| <path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" /> | |
| </svg></button> | |
| <button id="play-pause-btn" title="Play/Pause"></button> | |
| <button id="next-btn" title="Next"><svg viewBox="0 0 24 24"> | |
| <path d="M8 5v14l11-7zM18 6h-2v12h2z" /> | |
| </svg></button> | |
| </div> | |
| <div class="speed-control-group"> | |
| <button id="speed-down-btn" class="speed-btn" title="Decrease Speed">-</button> | |
| <span id="speed-label">1.0x</span> | |
| <button id="speed-up-btn" class="speed-btn" title="Increase Speed">+</button> | |
| </div> | |
| </div> | |
| <div class="playlist-section"> | |
| <ul id="playlist"></ul> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- RESUME MODAL --> | |
| <div class="modal-overlay" id="resume-modal"> | |
| <div class="modal-content"> | |
| <h3>Resume Playback?</h3> | |
| <p>We found a saved session. Would you like to continue from where you left off?</p> | |
| <div class="modal-buttons"> | |
| <button class="modal-secondary-btn" id="resume-no">Start Over</button> | |
| <button class="modal-primary-btn" id="resume-yes">Yes, Resume</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Libraries --> | |
| <script src="https://unpkg.com/@zip.js/zip.js/dist/zip-full.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"></script> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const dom = { | |
| fileInput: document.getElementById('file-input'), | |
| backBtn: document.getElementById('back-btn'), | |
| uploadDropZone: document.getElementById('upload-drop-zone'), | |
| uploadSection: document.getElementById('upload-section'), | |
| playerSection: document.getElementById('player-section'), | |
| loader: document.getElementById('loader'), | |
| currentTrackEl: document.getElementById('current-track'), | |
| playPauseBtn: document.getElementById('play-pause-btn'), | |
| prevBtn: document.getElementById('prev-btn'), | |
| nextBtn: document.getElementById('next-btn'), | |
| progressBar: document.getElementById('progress-bar'), | |
| currentTimeEl: document.getElementById('current-time'), | |
| totalDurationEl: document.getElementById('total-duration'), | |
| speedLabel: document.getElementById('speed-label'), | |
| speedDownBtn: document.getElementById('speed-down-btn'), | |
| speedUpBtn: document.getElementById('speed-up-btn'), | |
| playlistEl: document.getElementById('playlist'), | |
| artworkPlaceholder: document.getElementById('artwork-placeholder'), | |
| resumeModal: document.getElementById('resume-modal'), | |
| resumeYesBtn: document.getElementById('resume-yes'), | |
| resumeNoBtn: document.getElementById('resume-no'), | |
| historyGrid: document.getElementById('history-grid'), | |
| historySection: document.getElementById('history-section'), | |
| notification: document.getElementById('notification'), | |
| }; | |
| const audio = new Audio(); | |
| let playlistFiles = []; | |
| let currentTrackIndex = 0; | |
| let currentArchiveId = null; | |
| let saveInterval = null; | |
| let overallBookTitle = 'Untitled Audiobook'; | |
| let overallBookArtwork = null; | |
| const SPEED_MIN = 0.5, | |
| SPEED_MAX = 16.0, | |
| SPEED_INCREMENT = 0.1; | |
| const HISTORY_KEY = 'audiomax_history'; | |
| const MAX_HISTORY_ITEMS = 6; | |
| const playIcon = `<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; | |
| const pauseIcon = `<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`; | |
| const artworkIcon = `<svg viewBox="0 0 24 24"><path d="M6 22h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2zm6-14c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z" /></svg>`; | |
| const setupEventListeners = () => { | |
| dom.fileInput.addEventListener('change', (e) => handleFileUpload(e.target.files)); | |
| dom.backBtn.addEventListener('click', goBackToUpload); | |
| dom.playPauseBtn.addEventListener('click', togglePlayPause); | |
| dom.prevBtn.addEventListener('click', playPrevious); | |
| dom.nextBtn.addEventListener('click', playNext); | |
| dom.speedDownBtn.addEventListener('click', () => changeSpeed(-SPEED_INCREMENT)); | |
| dom.speedUpBtn.addEventListener('click', () => changeSpeed(SPEED_INCREMENT)); | |
| dom.progressBar.addEventListener('input', setSeek); | |
| audio.addEventListener('timeupdate', updateProgress); | |
| audio.addEventListener('loadedmetadata', handleTrackMetadata); | |
| audio.addEventListener('ended', playNext); | |
| audio.onplay = () => dom.playPauseBtn.innerHTML = pauseIcon; | |
| audio.onpause = () => { | |
| dom.playPauseBtn.innerHTML = playIcon; | |
| saveState(); // Save state on pause | |
| }; | |
| window.addEventListener('beforeunload', saveState); | |
| const dropZone = dom.uploadDropZone; | |
| dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('dragover'); | |
| if (e.dataTransfer.files.length) handleFileUpload(e.dataTransfer.files); | |
| }); | |
| }; | |
| const showNotification = (message, type = 'info') => { | |
| dom.notification.textContent = message; | |
| dom.notification.className = type; // 'error' or 'info' | |
| dom.notification.classList.add('show'); | |
| setTimeout(() => dom.notification.classList.remove('show'), 4000); | |
| }; | |
| async function handleFileUpload(files) { | |
| const file = files[0]; | |
| if (!file) return; | |
| const isZip = file.name.endsWith('.zip'); | |
| const isAudio = file.type.startsWith('audio/'); | |
| if (!isZip && !isAudio) { | |
| showNotification('Please upload a valid .zip or audio file.', 'error'); | |
| return; | |
| } | |
| dom.loader.style.display = 'block'; | |
| dom.uploadDropZone.style.display = 'none'; | |
| dom.historySection.style.display = 'none'; | |
| currentArchiveId = `${file.name}-${file.size}`; | |
| overallBookTitle = file.name.replace(/\.[^/.]+$/, ""); | |
| try { | |
| let extractedFiles; | |
| if (isZip) { | |
| extractedFiles = await unzipFile(file); | |
| } else { | |
| extractedFiles = [{ name: file.name, url: URL.createObjectURL(file), blob: file }]; | |
| } | |
| if (extractedFiles.length === 0) throw new Error('No supported audio files found in the archive.'); | |
| playlistFiles = await Promise.all(extractedFiles.map(processFile)); | |
| playlistFiles.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); | |
| // Try to find a common title/artwork from tags | |
| const firstFileWithTags = playlistFiles.find(f => f.tags); | |
| if (firstFileWithTags) { | |
| const tags = firstFileWithTags.tags; | |
| overallBookTitle = tags.album || overallBookTitle; | |
| overallBookArtwork = firstFileWithTags.artwork || null; | |
| } | |
| const savedState = getSavedState(); | |
| if (savedState) showResumePrompt(savedState); | |
| else startPlayer(); | |
| } catch (error) { | |
| showNotification(`Error: ${error.message}`, 'error'); | |
| resetToUploadView(); | |
| } | |
| } | |
| const showResumePrompt = (savedState) => { | |
| dom.resumeModal.classList.add('visible'); | |
| dom.resumeYesBtn.onclick = () => { dom.resumeModal.classList.remove('visible'); startPlayer(savedState); }; | |
| dom.resumeNoBtn.onclick = () => { dom.resumeModal.classList.remove('visible'); startPlayer(); localStorage.removeItem(currentArchiveId); }; | |
| }; | |
| function startPlayer(initialState = {}) { | |
| dom.uploadSection.classList.remove('visible'); | |
| dom.playerSection.classList.add('visible'); | |
| dom.loader.style.display = 'none'; | |
| buildPlaylist(); | |
| audio.playbackRate = initialState.speed || 1.0; | |
| updateSpeedUI(); | |
| loadTrack(initialState.trackIndex || 0, initialState.time || 0); | |
| updateAndSaveHistory({ | |
| id: currentArchiveId, | |
| title: overallBookTitle, | |
| artwork: overallBookArtwork | |
| }); | |
| if (saveInterval) clearInterval(saveInterval); | |
| saveInterval = setInterval(saveState, 5000); | |
| } | |
| function goBackToUpload() { | |
| saveState(); | |
| resetPlayerState(); | |
| resetToUploadView(); | |
| renderHistory(); | |
| } | |
| function resetPlayerState() { | |
| clearInterval(saveInterval); | |
| saveInterval = null; | |
| audio.pause(); | |
| audio.src = ''; | |
| playlistFiles.forEach(file => URL.revokeObjectURL(file.url)); | |
| playlistFiles = []; | |
| currentArchiveId = null; | |
| overallBookArtwork = null; | |
| overallBookTitle = 'Untitled Audiobook'; | |
| dom.playlistEl.innerHTML = ''; | |
| currentTrackIndex = 0; | |
| dom.fileInput.value = ''; // Reset file input | |
| } | |
| function resetToUploadView() { | |
| dom.playerSection.classList.remove('visible'); | |
| dom.uploadSection.classList.add('visible'); | |
| dom.loader.style.display = 'none'; | |
| dom.uploadDropZone.style.display = 'block'; | |
| dom.historySection.style.display = 'block'; | |
| } | |
| function loadTrack(index, startTime = 0) { | |
| if (index < 0 || index >= playlistFiles.length) return; | |
| currentTrackIndex = index; | |
| const track = playlistFiles[index]; | |
| const desiredSpeed = audio.playbackRate; | |
| audio.src = track.url; | |
| audio.currentTime = startTime; | |
| dom.currentTrackEl.textContent = track.title; | |
| updateArtwork(track); | |
| updatePlaylistUI(); | |
| audio.addEventListener('canplay', () => { | |
| audio.playbackRate = desiredSpeed; | |
| }, { once: true }); // The {once: true} option is important to auto-remove the listener. | |
| audio.play().catch(e => console.warn("Playback was interrupted.", e)); | |
| } | |
| function updateArtwork(track) { | |
| const artworkSrc = track.artwork || overallBookArtwork; | |
| if (artworkSrc) { | |
| dom.artworkPlaceholder.innerHTML = `<img src="${artworkSrc}" alt="Artwork for ${track.title}">`; | |
| } else { | |
| dom.artworkPlaceholder.innerHTML = artworkIcon; | |
| } | |
| } | |
| function saveState() { | |
| if (!currentArchiveId || isNaN(audio.currentTime)) return; | |
| const state = { | |
| trackIndex: currentTrackIndex, | |
| time: audio.currentTime, | |
| speed: audio.playbackRate | |
| }; | |
| try { | |
| localStorage.setItem(currentArchiveId, JSON.stringify(state)); | |
| } catch (e) { | |
| console.error("Could not save state to localStorage.", e); | |
| showNotification("Could not save progress.", "error"); | |
| } | |
| } | |
| const getSavedState = () => { | |
| try { | |
| return JSON.parse(localStorage.getItem(currentArchiveId)); | |
| } catch (e) { return null; } | |
| } | |
| function unzipFile(file) { | |
| return new Promise(async (resolve, reject) => { | |
| try { | |
| const zipReader = new zip.ZipReader(new zip.BlobReader(file)); | |
| const entries = await zipReader.getEntries(); | |
| const audioFiles = []; | |
| for (const entry of entries) { | |
| if (entry.directory || entry.filename.startsWith('__MACOSX/')) continue; | |
| if (/\.(mp3|wav|ogg|m4a|flac)$/i.test(entry.filename)) { | |
| const blob = await entry.getData(new zip.BlobWriter()); | |
| audioFiles.push({ name: entry.filename.split('/').pop(), url: URL.createObjectURL(blob), blob: blob }); | |
| } | |
| } | |
| await zipReader.close(); | |
| resolve(audioFiles); | |
| } catch (e) { reject(new Error("Could not read zip file.")); } | |
| }); | |
| } | |
| function processFile(file) { | |
| return new Promise((resolve) => { | |
| jsmediatags.read(file.blob, { | |
| onSuccess: (tag) => { | |
| file.tags = tag.tags; | |
| file.title = tag.tags.title || file.name.replace(/\.[^/.]+$/, ""); | |
| const { data, format } = tag.tags.picture || {}; | |
| if (data) { | |
| const base64String = btoa(data.reduce((acc, byte) => acc + String.fromCharCode(byte), '')); | |
| file.artwork = `data:${format};base64,${base64String}`; | |
| } | |
| resolve(file); | |
| }, | |
| onError: () => { file.title = file.name.replace(/\.[^/.]+$/, ""); resolve(file); } | |
| }); | |
| }); | |
| } | |
| function handleTrackMetadata() { | |
| const duration = audio.duration; | |
| dom.progressBar.max = duration; | |
| dom.totalDurationEl.textContent = formatTime(duration); | |
| } | |
| const togglePlayPause = () => { if (audio.src) audio.paused ? audio.play() : audio.pause(); }; | |
| const playPrevious = () => loadTrack((currentTrackIndex - 1 + playlistFiles.length) % playlistFiles.length); | |
| const playNext = () => { | |
| if (currentTrackIndex >= playlistFiles.length - 1) { | |
| showNotification("Audiobook finished!", "info"); | |
| goBackToUpload(); | |
| return; | |
| } | |
| loadTrack((currentTrackIndex + 1) % playlistFiles.length); | |
| } | |
| function changeSpeed(increment) { | |
| let newSpeed = parseFloat((audio.playbackRate + increment).toFixed(2)); | |
| newSpeed = Math.max(SPEED_MIN, Math.min(newSpeed, SPEED_MAX)); | |
| audio.playbackRate = newSpeed; | |
| updateSpeedUI(); | |
| } | |
| function updateSpeedUI() { | |
| const currentSpeed = audio.playbackRate; | |
| dom.speedLabel.textContent = `${currentSpeed.toFixed(1)}x`; | |
| dom.speedDownBtn.disabled = (currentSpeed <= SPEED_MIN); | |
| dom.speedUpBtn.disabled = (currentSpeed >= SPEED_MAX); | |
| } | |
| function updateProgress() { | |
| if (isNaN(audio.duration)) return; | |
| dom.progressBar.value = audio.currentTime; | |
| dom.currentTimeEl.textContent = formatTime(audio.currentTime); | |
| } | |
| const setSeek = () => audio.currentTime = dom.progressBar.value; | |
| function buildPlaylist() { | |
| dom.playlistEl.innerHTML = ''; | |
| playlistFiles.forEach((file, index) => { | |
| const li = document.createElement('li'); | |
| li.dataset.index = index; | |
| li.innerHTML = ` | |
| <div class="playlist-track-info"> | |
| <div class="now-playing-icon"><div class="bar"></div><div class="bar"></div><div class="bar"></div></div> | |
| <span class="playlist-track-title">${file.title}</span> | |
| </div> | |
| <span class="playlist-track-duration">--:--</span>`; | |
| li.addEventListener('click', () => { if (currentTrackIndex !== index) loadTrack(index); }); | |
| dom.playlistEl.appendChild(li); | |
| // Get duration async and update UI | |
| if (file.duration) { | |
| li.querySelector('.playlist-track-duration').textContent = formatTime(file.duration); | |
| } else { | |
| const tempAudio = new Audio(file.url); | |
| tempAudio.onloadedmetadata = () => { | |
| file.duration = tempAudio.duration; | |
| li.querySelector('.playlist-track-duration').textContent = formatTime(file.duration); | |
| }; | |
| } | |
| }); | |
| } | |
| function updatePlaylistUI() { | |
| Array.from(dom.playlistEl.children).forEach((item, index) => { | |
| item.classList.toggle('active', index === currentTrackIndex); | |
| if (index === currentTrackIndex) { | |
| item.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| }); | |
| } | |
| function formatTime(seconds) { | |
| if (isNaN(seconds) || seconds < 0) return "0:00"; | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = Math.floor(seconds % 60); | |
| const sFmt = `${s < 10 ? '0' : ''}${s}`; | |
| return h > 0 ? `${h}:${m < 10 ? '0' : ''}${m}:${sFmt}` : `${m}:${sFmt}`; | |
| } | |
| // --- HISTORY FUNCTIONS --- | |
| function getHistory() { | |
| try { | |
| return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; | |
| } catch (e) { return []; } | |
| } | |
| function updateAndSaveHistory(bookData) { | |
| let history = getHistory(); | |
| // Remove existing entry if it exists | |
| history = history.filter(item => item.id !== bookData.id); | |
| // Add new entry to the front | |
| history.unshift(bookData); | |
| // Trim history to max length | |
| history = history.slice(0, MAX_HISTORY_ITEMS); | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); | |
| } | |
| function renderHistory() { | |
| const history = getHistory(); | |
| dom.historyGrid.innerHTML = ''; | |
| if (history.length === 0) { | |
| dom.historySection.style.display = 'none'; | |
| return; | |
| } | |
| dom.historySection.style.display = 'block'; | |
| history.forEach(book => { | |
| const item = document.createElement('div'); | |
| item.className = 'history-item'; | |
| const rotation = Math.random() * 8 - 4; // -4 to +4 degrees | |
| item.style.transform = `rotate(${rotation}deg)`; | |
| item.innerHTML = ` | |
| ${book.artwork ? `<img src="${book.artwork}" alt="">` : artworkIcon} | |
| <div class="title">${book.title}</div> | |
| `; | |
| dom.historyGrid.appendChild(item); | |
| }); | |
| } | |
| // --- INITIAL SETUP --- | |
| dom.playPauseBtn.innerHTML = playIcon; | |
| dom.artworkPlaceholder.innerHTML = artworkIcon; | |
| renderHistory(); | |
| setupEventListeners(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |