Files
Toolbox/public/tools/web_zen_box/zen_box.js
T
yangxiangyuan 212dbc4e1d refactor: 重构禅音工具路径与配置,新增私人剪贴板技能
这是一次大型重构和功能新增:
1. 将原 zen_box 工具重命名为 web_zen_box,调整所有相关路径与配置
2. 新增 web_order_box 原生Web应用的完整资源与部署脚本
3. 新增私人剪贴板后端技能与配套测试脚本
4. 修复订单盒子APP的API地址配置,避免路径重复拼接
5. 新增本地开发跨域白名单支持localhost
6. 清理旧版zen_box的冗余文件
2026-07-26 21:03:28 +08:00

1396 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/><text x="11" y="14" font-size="6" fill="currentColor" stroke="none" text-anchor="middle">1</text></svg>'
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>';
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 `<button class="dl-btn downloading" data-key="${escAttr(key)}" disabled title="下载中"><span class="dl-spinner"></span></button>`;
}
if (track.isCached) {
return `<button class="dl-btn cached" data-key="${escAttr(key)}" disabled title="已缓存"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></button>`;
}
return `<button class="dl-btn" data-key="${escAttr(key)}" title="下载此歌曲"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg></button>`;
}
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 = `
<div class="account-row">
<span class="account-name">${escHtml(loggedInUser)}</span>
<button class="logout-btn" onclick="doLogout()">退出登录</button>
</div>
<div class="setting-note">已登录,歌单、排序、续播进度等配置将自动同步到云端。</div>
`;
} else {
wrap.innerHTML = `
<button class="login-entry-btn" onclick="openLoginOverlay()">登录以同步配置</button>
<div class="setting-note">登录后可跨设备同步歌单选择、排序偏好、缓存模式与续播进度。</div>
`;
}
}
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 = `
<div class="item-num">${displayPos + 1}</div>
<div class="item-info">
<div class="item-title marquee-host">
<div class="marquee-track">
<span class="marquee-copy marquee-copy-primary">${escHtml(track.title || '未知曲目')}</span>
<span class="marquee-gap" aria-hidden="true"></span>
<span class="marquee-copy marquee-copy-duplicate" aria-hidden="true">${escHtml(track.title || '未知曲目')}</span>
</div>
</div>
<div class="item-sub">${escHtml(track.artist || '未知歌手')}</div>
</div>
${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 = '<span class="eq-dots"><i></i><i></i><i></i></span>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escAttr(s) {
return escHtml(s).replace(/"/g, '&quot;');
}
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);