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
? `
`
: "";
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 = `
`;
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();