212dbc4e1d
这是一次大型重构和功能新增: 1. 将原 zen_box 工具重命名为 web_zen_box,调整所有相关路径与配置 2. 新增 web_order_box 原生Web应用的完整资源与部署脚本 3. 新增私人剪贴板后端技能与配套测试脚本 4. 修复订单盒子APP的API地址配置,避免路径重复拼接 5. 新增本地开发跨域白名单支持localhost 6. 清理旧版zen_box的冗余文件
1396 lines
45 KiB
JavaScript
1396 lines
45 KiB
JavaScript
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, '&').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);
|