const state = { library: null, currentFolder: "", currentQueue: [], currentTrackIndex: -1, playMode: "list", albums: [], currentView: "home", activeAlbumPath: "", featuredAlbums: [], featuredIndex: 0, detailTrackPage: 1, }; const audio = document.getElementById("audioPlayer"); const progressBar = document.getElementById("progressBar"); const audioCacheName = "musicweb-audio-cache-v1"; let toastTimer = null; let featuredTimer = null; const iconMarkup = { play: '', replace: '', remove: '', }; function savePlaybackState() { const currentTrack = state.currentQueue[state.currentTrackIndex] || null; const payload = { currentFolder: state.currentFolder, currentQueue: state.currentQueue.map((track) => track.path), currentTrackPath: currentTrack ? currentTrack.path : "", currentTime: audio.currentTime || 0, playMode: state.playMode, activeAlbumPath: state.activeAlbumPath, }; localStorage.setItem("musicwebplayback", JSON.stringify(payload)); } function showIconToast(text) { const toast = document.getElementById("iconToast"); toast.textContent = text; toast.classList.remove("is-hidden"); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.add("is-hidden"), 1200); } function restorePlaybackState() { const raw = localStorage.getItem("musicwebplayback"); if (!raw || !state.library) return; try { const saved = JSON.parse(raw); state.playMode = saved.playMode || (saved.shuffle ? "shuffle" : saved.loopMode === "one" ? "one" : saved.loopMode === "off" ? "once" : "list"); state.currentFolder = saved.currentFolder || ""; state.activeAlbumPath = saved.activeAlbumPath || ""; updateToggleStates(); setCurrentFolder(state.currentFolder); if (Array.isArray(saved.currentQueue) && saved.currentQueue.length) { state.currentQueue = saved.currentQueue .map((path) => state.library.all_tracks.find((track) => track.path === path)) .filter(Boolean); } const trackPath = saved.currentTrackPath || ""; if (trackPath) { const queueIndex = state.currentQueue.findIndex((track) => track.path === trackPath); if (queueIndex >= 0) { state.currentTrackIndex = queueIndex; } else { const single = state.library.all_tracks.find((track) => track.path === trackPath); if (single) { state.currentQueue = [single]; state.currentTrackIndex = 0; } } } if (state.currentQueue.length && state.currentTrackIndex >= 0) { const track = state.currentQueue[state.currentTrackIndex]; audio.src = track.url; updateDock(track); renderQueue(); syncPlayButton(); audio.addEventListener( "loadedmetadata", () => { audio.currentTime = Number(saved.currentTime || 0); }, { once: true } ); } if (state.activeAlbumPath) { renderAlbumDetail(state.activeAlbumPath); } } catch { localStorage.removeItem("musicwebplayback"); } } async function fetchLibrary() { const response = await fetch("/api/library"); state.library = await response.json(); state.albums = buildAlbums(state.library.tree); state.featuredAlbums = buildFeaturedAlbums(state.albums); state.featuredIndex = 0; document.getElementById("albumCount").textContent = state.albums.length; document.getElementById("trackCount").textContent = state.library.all_tracks.length; renderHome(); renderPlaylists(); restorePlaybackState(); if (state.activeAlbumPath) { renderAlbumDetail(state.activeAlbumPath); } startFeaturedCarousel(); } function buildAlbums(tree) { const albums = []; function visit(node, ancestors = []) { const tracks = flattenNodeTracks(node); const childAlbums = node.folders.filter((child) => flattenNodeTracks(child).length); if (node.path && tracks.length && !childAlbums.length) { const nameParts = [...ancestors, node.name].filter(Boolean); albums.push({ name: nameParts.join("") || "未命名专辑", path: node.path, cover: node.cover || null, tracks, }); } const nextAncestors = node.path ? [...ancestors, node.name] : ancestors; node.folders.forEach((child) => visit(child, nextAncestors)); } visit(tree); return albums; } function flattenNodeTracks(node) { const tracks = [...node.tracks]; node.folders.forEach((child) => tracks.push(...flattenNodeTracks(child))); return tracks; } function folderTracks(path) { const album = state.albums.find((item) => item.path === path); return album ? [...album.tracks] : []; } function setCurrentFolder(path) { state.currentFolder = path; document.getElementById("folderIndicator").textContent = path || "根目录"; } function showView(name) { state.currentView = name; document.getElementById("homeView").classList.toggle("is-hidden", name !== "home"); document.getElementById("detailView").classList.toggle("is-hidden", name !== "detail"); savePlaybackState(); } function scrollToPageTop() { requestAnimationFrame(() => { const scroller = document.scrollingElement || document.documentElement; scroller.scrollTop = 0; scroller.scrollLeft = 0; window.scrollTo(0, 0); }); } function coverLabel(name) { return name.length > 12 ? `${name.slice(0, 12)}...` : name; } function shortAlbumName(name) { return name.length > 8 ? `${name.slice(0, 8)}...` : name; } function buildCoverMarkup(album, className) { const imageMarkup = album.cover ? `${album.name}` : ""; return `
${imageMarkup} ${album.cover ? "" : `${coverLabel(album.name)}`}
`; } function renderHome() { const latestTracks = [...state.library.all_tracks].slice(-8).reverse(); const rankingAlbums = [...state.albums].sort((a, b) => b.tracks.length - a.tracks.length).slice(0, 6); renderFeaturedAlbums(state.featuredAlbums); renderAlbumGrid(state.albums); renderLatestList(latestTracks); renderRankingList(rankingAlbums); renderAlbumMenu(); renderQueue(); } function buildAlbumCardActions(card, album) { const detailBtn = card.querySelector("[data-action='detail']"); const playBtn = card.querySelector("[data-action='play']"); detailBtn.onclick = () => renderAlbumDetail(album.path); playBtn.onclick = (event) => { event.stopPropagation(); setCurrentFolder(album.path); startQueue(album.tracks, 0); }; card.onclick = () => renderAlbumDetail(album.path); } function renderFeaturedAlbums(items) { const container = document.getElementById("featuredAlbums"); container.innerHTML = ""; if (!items.length) { container.innerHTML = '
暂无可展示的专辑目录
'; return; } const album = items[state.featuredIndex] || items[0]; container.appendChild(buildFeaturedAlbumCard(album)); } function buildFeaturedAlbums(albums) { const sorted = [...albums].sort((a, b) => b.tracks.length - a.tracks.length); if (sorted.length <= 5) return sorted; return sorted.slice(0, 8); } function startFeaturedCarousel() { if (featuredTimer) clearInterval(featuredTimer); if (state.featuredAlbums.length <= 1) return; featuredTimer = setInterval(() => { state.featuredIndex = (state.featuredIndex + 1) % state.featuredAlbums.length; renderFeaturedAlbums(state.featuredAlbums); }, 3000); } function buildFeaturedAlbumCard(album) { const card = document.createElement("article"); card.className = "feature-card"; card.innerHTML = `
${buildCoverMarkup(album, "feature-cover")}

${album.name}

${album.tracks.length} 首歌曲 · ${album.path}

点击进入详细目录页,查看歌曲列表并播放。

`; buildAlbumCardActions(card, album); return card; } function renderAlbumGrid(items) { const container = document.getElementById("albumGrid"); container.innerHTML = ""; if (!items.length) { container.innerHTML = '
当前音乐库还没有专辑目录
'; return; } items.forEach((album) => { const card = document.createElement("article"); card.className = "album-card"; card.innerHTML = ` ${buildCoverMarkup(album, "album-cover")}

${album.name}

${album.tracks.length} 首歌曲

`; buildAlbumCardActions(card, album); container.appendChild(card); }); } function renderAlbumDetail(path) { const album = state.albums.find((item) => item.path === path); if (!album) return; if (state.activeAlbumPath !== path) { state.detailTrackPage = 1; } state.activeAlbumPath = path; setCurrentFolder(path); showView("detail"); scrollToPageTop(); document.getElementById("detailTitle").textContent = album.name; document.getElementById("detailMeta").textContent = `${album.path} · ${album.tracks.length} 首歌曲`; document.getElementById("detailTrackCount").textContent = `${album.tracks.length} 首`; const coverImage = document.getElementById("detailCoverImage"); const coverFallback = document.getElementById("detailCoverFallback"); if (album.cover) { coverImage.src = album.cover; coverImage.classList.remove("is-hidden"); coverFallback.classList.add("is-hidden"); } else { coverImage.removeAttribute("src"); coverImage.classList.add("is-hidden"); coverFallback.classList.remove("is-hidden"); coverFallback.textContent = coverLabel(album.name); } document.getElementById("detailPlayBtn").onclick = () => startQueue(album.tracks, 0); document.getElementById("detailPlayAllBtn").onclick = () => { state.currentQueue = [...album.tracks, ...state.currentQueue]; state.currentTrackIndex = 0; renderQueue(); playTrack(0); }; const container = document.getElementById("detailTrackList"); container.innerHTML = ""; renderAlbumTracks(container, album.tracks); } function renderAlbumTracks(container, tracks) { const pageSize = 30; const totalPages = Math.max(1, Math.ceil(tracks.length / pageSize)); state.detailTrackPage = Math.min(Math.max(state.detailTrackPage || 1, 1), totalPages); const start = (state.detailTrackPage - 1) * pageSize; const visibleTracks = tracks.slice(start, start + pageSize); visibleTracks.forEach((track, index) => container.appendChild(renderTrack(track, tracks, start + index, { compact: true }))); if (tracks.length <= pageSize) return; const pager = document.createElement("div"); pager.className = "pager"; pager.innerHTML = ` ${state.detailTrackPage} / ${totalPages} `; pager.querySelector("[data-action='prev']").onclick = () => { if (state.detailTrackPage <= 1) return; state.detailTrackPage -= 1; renderAlbumDetail(state.activeAlbumPath); }; pager.querySelector("[data-action='next']").onclick = () => { if (state.detailTrackPage >= totalPages) return; state.detailTrackPage += 1; renderAlbumDetail(state.activeAlbumPath); }; container.appendChild(pager); } function renderLatestList(tracks) { const container = document.getElementById("latestList"); container.innerHTML = ""; if (!tracks.length) { container.innerHTML = '
暂无最新更新
'; return; } tracks.forEach((track, index) => container.appendChild(renderTrack(track, tracks, index))); } function renderRankingList(items) { const container = document.getElementById("rankingList"); container.innerHTML = ""; if (!items.length) { container.innerHTML = '
暂无排名数据
'; return; } items.forEach((album, index) => { const card = document.createElement("div"); card.className = "ranking-card"; card.innerHTML = ` ${index + 1}

${album.name}

${album.tracks.length} 首歌曲

`; card.onclick = () => renderAlbumDetail(album.path); card.querySelector("button").onclick = (event) => { event.stopPropagation(); showIconToast("播放"); setCurrentFolder(album.path); startQueue(album.tracks, 0); }; container.appendChild(card); }); } function renderTrack(track, sourceTracks = [track], sourceIndex = 0, options = {}) { const template = document.getElementById("trackItemTemplate"); const fragment = template.content.cloneNode(true); const row = fragment.querySelector(".song-row"); const trackPath = fragment.querySelector(".track-path"); fragment.querySelector(".track-name").textContent = track.name; trackPath.textContent = track.path; if (options.compact) { row.classList.add("detail-track-row"); } fragment.querySelector(".play-btn").onclick = () => { showIconToast("播放"); startQueue(sourceTracks, sourceIndex); }; fragment.querySelector(".queue-btn").onclick = () => { showIconToast("加入队列"); state.currentQueue.push(track); renderQueue(); savePlaybackState(); }; fragment.querySelector(".move-btn").onclick = async () => { showIconToast("移动歌曲"); const destination = prompt("移动到哪个目录?输入相对 mp3file 的目录路径", track.folder || ""); if (destination === null) return; await fetch("/api/move", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: track.path, destination_dir: destination }), }); await fetchLibrary(); }; return fragment; } function renderQueue() { const container = document.getElementById("queueList"); container.innerHTML = ""; if (!state.currentQueue.length) { container.innerHTML = '
当前播放队列为空
'; return; } state.currentQueue.forEach((track, index) => { const row = document.createElement("div"); row.className = `song-row${index === state.currentTrackIndex ? " is-active" : ""}`; row.innerHTML = `
${track.name}

${track.path}

`; const [playBtn, removeBtn] = row.querySelectorAll("button"); playBtn.onclick = () => { showIconToast("播放"); playTrack(index); }; removeBtn.onclick = () => { showIconToast("移除"); state.currentQueue.splice(index, 1); if (state.currentTrackIndex >= state.currentQueue.length) { state.currentTrackIndex = state.currentQueue.length - 1; } renderQueue(); savePlaybackState(); }; container.appendChild(row); }); } function clearQueue() { state.currentQueue = []; clearCurrentTrack(); } function renderPlaylists() { const container = document.getElementById("playlistList"); container.innerHTML = ""; if (!state.library.playlists.length) { container.innerHTML = '
还没有播放列表
'; return; } state.library.playlists.forEach((playlist) => { const item = document.createElement("div"); item.className = "playlist-item"; item.innerHTML = `
${playlist.name}

${playlist.tracks.length} 首歌曲

`; const [playBtn, replaceBtn, deleteBtn] = item.querySelectorAll("button"); playBtn.onclick = () => { showIconToast("播放列表"); const tracks = playlist.tracks .map((path) => state.library.all_tracks.find((track) => track.path === path)) .filter(Boolean); startQueue(tracks, 0); }; replaceBtn.onclick = async () => { showIconToast("覆盖列表"); await fetch(`/api/playlists/${playlist.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tracks: state.currentQueue.map((track) => track.path) }), }); await fetchLibrary(); }; deleteBtn.onclick = async () => { showIconToast("删除列表"); await fetch(`/api/playlists/${playlist.id}`, { method: "DELETE" }); await fetchLibrary(); }; container.appendChild(item); }); } function renderAlbumMenu() { const container = document.getElementById("albumMenuList"); if (!container) return; container.innerHTML = ""; if (!state.albums.length) { container.innerHTML = '
暂无专辑
'; return; } state.albums.forEach((album) => { const button = document.createElement("button"); button.className = "album-menu-item"; button.innerHTML = ` ${shortAlbumName(album.name)} ${album.tracks.length} 首 `; button.onclick = () => { setCurrentFolder(album.path); renderAlbumDetail(album.path); toggleAlbumMenu(false); }; container.appendChild(button); }); } function toggleAlbumMenu(forceOpen) { const overlay = document.getElementById("albumMenuOverlay"); const open = typeof forceOpen === "boolean" ? forceOpen : overlay.classList.contains("is-hidden"); overlay.classList.toggle("is-hidden", !open); } function updateToggleStates() { const modes = { shuffle: { label: "随机播放", icon: '', }, one: { label: "单曲循环", icon: '1', }, list: { label: "列表播放", icon: '', }, once: { label: "单次播放", icon: '', }, }; const mode = modes[state.playMode] || modes.list; const button = document.getElementById("playModeBtn"); const icon = document.getElementById("playModeIcon"); button.dataset.tip = mode.label; button.title = mode.label; icon.innerHTML = mode.icon; } function startQueue(tracks, index) { if (!tracks.length) return; state.currentQueue = [...tracks]; state.currentTrackIndex = index; renderQueue(); playTrack(index); savePlaybackState(); } function updateDock(track) { document.getElementById("nowTitle").textContent = track.name; document.getElementById("nowMeta").textContent = track.path; document.getElementById("dockTitle").textContent = track.name; document.getElementById("dockPath").textContent = track.path; const coverImg = document.getElementById("dockCoverImage"); const fallback = document.getElementById("dockCoverFallback"); const album = state.albums.find((item) => item.path === track.folder) || null; if (album && album.cover) { coverImg.src = album.cover; coverImg.classList.remove("is-hidden"); fallback.classList.add("is-hidden"); } else { coverImg.removeAttribute("src"); coverImg.classList.add("is-hidden"); fallback.classList.remove("is-hidden"); fallback.textContent = (track.name || "乐").charAt(0).toUpperCase(); } } function clearCurrentTrack() { audio.pause(); audio.removeAttribute("src"); audio.load(); state.currentTrackIndex = -1; state.activeAlbumPath = ""; state.detailTrackPage = 1; document.getElementById("nowTitle").textContent = "选择一个专辑目录开始播放"; document.getElementById("nowMeta").textContent = "点击专辑进入详情页,或直接在卡片上快速播放。"; document.getElementById("dockTitle").textContent = "未开始播放"; document.getElementById("dockPath").textContent = "点击专辑或歌曲开始播放"; document.getElementById("currentTimeLabel").textContent = "00:00"; document.getElementById("durationLabel").textContent = "00:00"; progressBar.value = 0; const coverImg = document.getElementById("dockCoverImage"); const fallback = document.getElementById("dockCoverFallback"); coverImg.removeAttribute("src"); coverImg.classList.add("is-hidden"); fallback.classList.remove("is-hidden"); fallback.textContent = "乐"; renderQueue(); syncPlayButton(); savePlaybackState(); } function syncPlayButton() { document.getElementById("playToggleBtn").classList.toggle("is-playing", !audio.paused && !audio.ended); document.querySelector(".icon-play").classList.toggle("is-hidden", !audio.paused && !audio.ended); document.querySelector(".icon-pause").classList.toggle("is-hidden", audio.paused || audio.ended); document.getElementById("playToggleBtn").dataset.tip = audio.paused || audio.ended ? "播放" : "暂停"; } async function getCachedAudioUrl(track) { if (track.path?.startsWith("cloud/")) { const response = await fetch(`/api/cloud-url/${track.path}`); if (!response.ok) throw new Error(`cloud url request failed: ${response.status}`); const data = await response.json(); return data.url || track.url; } if (!("caches" in window)) return track.url; try { const cache = await caches.open(audioCacheName); const cached = await cache.match(track.url); if (cached) { const blob = await cached.blob(); return URL.createObjectURL(blob); } fetch(track.url, { credentials: "same-origin" }) .then((response) => { if (response.ok) cache.put(track.url, response.clone()); }) .catch(() => {}); } catch { return track.url; } return track.url; } async function playTrack(index) { if (!state.currentQueue.length) return; state.currentTrackIndex = index; const track = state.currentQueue[index]; try { audio.src = await getCachedAudioUrl(track); await audio.play(); } catch (error) { console.error("playTrack failed", track?.path, error); audio.src = track.url; audio.play().catch((fallbackError) => { console.error("fallback play failed", track?.path, fallbackError); }); } updateDock(track); renderQueue(); savePlaybackState(); } function playPrevious() { if (!state.currentQueue.length) return; const prevIndex = state.currentTrackIndex > 0 ? state.currentTrackIndex - 1 : state.currentQueue.length - 1; playTrack(prevIndex); } function playNext() { if (!state.currentQueue.length) return; if (state.playMode === "shuffle") { playTrack(Math.floor(Math.random() * state.currentQueue.length)); return; } const nextIndex = state.currentTrackIndex + 1 < state.currentQueue.length ? state.currentTrackIndex + 1 : 0; playTrack(nextIndex); } function nextTrack() { if (!state.currentQueue.length) return; if (state.playMode === "one") { playTrack(state.currentTrackIndex); return; } if (state.playMode === "once" || state.currentTrackIndex === state.currentQueue.length - 1) { audio.pause(); syncPlayButton(); return; } playNext(); } function formatTime(seconds) { if (!Number.isFinite(seconds)) return "00:00"; const minutes = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } audio.addEventListener("ended", nextTrack); audio.addEventListener("play", syncPlayButton); audio.addEventListener("pause", syncPlayButton); audio.addEventListener("timeupdate", () => { const progress = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0; progressBar.value = progress; document.getElementById("currentTimeLabel").textContent = formatTime(audio.currentTime); document.getElementById("durationLabel").textContent = formatTime(audio.duration); savePlaybackState(); }); audio.addEventListener("loadedmetadata", () => { document.getElementById("durationLabel").textContent = formatTime(audio.duration); }); progressBar.addEventListener("input", () => { if (!audio.duration) return; audio.currentTime = (Number(progressBar.value) / 100) * audio.duration; }); document.getElementById("backHomeBtn").onclick = () => { showView("home"); scrollToPageTop(); }; document.getElementById("saveQueueBtn").onclick = () => renderQueue(); document.getElementById("clearQueueBtn").onclick = () => { if (!state.currentQueue.length) return; showIconToast("清空队列"); clearQueue(); }; document.getElementById("playModeBtn").onclick = () => { const modes = ["shuffle", "one", "list", "once"]; const nextIndex = (modes.indexOf(state.playMode) + 1) % modes.length; state.playMode = modes[nextIndex]; updateToggleStates(); showIconToast(document.getElementById("playModeBtn").dataset.tip); savePlaybackState(); }; document.getElementById("albumMenuBtn").onclick = () => toggleAlbumMenu(); document.getElementById("closeAlbumMenuBtn").onclick = () => toggleAlbumMenu(false); document.getElementById("albumMenuHomeBtn").onclick = () => { toggleAlbumMenu(false); showView("home"); clearCurrentTrack(); scrollToPageTop(); }; document.getElementById("albumMenuOverlay").onclick = (event) => { if (event.target.id === "albumMenuOverlay") toggleAlbumMenu(false); }; document.getElementById("prevBtn").onclick = () => { showIconToast("上一曲"); playPrevious(); }; document.getElementById("nextBtn").onclick = () => { showIconToast("下一曲"); playNext(); }; document.getElementById("playToggleBtn").onclick = () => { if (!state.currentQueue.length && state.library?.all_tracks?.length) { startQueue(state.library.all_tracks, 0); showIconToast("开始播放"); return; } if (audio.paused) { audio.play(); showIconToast("播放"); } else { audio.pause(); showIconToast("暂停"); } }; document.getElementById("uploadBtn").onclick = async () => { const files = document.getElementById("uploadInput").files; if (!files.length) { alert("请选择文件"); return; } const formData = new FormData(); Array.from(files).forEach((file) => formData.append("files", file)); formData.append("target_dir", document.getElementById("targetDir").value.trim()); await fetch("/api/upload", { method: "POST", body: formData }); document.getElementById("uploadInput").value = ""; await fetchLibrary(); }; document.getElementById("createFolderBtn").onclick = async () => { const path = document.getElementById("newFolderInput").value.trim(); if (!path) { alert("请输入目录名"); return; } await fetch("/api/folder", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); document.getElementById("newFolderInput").value = ""; await fetchLibrary(); }; document.getElementById("createPlaylistBtn").onclick = async () => { const name = document.getElementById("playlistNameInput").value.trim(); if (!name) { alert("请输入播放列表名称"); return; } await fetch("/api/playlists", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, tracks: state.currentQueue.map((track) => track.path) }), }); document.getElementById("playlistNameInput").value = ""; await fetchLibrary(); }; updateToggleStates(); fetchLibrary();