|
@@ -5,86 +5,339 @@ const state = {
|
|
|
currentTrackIndex: -1,
|
|
currentTrackIndex: -1,
|
|
|
shuffle: false,
|
|
shuffle: false,
|
|
|
loopMode: "list",
|
|
loopMode: "list",
|
|
|
|
|
+ albums: [],
|
|
|
|
|
+ currentView: "home",
|
|
|
|
|
+ activeAlbumPath: "",
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const audio = document.getElementById("audioPlayer");
|
|
const audio = document.getElementById("audioPlayer");
|
|
|
|
|
+const progressBar = document.getElementById("progressBar");
|
|
|
|
|
+let toastTimer = null;
|
|
|
|
|
+
|
|
|
|
|
+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,
|
|
|
|
|
+ shuffle: state.shuffle,
|
|
|
|
|
+ loopMode: state.loopMode,
|
|
|
|
|
+ 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.shuffle = Boolean(saved.shuffle);
|
|
|
|
|
+ state.loopMode = saved.loopMode || "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() {
|
|
async function fetchLibrary() {
|
|
|
const response = await fetch("/api/library");
|
|
const response = await fetch("/api/library");
|
|
|
state.library = await response.json();
|
|
state.library = await response.json();
|
|
|
- renderTree();
|
|
|
|
|
|
|
+ state.albums = buildAlbums(state.library.tree);
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById("albumCount").textContent = state.albums.length;
|
|
|
|
|
+ document.getElementById("trackCount").textContent = state.library.all_tracks.length;
|
|
|
|
|
+
|
|
|
|
|
+ renderHome();
|
|
|
renderPlaylists();
|
|
renderPlaylists();
|
|
|
|
|
+ restorePlaybackState();
|
|
|
|
|
+
|
|
|
|
|
+ if (state.activeAlbumPath) {
|
|
|
|
|
+ renderAlbumDetail(state.activeAlbumPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if (!state.currentQueue.length) {
|
|
if (!state.currentQueue.length) {
|
|
|
state.currentQueue = [...state.library.all_tracks];
|
|
state.currentQueue = [...state.library.all_tracks];
|
|
|
renderQueue();
|
|
renderQueue();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function toggleButton(id, active, label) {
|
|
|
|
|
- const button = document.getElementById(id);
|
|
|
|
|
- button.classList.toggle("active", active);
|
|
|
|
|
- button.textContent = label;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+function buildAlbums(tree) {
|
|
|
|
|
+ const albums = [];
|
|
|
|
|
|
|
|
-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;
|
|
|
|
|
|
|
+ function visit(node) {
|
|
|
|
|
+ const tracks = flattenNodeTracks(node);
|
|
|
|
|
+ if (node.path && tracks.length) {
|
|
|
|
|
+ albums.push({
|
|
|
|
|
+ name: node.name || "未命名专辑",
|
|
|
|
|
+ path: node.path,
|
|
|
|
|
+ cover: node.cover || null,
|
|
|
|
|
+ tracks,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ node.folders.forEach(visit);
|
|
|
}
|
|
}
|
|
|
- return null;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ visit(tree);
|
|
|
|
|
+ return albums;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function flattenNodeTracks(node) {
|
|
function flattenNodeTracks(node) {
|
|
|
const tracks = [...node.tracks];
|
|
const tracks = [...node.tracks];
|
|
|
- for (const child of node.folders) {
|
|
|
|
|
- tracks.push(...flattenNodeTracks(child));
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ node.folders.forEach((child) => tracks.push(...flattenNodeTracks(child)));
|
|
|
return tracks;
|
|
return tracks;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function renderTree() {
|
|
|
|
|
- const container = document.getElementById("folderTree");
|
|
|
|
|
- container.innerHTML = "";
|
|
|
|
|
- container.appendChild(renderFolderNode(state.library.tree));
|
|
|
|
|
|
|
+function folderTracks(path) {
|
|
|
|
|
+ const album = state.albums.find((item) => item.path === path);
|
|
|
|
|
+ return album ? [...album.tracks] : [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function renderFolderNode(node) {
|
|
|
|
|
- const wrapper = document.createElement("div");
|
|
|
|
|
- wrapper.className = "folder-node";
|
|
|
|
|
|
|
+function setCurrentFolder(path) {
|
|
|
|
|
+ state.currentFolder = path;
|
|
|
|
|
+ document.getElementById("folderIndicator").textContent = path || "根目录";
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- const title = document.createElement(node.path ? "h4" : "h3");
|
|
|
|
|
- title.textContent = node.name;
|
|
|
|
|
- wrapper.appendChild(title);
|
|
|
|
|
|
|
+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 coverLabel(name) {
|
|
|
|
|
+ return name.length > 12 ? `${name.slice(0, 12)}...` : name;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- const actions = document.createElement("div");
|
|
|
|
|
- actions.className = "folder-actions";
|
|
|
|
|
|
|
+function buildCoverMarkup(album, className) {
|
|
|
|
|
+ const imageMarkup = album.cover
|
|
|
|
|
+ ? `<img class="cover-image" src="${album.cover}" alt="${album.name}" />`
|
|
|
|
|
+ : "";
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="${className}">
|
|
|
|
|
+ ${imageMarkup}
|
|
|
|
|
+ <span class="cover-name">${coverLabel(album.name)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderHome() {
|
|
|
|
|
+ const featured = [...state.albums].sort((a, b) => b.tracks.length - a.tracks.length).slice(0, 2);
|
|
|
|
|
+ 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(featured);
|
|
|
|
|
+ renderAlbumGrid(state.albums);
|
|
|
|
|
+ renderLatestList(latestTracks);
|
|
|
|
|
+ renderRankingList(rankingAlbums);
|
|
|
|
|
+ renderQueue();
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- const useBtn = document.createElement("button");
|
|
|
|
|
- useBtn.className = "ghost-btn";
|
|
|
|
|
- useBtn.textContent = "设为当前目录";
|
|
|
|
|
- useBtn.onclick = () => {
|
|
|
|
|
- state.currentFolder = node.path;
|
|
|
|
|
- document.getElementById("folderIndicator").textContent = `当前目录:${node.path || "根目录"}`;
|
|
|
|
|
|
|
+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);
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- 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 || "根目录"}`;
|
|
|
|
|
|
|
+function renderFeaturedAlbums(items) {
|
|
|
|
|
+ const container = document.getElementById("featuredAlbums");
|
|
|
|
|
+ container.innerHTML = "";
|
|
|
|
|
+
|
|
|
|
|
+ if (!items.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="empty-state">暂无可展示的专辑目录</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ items.forEach((album) => {
|
|
|
|
|
+ const card = document.createElement("article");
|
|
|
|
|
+ card.className = "feature-card";
|
|
|
|
|
+ card.innerHTML = `
|
|
|
|
|
+ <div class="feature-card-inner">
|
|
|
|
|
+ ${buildCoverMarkup(album, "feature-cover")}
|
|
|
|
|
+ <div class="feature-body">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3>${album.name}</h3>
|
|
|
|
|
+ <p class="meta-row">${album.tracks.length} 首歌曲 · ${album.path}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="subtext">点击进入详细目录页,查看歌曲列表并播放。</p>
|
|
|
|
|
+ <div class="card-actions">
|
|
|
|
|
+ <button class="primary-btn" data-action="detail">进入目录</button>
|
|
|
|
|
+ <button class="secondary-btn" data-action="play">播放</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ buildAlbumCardActions(card, album);
|
|
|
|
|
+ container.appendChild(card);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderAlbumGrid(items) {
|
|
|
|
|
+ const container = document.getElementById("albumGrid");
|
|
|
|
|
+ container.innerHTML = "";
|
|
|
|
|
+
|
|
|
|
|
+ if (!items.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="empty-state">当前音乐库还没有专辑目录</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ items.forEach((album) => {
|
|
|
|
|
+ const card = document.createElement("article");
|
|
|
|
|
+ card.className = "album-card";
|
|
|
|
|
+ card.innerHTML = `
|
|
|
|
|
+ ${buildCoverMarkup(album, "album-cover")}
|
|
|
|
|
+ <div class="album-body">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3>${album.name}</h3>
|
|
|
|
|
+ <p class="meta-row">${album.tracks.length} 首歌曲</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="card-actions">
|
|
|
|
|
+ <button class="secondary-btn" data-action="detail">进入</button>
|
|
|
|
|
+ <button class="text-btn" data-action="play">播放</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ buildAlbumCardActions(card, album);
|
|
|
|
|
+ container.appendChild(card);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderAlbumDetail(path) {
|
|
|
|
|
+ const album = state.albums.find((item) => item.path === path);
|
|
|
|
|
+ if (!album) return;
|
|
|
|
|
+
|
|
|
|
|
+ state.activeAlbumPath = path;
|
|
|
|
|
+ setCurrentFolder(path);
|
|
|
|
|
+ showView("detail");
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- actions.append(useBtn, playBtn);
|
|
|
|
|
- wrapper.appendChild(actions);
|
|
|
|
|
|
|
+ const container = document.getElementById("detailTrackList");
|
|
|
|
|
+ container.innerHTML = "";
|
|
|
|
|
+ album.tracks.forEach((track) => container.appendChild(renderTrack(track)));
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- node.tracks.forEach((track) => wrapper.appendChild(renderTrack(track)));
|
|
|
|
|
- node.folders.forEach((folder) => wrapper.appendChild(renderFolderNode(folder)));
|
|
|
|
|
- return wrapper;
|
|
|
|
|
|
|
+function renderLatestList(tracks) {
|
|
|
|
|
+ const container = document.getElementById("latestList");
|
|
|
|
|
+ container.innerHTML = "";
|
|
|
|
|
+ if (!tracks.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="empty-state">暂无最新更新</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ tracks.forEach((track) => container.appendChild(renderTrack(track)));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderRankingList(items) {
|
|
|
|
|
+ const container = document.getElementById("rankingList");
|
|
|
|
|
+ container.innerHTML = "";
|
|
|
|
|
+ if (!items.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="empty-state">暂无排名数据</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ items.forEach((album, index) => {
|
|
|
|
|
+ const card = document.createElement("div");
|
|
|
|
|
+ card.className = "ranking-card";
|
|
|
|
|
+ card.innerHTML = `
|
|
|
|
|
+ <span class="rank-badge">${index + 1}</span>
|
|
|
|
|
+ <div class="ranking-main">
|
|
|
|
|
+ <h3>${album.name}</h3>
|
|
|
|
|
+ <p class="ranking-note">${album.tracks.length} 首歌曲</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="track-actions compact-actions">
|
|
|
|
|
+ <button class="icon-btn small-icon-btn play-btn" aria-label="播放"></button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ 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) {
|
|
function renderTrack(track) {
|
|
@@ -92,12 +345,18 @@ function renderTrack(track) {
|
|
|
const fragment = template.content.cloneNode(true);
|
|
const fragment = template.content.cloneNode(true);
|
|
|
fragment.querySelector(".track-name").textContent = track.name;
|
|
fragment.querySelector(".track-name").textContent = track.name;
|
|
|
fragment.querySelector(".track-path").textContent = track.path;
|
|
fragment.querySelector(".track-path").textContent = track.path;
|
|
|
- fragment.querySelector(".play-btn").onclick = () => startQueue([track], 0);
|
|
|
|
|
|
|
+ fragment.querySelector(".play-btn").onclick = () => {
|
|
|
|
|
+ showIconToast("播放");
|
|
|
|
|
+ startQueue([track], 0);
|
|
|
|
|
+ };
|
|
|
fragment.querySelector(".queue-btn").onclick = () => {
|
|
fragment.querySelector(".queue-btn").onclick = () => {
|
|
|
|
|
+ showIconToast("加入队列");
|
|
|
state.currentQueue.push(track);
|
|
state.currentQueue.push(track);
|
|
|
renderQueue();
|
|
renderQueue();
|
|
|
|
|
+ savePlaybackState();
|
|
|
};
|
|
};
|
|
|
fragment.querySelector(".move-btn").onclick = async () => {
|
|
fragment.querySelector(".move-btn").onclick = async () => {
|
|
|
|
|
+ showIconToast("移动歌曲");
|
|
|
const destination = prompt("移动到哪个目录?输入相对 mp3file 的目录路径", track.folder || "");
|
|
const destination = prompt("移动到哪个目录?输入相对 mp3file 的目录路径", track.folder || "");
|
|
|
if (destination === null) return;
|
|
if (destination === null) return;
|
|
|
await fetch("/api/move", {
|
|
await fetch("/api/move", {
|
|
@@ -113,36 +372,50 @@ function renderTrack(track) {
|
|
|
function renderQueue() {
|
|
function renderQueue() {
|
|
|
const container = document.getElementById("queueList");
|
|
const container = document.getElementById("queueList");
|
|
|
container.innerHTML = "";
|
|
container.innerHTML = "";
|
|
|
|
|
+ if (!state.currentQueue.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="empty-state">当前播放队列为空</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
state.currentQueue.forEach((track, index) => {
|
|
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>
|
|
|
|
|
|
|
+ const row = document.createElement("div");
|
|
|
|
|
+ row.className = `song-row${index === state.currentTrackIndex ? " is-active" : ""}`;
|
|
|
|
|
+ row.innerHTML = `
|
|
|
|
|
+ <div class="song-copy">
|
|
|
<strong class="track-name">${track.name}</strong>
|
|
<strong class="track-name">${track.name}</strong>
|
|
|
<p class="track-path">${track.path}</p>
|
|
<p class="track-path">${track.path}</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="track-actions">
|
|
|
|
|
- <button class="ghost-btn">播放</button>
|
|
|
|
|
- <button class="ghost-btn">移除</button>
|
|
|
|
|
|
|
+ <div class="track-actions compact-actions">
|
|
|
|
|
+ <button class="icon-btn small-icon-btn play-btn" aria-label="播放"></button>
|
|
|
|
|
+ <button class="icon-btn small-icon-btn move-btn" aria-label="移除"></button>
|
|
|
</div>
|
|
</div>
|
|
|
`;
|
|
`;
|
|
|
- const [playBtn, removeBtn] = item.querySelectorAll("button");
|
|
|
|
|
- playBtn.onclick = () => playTrack(index);
|
|
|
|
|
|
|
+ const [playBtn, removeBtn] = row.querySelectorAll("button");
|
|
|
|
|
+ playBtn.onclick = () => {
|
|
|
|
|
+ showIconToast("播放");
|
|
|
|
|
+ playTrack(index);
|
|
|
|
|
+ };
|
|
|
removeBtn.onclick = () => {
|
|
removeBtn.onclick = () => {
|
|
|
|
|
+ showIconToast("移除");
|
|
|
state.currentQueue.splice(index, 1);
|
|
state.currentQueue.splice(index, 1);
|
|
|
if (state.currentTrackIndex >= state.currentQueue.length) {
|
|
if (state.currentTrackIndex >= state.currentQueue.length) {
|
|
|
state.currentTrackIndex = state.currentQueue.length - 1;
|
|
state.currentTrackIndex = state.currentQueue.length - 1;
|
|
|
}
|
|
}
|
|
|
renderQueue();
|
|
renderQueue();
|
|
|
|
|
+ savePlaybackState();
|
|
|
};
|
|
};
|
|
|
- container.appendChild(item);
|
|
|
|
|
|
|
+ container.appendChild(row);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderPlaylists() {
|
|
function renderPlaylists() {
|
|
|
const container = document.getElementById("playlistList");
|
|
const container = document.getElementById("playlistList");
|
|
|
container.innerHTML = "";
|
|
container.innerHTML = "";
|
|
|
|
|
+ if (!state.library.playlists.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="empty-state">还没有播放列表</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
state.library.playlists.forEach((playlist) => {
|
|
state.library.playlists.forEach((playlist) => {
|
|
|
const item = document.createElement("div");
|
|
const item = document.createElement("div");
|
|
|
item.className = "playlist-item";
|
|
item.className = "playlist-item";
|
|
@@ -151,20 +424,23 @@ function renderPlaylists() {
|
|
|
<strong>${playlist.name}</strong>
|
|
<strong>${playlist.name}</strong>
|
|
|
<p>${playlist.tracks.length} 首歌曲</p>
|
|
<p>${playlist.tracks.length} 首歌曲</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="track-actions">
|
|
|
|
|
- <button class="ghost-btn">播放</button>
|
|
|
|
|
- <button class="ghost-btn">覆盖</button>
|
|
|
|
|
- <button class="ghost-btn">删除</button>
|
|
|
|
|
|
|
+ <div class="track-actions compact-actions">
|
|
|
|
|
+ <button class="icon-btn small-icon-btn play-btn" aria-label="播放"></button>
|
|
|
|
|
+ <button class="icon-btn small-icon-btn queue-btn" aria-label="覆盖"></button>
|
|
|
|
|
+ <button class="icon-btn small-icon-btn move-btn" aria-label="删除"></button>
|
|
|
</div>
|
|
</div>
|
|
|
`;
|
|
`;
|
|
|
|
|
+
|
|
|
const [playBtn, replaceBtn, deleteBtn] = item.querySelectorAll("button");
|
|
const [playBtn, replaceBtn, deleteBtn] = item.querySelectorAll("button");
|
|
|
playBtn.onclick = () => {
|
|
playBtn.onclick = () => {
|
|
|
|
|
+ showIconToast("播放列表");
|
|
|
const tracks = playlist.tracks
|
|
const tracks = playlist.tracks
|
|
|
.map((path) => state.library.all_tracks.find((track) => track.path === path))
|
|
.map((path) => state.library.all_tracks.find((track) => track.path === path))
|
|
|
.filter(Boolean);
|
|
.filter(Boolean);
|
|
|
startQueue(tracks, 0);
|
|
startQueue(tracks, 0);
|
|
|
};
|
|
};
|
|
|
replaceBtn.onclick = async () => {
|
|
replaceBtn.onclick = async () => {
|
|
|
|
|
+ showIconToast("覆盖列表");
|
|
|
await fetch(`/api/playlists/${playlist.id}`, {
|
|
await fetch(`/api/playlists/${playlist.id}`, {
|
|
|
method: "PUT",
|
|
method: "PUT",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
headers: { "Content-Type": "application/json" },
|
|
@@ -173,6 +449,7 @@ function renderPlaylists() {
|
|
|
await fetchLibrary();
|
|
await fetchLibrary();
|
|
|
};
|
|
};
|
|
|
deleteBtn.onclick = async () => {
|
|
deleteBtn.onclick = async () => {
|
|
|
|
|
+ showIconToast("删除列表");
|
|
|
await fetch(`/api/playlists/${playlist.id}`, { method: "DELETE" });
|
|
await fetch(`/api/playlists/${playlist.id}`, { method: "DELETE" });
|
|
|
await fetchLibrary();
|
|
await fetchLibrary();
|
|
|
};
|
|
};
|
|
@@ -180,11 +457,50 @@ function renderPlaylists() {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function updateToggleStates() {
|
|
|
|
|
+ document.getElementById("shuffleBtn").classList.toggle("active", state.shuffle);
|
|
|
|
|
+ document.getElementById("loopBtn").classList.toggle("active", state.loopMode !== "off");
|
|
|
|
|
+ document.getElementById("loopBtn").title = `循环: ${state.loopMode === "list" ? "列表" : state.loopMode === "one" ? "单曲" : "关"}`;
|
|
|
|
|
+ document.getElementById("shuffleBtn").title = `随机: ${state.shuffle ? "开" : "关"}`;
|
|
|
|
|
+ document.getElementById("loopBtn").dataset.tip = state.loopMode === "list" ? "列表循环" : state.loopMode === "one" ? "单曲循环" : "循环关闭";
|
|
|
|
|
+ document.getElementById("shuffleBtn").dataset.tip = state.shuffle ? "随机播放: 开" : "随机播放: 关";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function startQueue(tracks, index) {
|
|
function startQueue(tracks, index) {
|
|
|
|
|
+ if (!tracks.length) return;
|
|
|
state.currentQueue = [...tracks];
|
|
state.currentQueue = [...tracks];
|
|
|
state.currentTrackIndex = index;
|
|
state.currentTrackIndex = index;
|
|
|
renderQueue();
|
|
renderQueue();
|
|
|
playTrack(index);
|
|
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 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 ? "播放" : "暂停";
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function playTrack(index) {
|
|
function playTrack(index) {
|
|
@@ -193,58 +509,110 @@ function playTrack(index) {
|
|
|
const track = state.currentQueue[index];
|
|
const track = state.currentQueue[index];
|
|
|
audio.src = track.url;
|
|
audio.src = track.url;
|
|
|
audio.play();
|
|
audio.play();
|
|
|
- document.getElementById("nowTitle").textContent = track.name;
|
|
|
|
|
- document.getElementById("nowMeta").textContent = track.path;
|
|
|
|
|
|
|
+ updateDock(track);
|
|
|
renderQueue();
|
|
renderQueue();
|
|
|
|
|
+ savePlaybackState();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function nextTrack() {
|
|
|
|
|
|
|
+function playPrevious() {
|
|
|
if (!state.currentQueue.length) return;
|
|
if (!state.currentQueue.length) return;
|
|
|
- if (state.loopMode === "one") {
|
|
|
|
|
- playTrack(state.currentTrackIndex);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const prevIndex = state.currentTrackIndex > 0 ? state.currentTrackIndex - 1 : state.currentQueue.length - 1;
|
|
|
|
|
+ playTrack(prevIndex);
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
|
|
+function playNext() {
|
|
|
|
|
+ if (!state.currentQueue.length) return;
|
|
|
if (state.shuffle) {
|
|
if (state.shuffle) {
|
|
|
- const randomIndex = Math.floor(Math.random() * state.currentQueue.length);
|
|
|
|
|
- playTrack(randomIndex);
|
|
|
|
|
|
|
+ playTrack(Math.floor(Math.random() * state.currentQueue.length));
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ const nextIndex = state.currentTrackIndex + 1 < state.currentQueue.length ? state.currentTrackIndex + 1 : 0;
|
|
|
|
|
+ playTrack(nextIndex);
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- const nextIndex = state.currentTrackIndex + 1;
|
|
|
|
|
- if (nextIndex < state.currentQueue.length) {
|
|
|
|
|
- playTrack(nextIndex);
|
|
|
|
|
|
|
+function nextTrack() {
|
|
|
|
|
+ if (!state.currentQueue.length) return;
|
|
|
|
|
+ if (state.loopMode === "one") {
|
|
|
|
|
+ playTrack(state.currentTrackIndex);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- if (state.loopMode === "list") {
|
|
|
|
|
- playTrack(0);
|
|
|
|
|
|
|
+ if (state.loopMode === "off" && 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("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);
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
-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);
|
|
|
|
|
-};
|
|
|
|
|
|
|
+progressBar.addEventListener("input", () => {
|
|
|
|
|
+ if (!audio.duration) return;
|
|
|
|
|
+ audio.currentTime = (Number(progressBar.value) / 100) * audio.duration;
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
|
|
+document.getElementById("backHomeBtn").onclick = () => showView("home");
|
|
|
|
|
+document.getElementById("saveQueueBtn").onclick = () => renderQueue();
|
|
|
document.getElementById("shuffleBtn").onclick = () => {
|
|
document.getElementById("shuffleBtn").onclick = () => {
|
|
|
state.shuffle = !state.shuffle;
|
|
state.shuffle = !state.shuffle;
|
|
|
- toggleButton("shuffleBtn", state.shuffle, `随机: ${state.shuffle ? "开" : "关"}`);
|
|
|
|
|
|
|
+ updateToggleStates();
|
|
|
|
|
+ showIconToast(document.getElementById("shuffleBtn").dataset.tip);
|
|
|
|
|
+ savePlaybackState();
|
|
|
};
|
|
};
|
|
|
-
|
|
|
|
|
document.getElementById("loopBtn").onclick = () => {
|
|
document.getElementById("loopBtn").onclick = () => {
|
|
|
state.loopMode = state.loopMode === "list" ? "one" : state.loopMode === "one" ? "off" : "list";
|
|
state.loopMode = state.loopMode === "list" ? "one" : state.loopMode === "one" ? "off" : "list";
|
|
|
- const labels = { list: "循环: 列表", one: "循环: 单曲", off: "循环: 关" };
|
|
|
|
|
- toggleButton("loopBtn", state.loopMode !== "off", labels[state.loopMode]);
|
|
|
|
|
|
|
+ updateToggleStates();
|
|
|
|
|
+ showIconToast(document.getElementById("loopBtn").dataset.tip);
|
|
|
|
|
+ savePlaybackState();
|
|
|
|
|
+};
|
|
|
|
|
+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 () => {
|
|
document.getElementById("uploadBtn").onclick = async () => {
|
|
|
const files = document.getElementById("uploadInput").files;
|
|
const files = document.getElementById("uploadInput").files;
|
|
|
- if (!files.length) return alert("请选择文件");
|
|
|
|
|
|
|
+ if (!files.length) {
|
|
|
|
|
+ alert("请选择文件");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
const formData = new FormData();
|
|
const formData = new FormData();
|
|
|
Array.from(files).forEach((file) => formData.append("files", file));
|
|
Array.from(files).forEach((file) => formData.append("files", file));
|
|
|
formData.append("target_dir", document.getElementById("targetDir").value.trim());
|
|
formData.append("target_dir", document.getElementById("targetDir").value.trim());
|
|
@@ -255,7 +623,10 @@ document.getElementById("uploadBtn").onclick = async () => {
|
|
|
|
|
|
|
|
document.getElementById("createFolderBtn").onclick = async () => {
|
|
document.getElementById("createFolderBtn").onclick = async () => {
|
|
|
const path = document.getElementById("newFolderInput").value.trim();
|
|
const path = document.getElementById("newFolderInput").value.trim();
|
|
|
- if (!path) return alert("请输入目录名");
|
|
|
|
|
|
|
+ if (!path) {
|
|
|
|
|
+ alert("请输入目录名");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
await fetch("/api/folder", {
|
|
await fetch("/api/folder", {
|
|
|
method: "POST",
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
headers: { "Content-Type": "application/json" },
|
|
@@ -267,7 +638,10 @@ document.getElementById("createFolderBtn").onclick = async () => {
|
|
|
|
|
|
|
|
document.getElementById("createPlaylistBtn").onclick = async () => {
|
|
document.getElementById("createPlaylistBtn").onclick = async () => {
|
|
|
const name = document.getElementById("playlistNameInput").value.trim();
|
|
const name = document.getElementById("playlistNameInput").value.trim();
|
|
|
- if (!name) return alert("请输入播放列表名称");
|
|
|
|
|
|
|
+ if (!name) {
|
|
|
|
|
+ alert("请输入播放列表名称");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
await fetch("/api/playlists", {
|
|
await fetch("/api/playlists", {
|
|
|
method: "POST",
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
headers: { "Content-Type": "application/json" },
|
|
@@ -277,6 +651,5 @@ document.getElementById("createPlaylistBtn").onclick = async () => {
|
|
|
await fetchLibrary();
|
|
await fetchLibrary();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-document.getElementById("saveQueueBtn").onclick = () => renderQueue();
|
|
|
|
|
-
|
|
|
|
|
|
|
+updateToggleStates();
|
|
|
fetchLibrary();
|
|
fetchLibrary();
|