|
@@ -0,0 +1,282 @@
|
|
|
|
|
+const state = {
|
|
|
|
|
+ library: null,
|
|
|
|
|
+ currentFolder: "",
|
|
|
|
|
+ currentQueue: [],
|
|
|
|
|
+ currentTrackIndex: -1,
|
|
|
|
|
+ shuffle: false,
|
|
|
|
|
+ loopMode: "list",
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const audio = document.getElementById("audioPlayer");
|
|
|
|
|
+
|
|
|
|
|
+async function fetchLibrary() {
|
|
|
|
|
+ const response = await fetch("/api/library");
|
|
|
|
|
+ state.library = await response.json();
|
|
|
|
|
+ renderTree();
|
|
|
|
|
+ renderPlaylists();
|
|
|
|
|
+ if (!state.currentQueue.length) {
|
|
|
|
|
+ state.currentQueue = [...state.library.all_tracks];
|
|
|
|
|
+ renderQueue();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toggleButton(id, active, label) {
|
|
|
|
|
+ const button = document.getElementById(id);
|
|
|
|
|
+ button.classList.toggle("active", active);
|
|
|
|
|
+ button.textContent = label;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function folderTracks(path, node = state.library.tree) {
|
|
|
|
|
+ if (node.path === path) return flattenNodeTracks(node);
|
|
|
|
|
+ for (const child of node.folders) {
|
|
|
|
|
+ const result = folderTracks(path, child);
|
|
|
|
|
+ if (result) return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function flattenNodeTracks(node) {
|
|
|
|
|
+ const tracks = [...node.tracks];
|
|
|
|
|
+ for (const child of node.folders) {
|
|
|
|
|
+ tracks.push(...flattenNodeTracks(child));
|
|
|
|
|
+ }
|
|
|
|
|
+ return tracks;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderTree() {
|
|
|
|
|
+ const container = document.getElementById("folderTree");
|
|
|
|
|
+ container.innerHTML = "";
|
|
|
|
|
+ container.appendChild(renderFolderNode(state.library.tree));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderFolderNode(node) {
|
|
|
|
|
+ const wrapper = document.createElement("div");
|
|
|
|
|
+ wrapper.className = "folder-node";
|
|
|
|
|
+
|
|
|
|
|
+ const title = document.createElement(node.path ? "h4" : "h3");
|
|
|
|
|
+ title.textContent = node.name;
|
|
|
|
|
+ wrapper.appendChild(title);
|
|
|
|
|
+
|
|
|
|
|
+ const actions = document.createElement("div");
|
|
|
|
|
+ actions.className = "folder-actions";
|
|
|
|
|
+
|
|
|
|
|
+ const useBtn = document.createElement("button");
|
|
|
|
|
+ useBtn.className = "ghost-btn";
|
|
|
|
|
+ useBtn.textContent = "设为当前目录";
|
|
|
|
|
+ useBtn.onclick = () => {
|
|
|
|
|
+ state.currentFolder = node.path;
|
|
|
|
|
+ document.getElementById("folderIndicator").textContent = `当前目录:${node.path || "根目录"}`;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const playBtn = document.createElement("button");
|
|
|
|
|
+ playBtn.className = "ghost-btn";
|
|
|
|
|
+ playBtn.textContent = "播放此目录";
|
|
|
|
|
+ playBtn.onclick = () => {
|
|
|
|
|
+ const tracks = flattenNodeTracks(node);
|
|
|
|
|
+ if (!tracks.length) return alert("该目录下没有可播放文件");
|
|
|
|
|
+ startQueue(tracks, 0);
|
|
|
|
|
+ state.currentFolder = node.path;
|
|
|
|
|
+ document.getElementById("folderIndicator").textContent = `当前目录:${node.path || "根目录"}`;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ actions.append(useBtn, playBtn);
|
|
|
|
|
+ wrapper.appendChild(actions);
|
|
|
|
|
+
|
|
|
|
|
+ node.tracks.forEach((track) => wrapper.appendChild(renderTrack(track)));
|
|
|
|
|
+ node.folders.forEach((folder) => wrapper.appendChild(renderFolderNode(folder)));
|
|
|
|
|
+ return wrapper;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderTrack(track) {
|
|
|
|
|
+ const template = document.getElementById("trackItemTemplate");
|
|
|
|
|
+ const fragment = template.content.cloneNode(true);
|
|
|
|
|
+ fragment.querySelector(".track-name").textContent = track.name;
|
|
|
|
|
+ fragment.querySelector(".track-path").textContent = track.path;
|
|
|
|
|
+ fragment.querySelector(".play-btn").onclick = () => startQueue([track], 0);
|
|
|
|
|
+ fragment.querySelector(".queue-btn").onclick = () => {
|
|
|
|
|
+ state.currentQueue.push(track);
|
|
|
|
|
+ renderQueue();
|
|
|
|
|
+ };
|
|
|
|
|
+ fragment.querySelector(".move-btn").onclick = async () => {
|
|
|
|
|
+ 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 = "";
|
|
|
|
|
+ state.currentQueue.forEach((track, index) => {
|
|
|
|
|
+ const item = document.createElement("div");
|
|
|
|
|
+ item.className = "track-item";
|
|
|
|
|
+ if (index === state.currentTrackIndex) item.style.borderColor = "var(--accent)";
|
|
|
|
|
+ item.innerHTML = `
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <strong class="track-name">${track.name}</strong>
|
|
|
|
|
+ <p class="track-path">${track.path}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="track-actions">
|
|
|
|
|
+ <button class="ghost-btn">播放</button>
|
|
|
|
|
+ <button class="ghost-btn">移除</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ const [playBtn, removeBtn] = item.querySelectorAll("button");
|
|
|
|
|
+ playBtn.onclick = () => playTrack(index);
|
|
|
|
|
+ removeBtn.onclick = () => {
|
|
|
|
|
+ state.currentQueue.splice(index, 1);
|
|
|
|
|
+ if (state.currentTrackIndex >= state.currentQueue.length) {
|
|
|
|
|
+ state.currentTrackIndex = state.currentQueue.length - 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ renderQueue();
|
|
|
|
|
+ };
|
|
|
|
|
+ container.appendChild(item);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderPlaylists() {
|
|
|
|
|
+ const container = document.getElementById("playlistList");
|
|
|
|
|
+ container.innerHTML = "";
|
|
|
|
|
+ state.library.playlists.forEach((playlist) => {
|
|
|
|
|
+ const item = document.createElement("div");
|
|
|
|
|
+ item.className = "playlist-item";
|
|
|
|
|
+ item.innerHTML = `
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <strong>${playlist.name}</strong>
|
|
|
|
|
+ <p>${playlist.tracks.length} 首歌曲</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="track-actions">
|
|
|
|
|
+ <button class="ghost-btn">播放</button>
|
|
|
|
|
+ <button class="ghost-btn">覆盖</button>
|
|
|
|
|
+ <button class="ghost-btn">删除</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ const [playBtn, replaceBtn, deleteBtn] = item.querySelectorAll("button");
|
|
|
|
|
+ playBtn.onclick = () => {
|
|
|
|
|
+ const tracks = playlist.tracks
|
|
|
|
|
+ .map((path) => state.library.all_tracks.find((track) => track.path === path))
|
|
|
|
|
+ .filter(Boolean);
|
|
|
|
|
+ startQueue(tracks, 0);
|
|
|
|
|
+ };
|
|
|
|
|
+ replaceBtn.onclick = async () => {
|
|
|
|
|
+ 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 () => {
|
|
|
|
|
+ await fetch(`/api/playlists/${playlist.id}`, { method: "DELETE" });
|
|
|
|
|
+ await fetchLibrary();
|
|
|
|
|
+ };
|
|
|
|
|
+ container.appendChild(item);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function startQueue(tracks, index) {
|
|
|
|
|
+ state.currentQueue = [...tracks];
|
|
|
|
|
+ state.currentTrackIndex = index;
|
|
|
|
|
+ renderQueue();
|
|
|
|
|
+ playTrack(index);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function playTrack(index) {
|
|
|
|
|
+ if (!state.currentQueue.length) return;
|
|
|
|
|
+ state.currentTrackIndex = index;
|
|
|
|
|
+ const track = state.currentQueue[index];
|
|
|
|
|
+ audio.src = track.url;
|
|
|
|
|
+ audio.play();
|
|
|
|
|
+ document.getElementById("nowTitle").textContent = track.name;
|
|
|
|
|
+ document.getElementById("nowMeta").textContent = track.path;
|
|
|
|
|
+ renderQueue();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function nextTrack() {
|
|
|
|
|
+ if (!state.currentQueue.length) return;
|
|
|
|
|
+ if (state.loopMode === "one") {
|
|
|
|
|
+ playTrack(state.currentTrackIndex);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (state.shuffle) {
|
|
|
|
|
+ const randomIndex = Math.floor(Math.random() * state.currentQueue.length);
|
|
|
|
|
+ playTrack(randomIndex);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const nextIndex = state.currentTrackIndex + 1;
|
|
|
|
|
+ if (nextIndex < state.currentQueue.length) {
|
|
|
|
|
+ playTrack(nextIndex);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (state.loopMode === "list") {
|
|
|
|
|
+ playTrack(0);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+audio.addEventListener("ended", nextTrack);
|
|
|
|
|
+
|
|
|
|
|
+document.getElementById("playAllBtn").onclick = () => startQueue(state.library.all_tracks, 0);
|
|
|
|
|
+document.getElementById("playFolderBtn").onclick = () => {
|
|
|
|
|
+ const tracks = folderTracks(state.currentFolder || "") || [];
|
|
|
|
|
+ if (!tracks.length) return alert("当前目录没有歌曲");
|
|
|
|
|
+ startQueue(tracks, 0);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+document.getElementById("shuffleBtn").onclick = () => {
|
|
|
|
|
+ state.shuffle = !state.shuffle;
|
|
|
|
|
+ toggleButton("shuffleBtn", state.shuffle, `随机: ${state.shuffle ? "开" : "关"}`);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+document.getElementById("loopBtn").onclick = () => {
|
|
|
|
|
+ state.loopMode = state.loopMode === "list" ? "one" : state.loopMode === "one" ? "off" : "list";
|
|
|
|
|
+ const labels = { list: "循环: 列表", one: "循环: 单曲", off: "循环: 关" };
|
|
|
|
|
+ toggleButton("loopBtn", state.loopMode !== "off", labels[state.loopMode]);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+document.getElementById("uploadBtn").onclick = async () => {
|
|
|
|
|
+ const files = document.getElementById("uploadInput").files;
|
|
|
|
|
+ if (!files.length) return alert("请选择文件");
|
|
|
|
|
+ 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) return alert("请输入目录名");
|
|
|
|
|
+ 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) return alert("请输入播放列表名称");
|
|
|
|
|
+ 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();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+document.getElementById("saveQueueBtn").onclick = () => renderQueue();
|
|
|
|
|
+
|
|
|
|
|
+fetchLibrary();
|