const PLAYLIST_LIST_API = '/api/zen_box/playlists'; const PLAYLIST_STORAGE_KEY = 'zen_box_selected_playlist'; const RESUME_PLAYBACK_STORAGE_KEY = 'zen_box_resume_playback_enabled'; const PLAYBACK_POSITION_KEY_PREFIX = 'zen_box_playback_position'; let PLAYLIST = []; let playlistFiles = []; let currentPlaylistFile = 'playlist.json'; let cachedTrackUrls = new Set(); let currentIdx = 0; let isPlaying = false; let isSeeking = false; let repeatMode = 0; let shuffleOn = false; let shuffleOrder = []; let sheetOpen = false; let settingsOpen = false; let volumeVisible = false; let resumePlaybackEnabled = true; let lastSavedPositionSeconds = 0; const audio = document.getElementById('audio'); async function init() { resumePlaybackEnabled = getResumePlaybackEnabled(); bindSettingsEvents(); registerSW(); await loadPlaylistFiles(); await restorePlaylistSelection(); setupMediaSession(); window.addEventListener('resize', refreshMarqueeState); } async function loadPlaylistFiles() { try { const response = await fetch(PLAYLIST_LIST_API, { cache: 'no-store' }); const data = await response.json(); if (data && data.ok && Array.isArray(data.playlists) && data.playlists.length) { playlistFiles = data.playlists.map(item => String(item || '')).filter(Boolean); } else { playlistFiles = ['playlist.json']; } } catch (error) { console.warn('Failed to load playlist files:', error); playlistFiles = ['playlist.json']; } if (!playlistFiles.includes('playlist.json')) playlistFiles.unshift('playlist.json'); } async function restorePlaylistSelection() { const saved = getSavedPlaylistFile(); const target = playlistFiles.includes(saved) ? saved : 'playlist.json'; await switchPlaylist(target, { silent: true }); } function getSavedPlaylistFile() { try { return String(localStorage.getItem(PLAYLIST_STORAGE_KEY) || 'playlist.json') || 'playlist.json'; } catch { return 'playlist.json'; } } function savePlaylistFile(fileName) { try { localStorage.setItem(PLAYLIST_STORAGE_KEY, fileName); } catch {} } function getResumePlaybackEnabled() { try { const value = localStorage.getItem(RESUME_PLAYBACK_STORAGE_KEY); if (value === null) return true; return value === '1'; } catch { return true; } } function saveResumePlaybackEnabled(enabled) { resumePlaybackEnabled = !!enabled; try { localStorage.setItem(RESUME_PLAYBACK_STORAGE_KEY, enabled ? '1' : '0'); } catch {} } function getPlaybackPositionKey(trackSrc) { return `${PLAYBACK_POSITION_KEY_PREFIX}:${currentPlaylistFile}::${normalizeTrackSrc(trackSrc)}`; } function getSavedPlaybackPosition(trackSrc) { try { const value = localStorage.getItem(getPlaybackPositionKey(trackSrc)); const seconds = Number(value || 0); return Number.isFinite(seconds) && seconds > 0 ? seconds : 0; } catch { return 0; } } function clearSavedPlaybackPosition(trackSrc) { try { localStorage.removeItem(getPlaybackPositionKey(trackSrc)); } catch {} } function saveCurrentPlaybackPosition({ force = false, explicitPosition = null, explicitDuration = null } = {}) { if (!resumePlaybackEnabled) return; const track = PLAYLIST[currentIdx]; if (!track || !track.src) return; const currentTime = explicitPosition === null ? audio.currentTime : explicitPosition; const duration = explicitDuration === null ? audio.duration : explicitDuration; const safeSeconds = Math.max(0, Math.floor(Number(currentTime) || 0)); const safeDuration = Math.max(0, Math.floor(Number(duration) || 0)); const nearEnd = safeDuration > 0 && safeDuration - safeSeconds <= 30; if (safeSeconds < 5 || nearEnd) { clearSavedPlaybackPosition(track.src); lastSavedPositionSeconds = 0; return; } if (!force && Math.abs(safeSeconds - lastSavedPositionSeconds) < 15) { return; } try { localStorage.setItem(getPlaybackPositionKey(track.src), String(safeSeconds)); lastSavedPositionSeconds = safeSeconds; } catch {} } function getPlaylistRequestPath(fileName) { return encodeURIComponent(String(fileName || 'playlist.json')); } async function fetchPlaylistData(fileName) { const response = await fetch(getPlaylistRequestPath(fileName), { cache: 'no-store' }); if (!response.ok) throw new Error(`playlist_http_${response.status}`); const data = await response.json(); return Array.isArray(data) ? data : []; } async function switchPlaylist(fileName, { silent = false } = {}) { const target = String(fileName || 'playlist.json'); const fallback = playlistFiles.includes(target) ? target : 'playlist.json'; const previousFile = currentPlaylistFile; const previousSrc = PLAYLIST[currentIdx] ? normalizeTrackSrc(PLAYLIST[currentIdx].src) : ''; const wasPlaying = isPlaying; const playlistSelect = document.getElementById('playlistSelect'); if (playlistSelect) playlistSelect.disabled = true; saveCurrentPlaybackPosition({ force: true }); pauseAudio(); try { const nextPlaylist = await fetchPlaylistData(fallback); currentPlaylistFile = fallback; savePlaylistFile(fallback); PLAYLIST = nextPlaylist.map(track => ({ ...track, isCached: false })); await refreshCacheState(); const matchedIdx = previousSrc ? PLAYLIST.findIndex(track => normalizeTrackSrc(track.src) === previousSrc) : -1; currentIdx = matchedIdx >= 0 ? matchedIdx : 0; updateTrackCount(); buildShuffleOrder(); renderSettings(); renderPlaylist(); if (PLAYLIST.length > 0) { loadTrack(currentIdx, false); if (wasPlaying && matchedIdx >= 0) playAudio(); } else { resetTrackInfo(); } closeSheet(); if (!silent) showToast(`已切换歌单:${fallback}`); } catch (error) { currentPlaylistFile = previousFile || 'playlist.json'; renderSettings(); console.warn('Failed to switch playlist:', error); if (!silent) showToast('歌单切换失败'); } finally { if (playlistSelect) playlistSelect.disabled = false; } } async function refreshCacheState() { cachedTrackUrls = await collectCachedTrackUrls(); PLAYLIST = PLAYLIST.map(track => ({ ...track, isCached: cachedTrackUrls.has(normalizeTrackSrc(track.src)) })); updateCacheSummary(); } async function collectCachedTrackUrls() { const result = new Set(); if (!('caches' in window)) return result; try { const cacheNames = await caches.keys(); for (const cacheName of cacheNames) { const cache = await caches.open(cacheName); const requests = await cache.keys(); requests.forEach(request => { const url = normalizeTrackSrc(request && request.url ? request.url : ''); if (isAudioCacheUrl(url)) result.add(url); }); } } catch (error) { console.warn('Failed to inspect cache storage:', error); } return result; } function isAudioCacheUrl(url) { return !!url && (url.includes('.mp3') || url.includes('oss')); } function normalizeTrackSrc(src) { try { const url = new URL(String(src || ''), window.location.href); url.hash = ''; return url.href; } catch { return String(src || '').trim(); } } function updateTrackCount() { const countText = `${PLAYLIST.length} 首`; document.getElementById('trackCount').textContent = countText; } function setMarqueeText(host, text) { if (!host) return; const content = String(text || ''); const primary = host.querySelector('.marquee-copy-primary'); const duplicate = host.querySelector('.marquee-copy-duplicate'); if (primary) primary.textContent = content; if (duplicate) duplicate.textContent = content; } function refreshMarqueeHost(host, enabled) { if (!host) return; const primary = host.querySelector('.marquee-copy-primary'); const duplicate = host.querySelector('.marquee-copy-duplicate'); if (!primary || !duplicate) return; const overflow = primary.scrollWidth > host.clientWidth + 6; if (!enabled || !overflow) { host.classList.remove('marquee-active'); host.style.removeProperty('--marquee-distance'); host.style.removeProperty('--marquee-duration'); return; } const distance = Math.ceil(primary.scrollWidth + 42); const duration = Math.max(10, Math.round(distance / 24)); host.style.setProperty('--marquee-distance', `${distance}px`); host.style.setProperty('--marquee-duration', `${duration}s`); host.classList.add('marquee-active'); } function refreshCurrentTrackMarquee() { const host = document.getElementById('trackTitleHost'); refreshMarqueeHost(host, !!PLAYLIST[currentIdx]); } function refreshPlaylistMarquee() { document.querySelectorAll('.playlist-item .marquee-host').forEach(host => { const item = host.closest('.playlist-item'); refreshMarqueeHost(host, !!(item && item.classList.contains('active'))); }); } function refreshMarqueeState() { refreshCurrentTrackMarquee(); refreshPlaylistMarquee(); } function resetTrackInfo() { audio.removeAttribute('src'); audio.load(); setMarqueeText(document.getElementById('trackTitleHost'), '尚未选择曲目'); document.getElementById('trackArtist').textContent = currentPlaylistFile || '—'; document.getElementById('timeCurrent').textContent = '0:00'; document.getElementById('timeTotal').textContent = '0:00'; document.getElementById('progressBar').value = 0; updateSliderFill(document.getElementById('progressBar'), 0); updatePlaylistHighlight(); refreshCurrentTrackMarquee(); } function renderSettings() { const select = document.getElementById('playlistSelect'); if (select) { select.innerHTML = ''; playlistFiles.forEach(file => { const option = document.createElement('option'); option.value = file; option.textContent = file; if (file === currentPlaylistFile) option.selected = true; select.appendChild(option); }); select.value = currentPlaylistFile; } const resumeToggle = document.getElementById('resumePlaybackToggle'); if (resumeToggle) { resumeToggle.checked = !!resumePlaybackEnabled; } updateCacheSummary(); } function updateCacheSummary() { const summary = document.getElementById('cacheSummary'); if (!summary) return; const cachedCount = PLAYLIST.filter(track => !!track.isCached).length; summary.textContent = PLAYLIST.length ? `已缓存 ${cachedCount} / ${PLAYLIST.length} 首` : '当前歌单暂无曲目'; } function bindSettingsEvents() { const panel = document.getElementById('settingsPanel'); if (panel) { panel.addEventListener('click', event => event.stopPropagation()); } const select = document.getElementById('playlistSelect'); if (select) { select.addEventListener('change', async event => { const nextFile = String(event.target && event.target.value || 'playlist.json'); await switchPlaylist(nextFile); }); } const resumeToggle = document.getElementById('resumePlaybackToggle'); if (resumeToggle) { resumeToggle.addEventListener('change', event => { const enabled = !!(event.target && event.target.checked); saveResumePlaybackEnabled(enabled); renderSettings(); showToast(enabled ? '已开启断点续播' : '已关闭断点续播'); }); } } function openSettings() { settingsOpen = true; renderSettings(); document.getElementById('settingsOverlay').classList.add('open'); document.getElementById('settingsPanel').classList.add('open'); } function closeSettings() { settingsOpen = false; document.getElementById('settingsOverlay').classList.remove('open'); document.getElementById('settingsPanel').classList.remove('open'); } function loadTrack(idx, autoplay) { if (PLAYLIST[currentIdx] && audio.src) { saveCurrentPlaybackPosition({ force: true }); } currentIdx = idx; const track = PLAYLIST[idx]; if (!track) return; const savedPosition = resumePlaybackEnabled ? getSavedPlaybackPosition(track.src) : 0; audio.src = track.src; audio.dataset.resumePosition = savedPosition > 5 ? String(savedPosition) : '0'; audio.load(); setMarqueeText(document.getElementById('trackTitleHost'), track.title || '未知曲目'); document.getElementById('trackArtist').textContent = track.artist || '未知歌手'; document.getElementById('timeCurrent').textContent = '0:00'; document.getElementById('timeTotal').textContent = '0:00'; document.getElementById('progressBar').value = 0; updateSliderFill(document.getElementById('progressBar'), 0); updatePlaylistHighlight(); updateMediaSessionMetadata(); refreshCurrentTrackMarquee(); lastSavedPositionSeconds = savedPosition > 5 ? Math.floor(savedPosition) : 0; if (autoplay) playAudio(); } function togglePlay() { if (!PLAYLIST.length) { showToast('播放列表为空'); return; } if (!audio.src || audio.src === location.href) { loadTrack(0, true); return; } isPlaying ? pauseAudio() : playAudio(); } function playAudio() { audio.play().then(() => { isPlaying = true; updatePlayUI(true); }).catch(error => { console.warn('Play failed', error); showToast('播放失败,请检查音频地址'); }); } function pauseAudio() { audio.pause(); isPlaying = false; updatePlayUI(false); } function updatePlayUI(playing) { document.getElementById('iconPlay').style.display = playing ? 'none' : 'block'; document.getElementById('iconPause').style.display = playing ? 'block' : 'none'; const glow = document.getElementById('zenGlow'); glow.classList.toggle('active', playing); for (let i = 0; i < 3; i += 1) { document.getElementById(`zenWave${i}`).classList.toggle('active', playing); } if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = playing ? 'playing' : 'paused'; } updatePlaylistHighlight(); } function nextTrack() { if (!PLAYLIST.length) return; if (repeatMode === 1) { audio.currentTime = 0; if (isPlaying) playAudio(); return; } let next; if (shuffleOn) { const pos = shuffleOrder.indexOf(currentIdx); next = shuffleOrder[(pos + 1) % shuffleOrder.length]; } else { next = (currentIdx + 1) % PLAYLIST.length; } loadTrack(next, isPlaying); } function prevTrack() { if (!PLAYLIST.length) return; if (audio.currentTime > 3) { audio.currentTime = 0; return; } let prev; if (shuffleOn) { const pos = shuffleOrder.indexOf(currentIdx); prev = shuffleOrder[(pos - 1 + shuffleOrder.length) % shuffleOrder.length]; } else { prev = (currentIdx - 1 + PLAYLIST.length) % PLAYLIST.length; } loadTrack(prev, isPlaying); } function buildShuffleOrder() { shuffleOrder = [...Array(PLAYLIST.length).keys()]; for (let i = shuffleOrder.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]; } } function toggleShuffle() { shuffleOn = !shuffleOn; if (shuffleOn) buildShuffleOrder(); document.getElementById('btnShuffle').classList.toggle('active-mode', shuffleOn); showToast(shuffleOn ? '随机播放 开' : '随机播放 关'); } const repeatLabels = ['顺序播放', '单曲循环', '列表循环']; function cycleRepeat() { repeatMode = (repeatMode + 1) % 3; const btn = document.getElementById('btnRepeat'); btn.classList.toggle('active-mode', repeatMode > 0); btn.innerHTML = repeatMode === 1 ? '1' : ''; showToast(repeatLabels[repeatMode]); } function toggleVolume() { volumeVisible = !volumeVisible; showToast(volumeVisible ? '请用 iPhone 侧面音量键调节' : ''); } function onProgressInput(val) { isSeeking = true; updateSliderFill(document.getElementById('progressBar'), val); } function onProgressChange(val) { if (audio.duration) audio.currentTime = (val / 100) * audio.duration; isSeeking = false; saveCurrentPlaybackPosition({ force: true }); } function updateSliderFill(el, pct) { el.style.background = `linear-gradient(to right, #e8b86d 0%, #e8b86d ${pct}%, rgba(255,255,255,0.10) ${pct}%, rgba(255,255,255,0.10) 100%)`; } function fmtTime(seconds) { if (isNaN(seconds) || seconds < 0) return '0:00'; const minutes = Math.floor(seconds / 60); const remain = Math.floor(seconds % 60); return `${minutes}:${String(remain).padStart(2, '0')}`; } audio.addEventListener('timeupdate', () => { if (isSeeking) return; const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0; document.getElementById('progressBar').value = pct; updateSliderFill(document.getElementById('progressBar'), pct); document.getElementById('timeCurrent').textContent = fmtTime(audio.currentTime); if ('mediaSession' in navigator && audio.duration) { try { navigator.mediaSession.setPositionState({ duration: audio.duration, playbackRate: audio.playbackRate, position: audio.currentTime }); } catch {} } if (isPlaying) { saveCurrentPlaybackPosition(); } }); audio.addEventListener('loadedmetadata', () => { const resumeSeconds = Number(audio.dataset.resumePosition || '0'); if (resumePlaybackEnabled && resumeSeconds > 5 && audio.duration && resumeSeconds < Math.max(audio.duration - 30, 5)) { audio.currentTime = resumeSeconds; lastSavedPositionSeconds = Math.floor(resumeSeconds); } else { lastSavedPositionSeconds = 0; } audio.dataset.resumePosition = '0'; document.getElementById('timeTotal').textContent = fmtTime(audio.duration); }); audio.addEventListener('ended', () => { const track = PLAYLIST[currentIdx]; if (track && track.src) { clearSavedPlaybackPosition(track.src); } lastSavedPositionSeconds = 0; isPlaying = false; updatePlayUI(false); if (repeatMode === 1) { audio.currentTime = 0; playAudio(); } else { const isLast = !shuffleOn && currentIdx === PLAYLIST.length - 1; if (isLast && repeatMode === 0) return; nextTrack(); playAudio(); } }); audio.addEventListener('play', () => { isPlaying = true; updatePlayUI(true); }); audio.addEventListener('pause', () => { isPlaying = false; updatePlayUI(false); saveCurrentPlaybackPosition({ force: true }); }); audio.addEventListener('error', () => { isPlaying = false; updatePlayUI(false); const err = audio.error; let msg = '音频加载失败'; if (err) { switch (err.code) { case MediaError.MEDIA_ERR_ABORTED: msg = '播放被中止'; break; case MediaError.MEDIA_ERR_NETWORK: msg = '网络错误,无法加载音频'; break; case MediaError.MEDIA_ERR_DECODE: msg = '音频解码失败'; break; case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: msg = '音频地址无效或格式不支持'; break; default: break; } } console.warn('Audio error:', err, audio.src); showToast(msg); }); function renderPlaylist() { const wrap = document.getElementById('sheetList'); const empty = document.getElementById('emptyState'); if (!PLAYLIST.length) { empty.style.display = 'block'; wrap.querySelectorAll('.playlist-item').forEach(el => el.remove()); return; } empty.style.display = 'none'; wrap.querySelectorAll('.playlist-item').forEach(el => el.remove()); PLAYLIST.forEach((track, index) => { const div = document.createElement('div'); div.className = `playlist-item${index === currentIdx ? ' active' : ''}`; div.dataset.idx = index; div.innerHTML = `
${index + 1}
${escHtml(track.title || '未知曲目')}
${escHtml(track.artist || '未知歌手')}
${track.isCached ? '已缓存' : '未缓存'}
`; div.addEventListener('click', () => { if (index === currentIdx) { togglePlay(); } else { loadTrack(index, true); closeSheet(); } }); wrap.appendChild(div); }); refreshPlaylistMarquee(); } function updatePlaylistHighlight() { document.querySelectorAll('.playlist-item').forEach((el, index) => { el.classList.toggle('active', index === currentIdx); const num = document.getElementById(`itemNum${index}`); if (num) { num.textContent = (index === currentIdx && isPlaying) ? '▶' : (index + 1); num.className = `item-num${index === currentIdx ? ' playing-icon' : ''}`; } }); refreshPlaylistMarquee(); } function openSheet() { sheetOpen = true; document.getElementById('sheetOverlay').classList.add('open'); const sheet = document.getElementById('sheet'); sheet.classList.add('open'); sheet.onclick = event => event.stopPropagation(); updatePlaylistHighlight(); } function closeSheet() { sheetOpen = false; document.getElementById('sheetOverlay').classList.remove('open'); document.getElementById('sheet').classList.remove('open'); } function setupMediaSession() { if (!('mediaSession' in navigator)) return; navigator.mediaSession.setActionHandler('play', () => playAudio()); navigator.mediaSession.setActionHandler('pause', () => pauseAudio()); navigator.mediaSession.setActionHandler('nexttrack', () => nextTrack()); navigator.mediaSession.setActionHandler('previoustrack', () => prevTrack()); navigator.mediaSession.setActionHandler('seekto', event => { if (event.seekTime !== undefined && audio.duration) { audio.currentTime = event.seekTime; } }); } function updateMediaSessionMetadata() { if (!('mediaSession' in navigator)) return; const track = PLAYLIST[currentIdx]; if (!track) return; navigator.mediaSession.metadata = new MediaMetadata({ title: track.title || '未知曲目', artist: track.artist || '未知歌手', album: '八音盒', artwork: track.cover ? [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }] : [] }); } function registerSW() { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').catch(() => {}); } } document.addEventListener('visibilitychange', () => { if (document.hidden) { saveCurrentPlaybackPosition({ force: true }); } }); window.addEventListener('beforeunload', () => { saveCurrentPlaybackPosition({ force: true }); }); function escHtml(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } let toastTimer; function showToast(msg) { const el = document.getElementById('toast'); if (!msg) { el.classList.remove('show'); el.textContent = ''; return; } el.textContent = msg; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 1800); } init(); setTimeout(() => { updateSliderFill(document.getElementById('progressBar'), 0); }, 50);