| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015 |
- const state = {
- library: null,
- libraries: {
- local: null,
- cloud: null,
- },
- activeSource: "local",
- cloudLoadingStarted: false,
- 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";
- const cloudLibraryCacheKey = "musicweb-cloud-library-cache-v1";
- let toastTimer = null;
- let featuredTimer = null;
- let playRequestId = 0;
- const iconMarkup = {
- play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7-11-7z"/></svg>',
- replace: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M4 12h10M4 17h7M18 9v8M14 13h8"/></svg>',
- remove: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 7h12M9 7V5h6v2M9 10v8M15 10v8M7 7l1 14h8l1-14"/></svg>',
- };
- function savePlaybackState() {
- const payload = {
- currentFolder: state.currentFolder,
- 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 || "";
- state.activeSource = saved.activeSource || (state.currentFolder.startsWith("cloud/") ? "cloud" : "local");
- updateToggleStates();
- syncLibraryTabs();
- applyActiveLibrary();
- setCurrentFolder(state.currentFolder);
- if (state.activeAlbumPath) {
- renderAlbumDetail(state.activeAlbumPath);
- }
- } catch {
- localStorage.removeItem("musicwebplayback");
- }
- }
- function readCloudLibraryCache() {
- const raw = localStorage.getItem(cloudLibraryCacheKey);
- if (!raw) return null;
- try {
- return JSON.parse(raw);
- } catch {
- localStorage.removeItem(cloudLibraryCacheKey);
- return null;
- }
- }
- function writeCloudLibraryCache(payload) {
- if (!payload) return;
- localStorage.setItem(cloudLibraryCacheKey, JSON.stringify(payload));
- }
- async function fetchLibrary() {
- const response = await fetch("/api/library/local");
- state.libraries.local = await response.json();
- state.library = state.libraries.local;
- applyActiveLibrary();
- renderPlaylists();
- restorePlaybackState();
- if (!state.cloudLoadingStarted) {
- state.cloudLoadingStarted = true;
- const cachedCloud = readCloudLibraryCache();
- if (cachedCloud) {
- state.libraries.cloud = cachedCloud;
- if (state.activeSource === "cloud") {
- applyActiveLibrary();
- }
- }
- queueMicrotask(() => fetchCloudLibrary());
- }
- }
- async function fetchCloudLibrary() {
- try {
- const response = await fetch("/api/library/cloud");
- state.libraries.cloud = await response.json();
- writeCloudLibraryCache(state.libraries.cloud);
- if (state.activeSource === "cloud") {
- applyActiveLibrary();
- }
- queueMicrotask(() => refreshCloudLibraryInBackground());
- } catch (error) {
- console.error("fetchCloudLibrary failed", error);
- }
- }
- async function refreshCloudLibraryInBackground() {
- try {
- const response = await fetch("/api/library/cloud/refresh");
- const payload = await response.json();
- const library = payload?.library;
- if (!library) return;
- state.libraries.cloud = library;
- writeCloudLibraryCache(library);
- if (state.activeSource === "cloud") {
- applyActiveLibrary();
- }
- if (payload?.started) {
- window.setTimeout(() => syncCloudLibraryAfterRefresh(), 1500);
- }
- } catch (error) {
- console.error("refreshCloudLibraryInBackground failed", error);
- }
- }
- async function syncCloudLibraryAfterRefresh() {
- try {
- const response = await fetch("/api/library/cloud");
- const payload = await response.json();
- state.libraries.cloud = payload;
- writeCloudLibraryCache(payload);
- if (state.activeSource === "cloud") {
- applyActiveLibrary();
- }
- if (payload?.cache?.refreshing) {
- window.setTimeout(() => syncCloudLibraryAfterRefresh(), 1500);
- }
- } catch (error) {
- console.error("syncCloudLibraryAfterRefresh failed", error);
- }
- }
- function applyActiveLibrary() {
- const fallback = state.libraries.local;
- state.library = state.libraries[state.activeSource] || fallback;
- if (!state.library) return;
- 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();
- if (state.activeAlbumPath) {
- renderAlbumDetail(state.activeAlbumPath);
- } else {
- showView("home");
- }
- startFeaturedCarousel();
- }
- function syncLibraryTabs() {
- document.querySelectorAll(".library-tab").forEach((button) => {
- button.classList.toggle("is-active", button.dataset.source === state.activeSource);
- });
- }
- function switchLibrary(source) {
- if (source === state.activeSource) return;
- state.activeSource = source;
- state.activeAlbumPath = "";
- state.detailTrackPage = 1;
- state.currentFolder = source === "cloud" ? "cloud" : "";
- syncLibraryTabs();
- applyActiveLibrary();
- setCurrentFolder(source === "cloud" ? "cloud" : "");
- savePlaybackState();
- if (source === "cloud") {
- if (!state.libraries.cloud) {
- const cachedCloud = readCloudLibraryCache();
- if (cachedCloud) {
- state.libraries.cloud = cachedCloud;
- applyActiveLibrary();
- }
- }
- if (!state.cloudLoadingStarted) {
- state.cloudLoadingStarted = true;
- fetchCloudLibrary();
- } else {
- refreshCloudLibraryInBackground();
- }
- }
- }
- 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 shortenFolderLabel(path, maxLength = window.innerWidth <= 480 ? 16 : 28) {
- if (!path) return "根目录";
- if (path.length <= maxLength) return path;
- return `${path.slice(0, maxLength)}...`;
- }
- function setCurrentFolder(path) {
- state.currentFolder = path;
- const folderText = path || "根目录";
- document.getElementById("folderIndicator").textContent = shortenFolderLabel(folderText);
- document.querySelector(".header-pill")?.setAttribute("title", `当前目录:${folderText}`);
- }
- 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
- ? `<img class="cover-image" src="${album.cover}" alt="${album.name}" />`
- : "";
- return `
- <div class="${className}${album.cover ? " has-cover" : ""}">
- ${imageMarkup}
- ${album.cover ? "" : `<span class="cover-name">${coverLabel(album.name)}</span>`}
- </div>
- `;
- }
- 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 = '<div class="empty-state">暂无可展示的专辑目录</div>';
- 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 = `
- <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);
- return 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;
- 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 = () => {
- appendToQueue(album.tracks);
- showIconToast("已加入播放列表");
- };
- 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 = `
- <button class="text-btn" data-action="prev" ${state.detailTrackPage === 1 ? "disabled" : ""}>上一页</button>
- <span class="pager-meta">${state.detailTrackPage} / ${totalPages}</span>
- <button class="text-btn" data-action="next" ${state.detailTrackPage === totalPages ? "disabled" : ""}>下一页</button>
- `;
- 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 = '<div class="empty-state">暂无最新更新</div>';
- 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 = '<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="播放">${iconMarkup.play}</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, 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("播放");
- if (options.compact) {
- startQueue([track], 0);
- return;
- }
- startQueue(sourceTracks, sourceIndex);
- };
- fragment.querySelector(".play-from-btn").onclick = () => {
- if (options.compact) {
- showIconToast("从此播放");
- const remainingTracks = sourceTracks.slice(sourceIndex);
- startQueue(remainingTracks, 0);
- return;
- }
- showIconToast("加入队列");
- state.currentQueue.push(track);
- renderQueue();
- savePlaybackState();
- };
- 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 = '<div class="empty-state">当前播放队列为空</div>';
- return;
- }
- state.currentQueue.forEach((track, index) => {
- 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>
- <p class="track-path">${track.path}</p>
- </div>
- <div class="track-actions compact-actions">
- <button class="icon-btn small-icon-btn play-btn" aria-label="播放">${iconMarkup.play}</button>
- <button class="icon-btn small-icon-btn move-btn" aria-label="移除">${iconMarkup.remove}</button>
- </div>
- `;
- 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 = '<div class="empty-state">还没有播放列表</div>';
- return;
- }
- 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 compact-actions">
- <button class="icon-btn small-icon-btn play-btn" aria-label="播放">${iconMarkup.play}</button>
- <button class="icon-btn small-icon-btn queue-btn" aria-label="覆盖">${iconMarkup.replace}</button>
- <button class="icon-btn small-icon-btn move-btn" aria-label="删除">${iconMarkup.remove}</button>
- </div>
- `;
- 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 = '<div class="empty-state">暂无专辑</div>';
- return;
- }
- state.albums.forEach((album) => {
- const button = document.createElement("button");
- button.className = "album-menu-item";
- button.innerHTML = `
- <span>${shortAlbumName(album.name)}</span>
- <small>${album.tracks.length} 首</small>
- `;
- 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: '<path d="M16 4h4v4M20 4l-5 5M4 7h5l6 6M4 17h5l2-2M15 15l5 5M20 20v-4"/>',
- },
- one: {
- label: "单曲循环",
- icon: '<path d="M17 2l4 4-4 4M3 11V9a3 3 0 0 1 3-3h15M7 22l-4-4 4-4M21 13v2a3 3 0 0 1-3 3H3"/><text x="12" y="16" text-anchor="middle">1</text>',
- },
- list: {
- label: "列表播放",
- icon: '<path d="M17 2l4 4-4 4M3 11V9a3 3 0 0 1 3-3h15M7 22l-4-4 4-4M21 13v2a3 3 0 0 1-3 3H3"/>',
- },
- once: {
- label: "单次播放",
- icon: '<path class="thick-icon-path" d="M5 12h13M14 8l4 4-4 4"/>',
- },
- };
- 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;
- state.activeSource = tracks[index]?.path?.startsWith("cloud/") ? "cloud" : "local";
- syncLibraryTabs();
- renderQueue();
- playTrack(index);
- savePlaybackState();
- }
- function appendToQueue(tracks, options = {}) {
- if (!tracks.length) return;
- const { playNow = false, startIndex = 0 } = options;
- const insertAt = state.currentQueue.length;
- state.currentQueue.push(...tracks);
- renderQueue();
- savePlaybackState();
- if (playNow) {
- playTrack(insertAt + startIndex);
- }
- }
- 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/")) return 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;
- const requestId = ++playRequestId;
- state.currentTrackIndex = index;
- const track = state.currentQueue[index];
- state.activeSource = track?.path?.startsWith("cloud/") ? "cloud" : "local";
- syncLibraryTabs();
- try {
- const resolvedUrl = await getCachedAudioUrl(track);
- if (requestId !== playRequestId) return;
- audio.pause();
- audio.removeAttribute("src");
- audio.load();
- audio.src = resolvedUrl;
- await audio.play();
- } catch (error) {
- if (requestId !== playRequestId) return;
- console.error("playTrack failed", track?.path, error);
- audio.pause();
- audio.removeAttribute("src");
- audio.load();
- audio.src = track.url;
- audio.play().catch((fallbackError) => {
- console.error("fallback play failed", track?.path, fallbackError);
- });
- }
- if (requestId !== playRequestId) return;
- 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("error", () => {
- const currentTrack = state.currentQueue[state.currentTrackIndex];
- console.error("audio error", currentTrack?.path, audio.error);
- if (state.currentQueue.length > 1) {
- nextTrack();
- } else {
- syncPlayButton();
- }
- });
- 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();
- };
- document.querySelectorAll(".library-tab").forEach((button) => {
- button.onclick = () => switchLibrary(button.dataset.source);
- });
- updateToggleStates();
- fetchLibrary();
|