feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,717 @@
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
if (!msg) {
|
||||
el.classList.remove('show');
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
init();
|
||||
setTimeout(() => {
|
||||
updateSliderFill(document.getElementById('progressBar'), 0);
|
||||
}, 50);
|
||||
Reference in New Issue
Block a user