const PLAYLIST_LIST_API = '/api/zen_box/playlists'; const LOGIN_API = '/api/zen_box/auth/login'; const CONFIG_API = '/api/zen_box/config'; const AUDIO_CACHE_NAME = 'zen-audio-cache'; 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'; const SORT_PREF_STORAGE_KEY = 'zen_box_sort_pref'; const CACHE_ONLY_STORAGE_KEY = 'zen_box_cache_only'; const LOGGED_IN_USER_KEY = 'zen_box_logged_in_user'; const PENDING_CONFIG_KEY = 'zen_box_pending_config'; const SNAPSHOT_KEY_PREFIX = 'zen_box_playlist_snapshot'; const APP_NAME = '观空禅音'; const INITIAL_TITLE = '未选择歌曲'; 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; /* volumeVisible removed — RN 端无此功能 */ let resumePlaybackEnabled = true; let lastSavedPositionSeconds = 0; let sortMode = 'original'; let displayOrder = []; let cacheOnly = true; let loggedInUser = ''; let isOnline = true; let downloadingUrls = new Set(); let batchDownloadActive = false; let offlineAvailableFiles = null; let lastCloudPushAt = 0; let cloudSyncTimer = null; const audio = document.getElementById('audio'); async function init() { resumePlaybackEnabled = getResumePlaybackEnabled(); cacheOnly = getCacheOnlyEnabled(); sortMode = getSavedSortMode(); loggedInUser = getLoggedInUser(); isOnline = navigator.onLine !== false; bindSettingsEvents(); bindOnlineEvents(); registerSW(); await loadPlaylistFiles(); await restorePlaylistSelection(); setupMediaSession(); renderAccountSection(); updateOfflineBadge(); updateSortButtons(); 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 getCacheOnlyEnabled() { try { const value = localStorage.getItem(CACHE_ONLY_STORAGE_KEY); if (value === null) return true; return value === '1'; } catch { return true; } } function saveCacheOnlyEnabled(enabled) { cacheOnly = !!enabled; try { localStorage.setItem(CACHE_ONLY_STORAGE_KEY, enabled ? '1' : '0'); } catch {} } function getSavedSortMode() { try { const value = String(localStorage.getItem(SORT_PREF_STORAGE_KEY) || 'original'); return ['original', 'asc', 'desc'].includes(value) ? value : 'original'; } catch { return 'original'; } } function saveSortMode(mode) { try { localStorage.setItem(SORT_PREF_STORAGE_KEY, mode); } catch {} } function getLoggedInUser() { try { return String(localStorage.getItem(LOGGED_IN_USER_KEY) || ''); } catch { return ''; } } 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; scheduleCloudSync(); } 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 : []; } function savePlaylistSnapshot(fileName, tracks) { try { localStorage.setItem(`${SNAPSHOT_KEY_PREFIX}:${fileName}`, JSON.stringify(tracks)); } catch {} } function readPlaylistSnapshot(fileName) { try { const raw = localStorage.getItem(`${SNAPSHOT_KEY_PREFIX}:${fileName}`); if (!raw) return null; const data = JSON.parse(raw); return Array.isArray(data) && data.length ? data : null; } catch { return null; } } async function loadPlaylistWithFallback(fileName) { try { const tracks = await fetchPlaylistData(fileName); savePlaylistSnapshot(fileName, tracks); return { tracks, fromSnapshot: false }; } catch (error) { const snapshot = readPlaylistSnapshot(fileName); if (snapshot) return { tracks: snapshot, fromSnapshot: true }; throw error; } } 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 { tracks: nextPlaylistRaw, fromSnapshot } = await loadPlaylistWithFallback(fallback); currentPlaylistFile = fallback; savePlaylistFile(fallback); PLAYLIST = nextPlaylistRaw.map(track => ({ ...track, isCached: false })); await refreshCacheState(); const matchedIdx = previousSrc ? PLAYLIST.findIndex(track => normalizeTrackSrc(track.src) === previousSrc) : -1; currentIdx = matchedIdx >= 0 ? matchedIdx : 0; updateTrackCount(); rebuildDisplayOrder(); buildShuffleOrder(); renderSettings(); renderPlaylist(); if (PLAYLIST.length > 0) { const loaded = loadTrack(currentIdx, false); if (loaded !== false && wasPlaying && matchedIdx >= 0) playAudio(); } else { resetTrackInfo(); } closeSheet(); if (fromSnapshot) { showToast('远程歌单加载失败,已切换到本地快照'); } else if (!silent) { showToast(`已切换歌单:${fallback}`); } scheduleCloudSync(); } 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'), INITIAL_TITLE); 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 = ''; let files = playlistFiles; if (!isOnline && Array.isArray(offlineAvailableFiles)) { files = playlistFiles.filter(file => offlineAvailableFiles.includes(file)); if (!files.includes(currentPlaylistFile)) files = [currentPlaylistFile, ...files]; } files.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; } const cacheOnlyToggle = document.getElementById('cacheOnlyToggle'); if (cacheOnlyToggle) { cacheOnlyToggle.checked = !!cacheOnly; } renderAccountSection(); updateCacheSummary(); refreshCacheSize(); } 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 ? '已开启断点续播' : '已关闭断点续播'); scheduleCloudSync(); }); } const cacheOnlyToggle = document.getElementById('cacheOnlyToggle'); if (cacheOnlyToggle) { cacheOnlyToggle.addEventListener('change', event => { const enabled = !!(event.target && event.target.checked); saveCacheOnlyEnabled(enabled); showToast(enabled ? '已开启只用缓存听歌' : '已关闭只用缓存听歌'); scheduleCloudSync(); }); } const loginOverlay = document.getElementById('loginOverlay'); if (loginOverlay) { loginOverlay.addEventListener('click', event => { if (event.target === loginOverlay) closeLoginOverlay(); }); } const loginPassword = document.getElementById('loginPassword'); if (loginPassword) { loginPassword.addEventListener('keydown', event => { if (event.key === 'Enter') doLogin(); }); } } 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) { const track = PLAYLIST[idx]; if (!track) return false; if (cacheOnly && !track.isCached) { showToast('歌曲未缓存,且当前设置为只用缓存听歌曲。请先在播放列表页面下载此歌曲。'); return false; } if (PLAYLIST[currentIdx] && audio.src) { saveCurrentPlaybackPosition({ force: true }); } currentIdx = idx; 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(); return true; } 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 findCachedTrackFrom(startIdx, dir) { const total = PLAYLIST.length; if (!total) return -1; for (let step = 0; step < total; step += 1) { const idx = dir >= 0 ? (startIdx + step) % total : (startIdx - step + total) % total; if (PLAYLIST[idx] && PLAYLIST[idx].isCached) return idx; } return -1; } 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; } if (cacheOnly) { const cachedNext = findCachedTrackFrom(next, 1); if (cachedNext < 0) { showToast('没有已缓存的歌曲,请先在播放列表下载'); return; } next = cachedNext; } 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; } if (cacheOnly) { const cachedPrev = findCachedTrackFrom(prev, -1); if (cachedPrev < 0) { showToast('没有已缓存的歌曲,请先在播放列表下载'); return; } prev = cachedPrev; } 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 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 rebuildDisplayOrder() { const indices = PLAYLIST.map((_, idx) => idx); if (sortMode === 'asc') { indices.sort((a, b) => String(PLAYLIST[a].title || '').localeCompare(String(PLAYLIST[b].title || ''), 'zh-CN')); } else if (sortMode === 'desc') { indices.sort((a, b) => String(PLAYLIST[b].title || '').localeCompare(String(PLAYLIST[a].title || ''), 'zh-CN')); } displayOrder = indices; } function applySort(mode, { fromCloud = false } = {}) { const nextMode = ['original', 'asc', 'desc'].includes(mode) ? mode : 'original'; const changed = nextMode !== sortMode; sortMode = nextMode; saveSortMode(sortMode); rebuildDisplayOrder(); buildShuffleOrder(); updateSortButtons(); renderPlaylist(); if (changed && !fromCloud) { const labels = { original: '原始顺序', asc: '名称正序', desc: '名称倒序' }; showToast(`排序:${labels[sortMode]}`); scheduleCloudSync(); } } function updateSortButtons() { ['original', 'asc', 'desc'].forEach(mode => { const btn = document.getElementById(`sort${mode.charAt(0).toUpperCase()}${mode.slice(1)}`); if (btn) btn.classList.toggle('active', sortMode === mode); }); } /* ========== 下载与缓存 ========== */ function dlButtonHtml(track) { const key = normalizeTrackSrc(track.src); if (downloadingUrls.has(key)) { return ``; } if (track.isCached) { return ``; } return ``; } function updateDownloadButtons(key) { document.querySelectorAll('.playlist-item').forEach(el => { const idx = Number(el.dataset.idx); const track = PLAYLIST[idx]; if (!track || normalizeTrackSrc(track.src) !== key) return; const btn = el.querySelector('.dl-btn'); if (!btn) return; const wrapper = document.createElement('span'); wrapper.innerHTML = dlButtonHtml(track); btn.replaceWith(wrapper.firstChild); }); } function markTrackCached(src) { const key = normalizeTrackSrc(src); PLAYLIST = PLAYLIST.map(track => (normalizeTrackSrc(track.src) === key ? { ...track, isCached: true } : track)); updateDownloadButtons(key); } async function downloadTrack(src, { silent = false } = {}) { const key = normalizeTrackSrc(src); if (cachedTrackUrls.has(key)) return true; if (downloadingUrls.has(key)) return false; if (!('caches' in window)) { if (!silent) showToast('当前浏览器不支持缓存下载'); return false; } downloadingUrls.add(key); updateDownloadButtons(key); try { const response = await fetch(src, { cache: 'no-store' }); if (!response.ok) throw new Error(`http_${response.status}`); const blob = await response.blob(); if (!blob || !blob.size) throw new Error('empty_blob'); const cache = await caches.open(AUDIO_CACHE_NAME); await cache.put(new Request(key), new Response(blob)); cachedTrackUrls.add(key); markTrackCached(src); updateCacheSummary(); refreshCacheSize(); if (!isOnline) refreshOfflineAvailability().then(() => { if (settingsOpen) renderSettings(); }); if (!silent) showToast('下载完成'); return true; } catch (error) { console.warn('Download failed:', src, error); if (!silent) showToast('下载失败,请稍后重试'); return false; } finally { downloadingUrls.delete(key); updateDownloadButtons(key); } } async function downloadAllTracks() { if (!PLAYLIST.length) { showToast('播放列表为空'); return; } if (batchDownloadActive) return; const pending = PLAYLIST.filter(track => !cachedTrackUrls.has(normalizeTrackSrc(track.src))); if (!pending.length) { showToast('所有歌曲均已缓存'); return; } batchDownloadActive = true; const btn = document.getElementById('btnDownloadAll'); const btnText = document.getElementById('btnDownloadAllText'); const progressWrap = document.getElementById('batchProgress'); const progressTitle = document.getElementById('batchProgressTitle'); const progressFill = document.getElementById('batchProgressFill'); const progressPct = document.getElementById('batchProgressPct'); const progressCount = document.getElementById('batchProgressCount'); if (btn) btn.disabled = true; if (btnText) btnText.textContent = '下载中…'; if (progressWrap) progressWrap.style.display = 'block'; const total = pending.length; let done = 0; let okCount = 0; let failCount = 0; for (const track of pending) { if (progressTitle) progressTitle.textContent = track.title || '未知曲目'; const success = await downloadTrack(track.src, { silent: true }); if (success) okCount += 1; else failCount += 1; done += 1; const pct = Math.round((done / total) * 100); if (progressFill) progressFill.style.width = `${pct}%`; if (progressPct) progressPct.textContent = `${pct}%`; if (progressCount) progressCount.textContent = `${done}/${total}`; } batchDownloadActive = false; if (btn) btn.disabled = false; if (btnText) btnText.textContent = '一键下载所有'; setTimeout(() => { if (!batchDownloadActive && progressWrap) progressWrap.style.display = 'none'; if (progressFill) progressFill.style.width = '0'; }, 1600); showToast(`批量下载完成:成功 ${okCount} 首${failCount ? `,失败 ${failCount} 首` : ''}`); } async function computeCacheSize() { if (!('caches' in window)) return 0; try { const cache = await caches.open(AUDIO_CACHE_NAME); const requests = await cache.keys(); let total = 0; for (const request of requests) { const response = await cache.match(request); if (!response) continue; const declared = Number(response.headers.get('content-length') || 0); if (Number.isFinite(declared) && declared > 0) { total += declared; continue; } try { const blob = await response.blob(); total += blob ? blob.size : 0; } catch {} } return total; } catch (error) { console.warn('Failed to compute cache size:', error); return 0; } } function formatSize(bytes) { const safe = Number(bytes) || 0; if (safe <= 0) return '0 MB'; const mb = safe / 1024 / 1024; if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`; if (mb >= 1) return `${mb.toFixed(1)} MB`; return `${Math.max(1, Math.round(safe / 1024))} KB`; } async function refreshCacheSize() { const el = document.getElementById('cacheSizeText'); if (!el) return; el.textContent = '正在统计...'; const size = await computeCacheSize(); el.textContent = formatSize(size); } async function clearAllCache() { if (!window.confirm('确定清除所有已缓存的歌曲?清除后离线将无可播放内容。')) return; const btn = document.getElementById('btnClearCache'); const oldText = btn ? btn.textContent : ''; if (btn) { btn.disabled = true; btn.textContent = '清除中…'; } try { if ('caches' in window) await caches.delete(AUDIO_CACHE_NAME); cachedTrackUrls = new Set(); PLAYLIST = PLAYLIST.map(track => ({ ...track, isCached: false })); renderPlaylist(); updateCacheSummary(); refreshCacheSize(); if (!isOnline) refreshOfflineAvailability().then(() => { if (settingsOpen) renderSettings(); }); showToast('已清除全部缓存'); } catch (error) { console.warn('Failed to clear cache:', error); showToast('清除失败,请稍后重试'); } finally { if (btn) { btn.disabled = false; btn.textContent = oldText; } } } /* ========== 登录与云端同步 ========== */ function openLoginOverlay() { const overlay = document.getElementById('loginOverlay'); if (!overlay) return; document.getElementById('loginError').textContent = ''; overlay.classList.add('open'); setTimeout(() => { const input = document.getElementById('loginUsername'); if (input) input.focus(); }, 250); } function closeLoginOverlay() { const overlay = document.getElementById('loginOverlay'); if (overlay) overlay.classList.remove('open'); } async function doLogin() { const usernameInput = document.getElementById('loginUsername'); const passwordInput = document.getElementById('loginPassword'); const errorEl = document.getElementById('loginError'); const btn = document.getElementById('btnLogin'); const username = String(usernameInput.value || '').trim(); const password = String(passwordInput.value || ''); if (errorEl) errorEl.textContent = ''; if (!username || !password) { if (errorEl) errorEl.textContent = '请输入账号和密码'; return; } const oldText = btn.textContent; btn.disabled = true; btn.textContent = '登录中…'; try { const response = await fetch(LOGIN_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json().catch(() => ({})); if (!response.ok || !data.ok) throw new Error(data.error || `登录失败(${response.status})`); loggedInUser = username; try { localStorage.setItem(LOGGED_IN_USER_KEY, username); } catch {} if (passwordInput) passwordInput.value = ''; closeLoginOverlay(); renderAccountSection(); showToast('登录成功'); await pullCloudConfig(); } catch (error) { console.warn('Login failed:', error); if (errorEl) errorEl.textContent = error.message || '登录失败'; } finally { btn.disabled = false; btn.textContent = oldText; } } function doLogout() { if (!window.confirm('确定退出登录?退出后将不再同步云端配置。')) return; loggedInUser = ''; try { localStorage.removeItem(LOGGED_IN_USER_KEY); localStorage.removeItem(PENDING_CONFIG_KEY); } catch {} renderAccountSection(); showToast('已退出登录'); } function renderAccountSection() { const wrap = document.getElementById('accountSection'); if (!wrap) return; if (loggedInUser) { wrap.innerHTML = `
${escHtml(loggedInUser)}
已登录,歌单、排序、续播进度等配置将自动同步到云端。
`; } else { wrap.innerHTML = `
登录后可跨设备同步歌单选择、排序偏好、缓存模式与续播进度。
`; } } function collectPlaybackPositions() { const result = {}; try { const prefix = `${PLAYBACK_POSITION_KEY_PREFIX}:`; for (let i = 0; i < localStorage.length; i += 1) { const key = localStorage.key(i); if (!key || !key.startsWith(prefix)) continue; const suffix = key.slice(prefix.length); if (!suffix.includes('::')) continue; const seconds = Number(localStorage.getItem(key) || 0); if (Number.isFinite(seconds) && seconds > 0) result[suffix] = seconds; } } catch {} return result; } function buildUserConfig() { return { selected_playlist: currentPlaylistFile, resume_enabled: !!resumePlaybackEnabled, cache_only: !!cacheOnly, sort_mode: sortMode, playback_positions: collectPlaybackPositions() }; } function scheduleCloudSync() { if (!loggedInUser) return; clearTimeout(cloudSyncTimer); cloudSyncTimer = setTimeout(() => pushCloudConfig(), 1500); } async function pushCloudConfig({ force = false } = {}) { if (!loggedInUser) return; const config = buildUserConfig(); if (!isOnline) { try { localStorage.setItem(PENDING_CONFIG_KEY, JSON.stringify(config)); } catch {} return; } if (!force && Date.now() - lastCloudPushAt < 15000) { clearTimeout(cloudSyncTimer); cloudSyncTimer = setTimeout(() => pushCloudConfig({ force: true }), 15000); return; } try { const response = await fetch(CONFIG_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: loggedInUser, config }) }); const data = await response.json().catch(() => ({})); if (!response.ok || !data.ok) throw new Error(data.error || `sync_http_${response.status}`); lastCloudPushAt = Date.now(); try { localStorage.removeItem(PENDING_CONFIG_KEY); } catch {} } catch (error) { console.warn('Cloud config push failed:', error); try { localStorage.setItem(PENDING_CONFIG_KEY, JSON.stringify(config)); } catch {} } } async function flushPendingConfig() { if (!loggedInUser || !isOnline) return; try { const pending = localStorage.getItem(PENDING_CONFIG_KEY); if (pending) await pushCloudConfig({ force: true }); } catch {} } async function pullCloudConfig() { if (!loggedInUser) return; try { const response = await fetch(`${CONFIG_API}?username=${encodeURIComponent(loggedInUser)}`, { cache: 'no-store' }); const data = await response.json().catch(() => ({})); if (!response.ok || !data.ok) throw new Error(data.error || `pull_http_${response.status}`); if (!data.config) { showToast('云端暂无配置,将以本机配置为准'); await pushCloudConfig({ force: true }); return; } await applyCloudConfig(data.config); showToast('已同步云端配置'); } catch (error) { console.warn('Cloud config pull failed:', error); showToast('云端配置拉取失败'); } } async function applyCloudConfig(config) { try { if (config.resume_enabled !== undefined) { saveResumePlaybackEnabled(!!config.resume_enabled); } if (config.cache_only !== undefined) { saveCacheOnlyEnabled(!!config.cache_only); } if (config.sort_mode) { applySort(String(config.sort_mode), { fromCloud: true }); } if (config.playback_positions && typeof config.playback_positions === 'object') { for (const [key, seconds] of Object.entries(config.playback_positions)) { const parts = String(key).split('::'); const value = Number(seconds); if (parts.length >= 2 && Number.isFinite(value) && value > 0) { try { localStorage.setItem(`${PLAYBACK_POSITION_KEY_PREFIX}:${key}`, String(Math.floor(value))); } catch {} } } } renderSettings(); if (config.selected_playlist && playlistFiles.includes(config.selected_playlist) && config.selected_playlist !== currentPlaylistFile) { await switchPlaylist(config.selected_playlist, { silent: true }); } } catch (error) { console.warn('Apply cloud config failed:', error); } } /* ========== 离线模式 ========== */ function bindOnlineEvents() { window.addEventListener('online', () => { isOnline = true; offlineAvailableFiles = null; updateOfflineBadge(); renderSettings(); flushPendingConfig(); showToast('网络已恢复'); }); window.addEventListener('offline', () => { isOnline = false; updateOfflineBadge(); refreshOfflineAvailability().then(() => renderSettings()); showToast('网络已断开,进入离线模式'); }); } function updateOfflineBadge() { const badge = document.getElementById('offlineBadge'); if (badge) badge.style.display = isOnline ? 'none' : 'inline-flex'; } async function refreshOfflineAvailability() { if (isOnline) { offlineAvailableFiles = null; return; } const available = []; for (const file of playlistFiles) { try { const tracks = await fetchPlaylistData(file); const hasCached = tracks.some(track => cachedTrackUrls.has(normalizeTrackSrc(track.src))); if (hasCached) available.push(file); } catch { // 离线且无 SW 缓存的歌单,直接跳过 } } offlineAvailableFiles = available; } /* ========== 播放列表渲染 ========== */ function renderPlaylist() { const wrap = document.getElementById('sheetList'); const empty = document.getElementById('emptyState'); wrap.querySelectorAll('.playlist-item').forEach(el => el.remove()); if (!PLAYLIST.length) { empty.style.display = 'block'; return; } empty.style.display = 'none'; displayOrder.forEach((originalIdx, displayPos) => { const track = PLAYLIST[originalIdx]; const div = document.createElement('div'); div.className = `playlist-item${originalIdx === currentIdx ? ' active' : ''}`; div.dataset.idx = originalIdx; div.dataset.pos = displayPos; div.innerHTML = `
${displayPos + 1}
${escHtml(track.title || '未知曲目')}
${escHtml(track.artist || '未知歌手')}
${dlButtonHtml(track)} `; div.addEventListener('click', event => { const btn = event.target.closest('.dl-btn'); if (btn) { if (!btn.disabled && !track.isCached) downloadTrack(track.src); return; } if (originalIdx === currentIdx) { togglePlay(); } else { const loaded = loadTrack(originalIdx, true); if (loaded !== false) closeSheet(); } }); wrap.appendChild(div); }); updatePlaylistHighlight(); refreshPlaylistMarquee(); } function updatePlaylistHighlight() { document.querySelectorAll('.playlist-item').forEach(el => { const idx = Number(el.dataset.idx); const pos = Number(el.dataset.pos); const isActive = idx === currentIdx; el.classList.toggle('active', isActive); const num = el.querySelector('.item-num'); if (!num) return; if (isActive && isPlaying) { num.innerHTML = ''; num.classList.add('playing-icon'); } else if (isActive) { num.textContent = '▶'; num.classList.add('playing-icon'); } else { num.textContent = String(pos + 1); num.classList.remove('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: APP_NAME, artwork: track.cover ? [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }] : [] }); } function registerSW() { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').catch(() => {}); } } /* ========== 启动屏 ========== */ function hideSplash() { const splash = document.getElementById('splash'); if (!splash) return; splash.classList.add('hide'); setTimeout(() => { splash.style.display = 'none'; }, 700); } document.addEventListener('visibilitychange', () => { if (document.hidden) { saveCurrentPlaybackPosition({ force: true }); pushCloudConfig({ force: true }); } }); window.addEventListener('beforeunload', () => { saveCurrentPlaybackPosition({ force: true }); pushCloudConfig({ force: true }); }); function escHtml(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } function escAttr(s) { return escHtml(s).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'), 2200); } init() .catch(error => console.warn('Init failed:', error)) .finally(() => hideSplash()); setTimeout(() => { updateSliderFill(document.getElementById('progressBar'), 0); }, 50);