Files
Toolbox/public/tools/zen_box/zen_box.js
T

718 lines
23 KiB
JavaScript

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