app.js 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015
  1. const state = {
  2. library: null,
  3. libraries: {
  4. local: null,
  5. cloud: null,
  6. },
  7. activeSource: "local",
  8. cloudLoadingStarted: false,
  9. currentFolder: "",
  10. currentQueue: [],
  11. currentTrackIndex: -1,
  12. playMode: "list",
  13. albums: [],
  14. currentView: "home",
  15. activeAlbumPath: "",
  16. featuredAlbums: [],
  17. featuredIndex: 0,
  18. detailTrackPage: 1,
  19. };
  20. const audio = document.getElementById("audioPlayer");
  21. const progressBar = document.getElementById("progressBar");
  22. const audioCacheName = "musicweb-audio-cache-v1";
  23. const cloudLibraryCacheKey = "musicweb-cloud-library-cache-v1";
  24. let toastTimer = null;
  25. let featuredTimer = null;
  26. let playRequestId = 0;
  27. const iconMarkup = {
  28. play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7-11-7z"/></svg>',
  29. replace: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M4 12h10M4 17h7M18 9v8M14 13h8"/></svg>',
  30. remove: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 7h12M9 7V5h6v2M9 10v8M15 10v8M7 7l1 14h8l1-14"/></svg>',
  31. };
  32. function savePlaybackState() {
  33. const payload = {
  34. currentFolder: state.currentFolder,
  35. playMode: state.playMode,
  36. activeAlbumPath: state.activeAlbumPath,
  37. };
  38. localStorage.setItem("musicwebplayback", JSON.stringify(payload));
  39. }
  40. function showIconToast(text) {
  41. const toast = document.getElementById("iconToast");
  42. toast.textContent = text;
  43. toast.classList.remove("is-hidden");
  44. if (toastTimer) clearTimeout(toastTimer);
  45. toastTimer = setTimeout(() => toast.classList.add("is-hidden"), 1200);
  46. }
  47. function restorePlaybackState() {
  48. const raw = localStorage.getItem("musicwebplayback");
  49. if (!raw || !state.library) return;
  50. try {
  51. const saved = JSON.parse(raw);
  52. state.playMode = saved.playMode || (saved.shuffle ? "shuffle" : saved.loopMode === "one" ? "one" : saved.loopMode === "off" ? "once" : "list");
  53. state.currentFolder = saved.currentFolder || "";
  54. state.activeAlbumPath = saved.activeAlbumPath || "";
  55. state.activeSource = saved.activeSource || (state.currentFolder.startsWith("cloud/") ? "cloud" : "local");
  56. updateToggleStates();
  57. syncLibraryTabs();
  58. applyActiveLibrary();
  59. setCurrentFolder(state.currentFolder);
  60. if (state.activeAlbumPath) {
  61. renderAlbumDetail(state.activeAlbumPath);
  62. }
  63. } catch {
  64. localStorage.removeItem("musicwebplayback");
  65. }
  66. }
  67. function readCloudLibraryCache() {
  68. const raw = localStorage.getItem(cloudLibraryCacheKey);
  69. if (!raw) return null;
  70. try {
  71. return JSON.parse(raw);
  72. } catch {
  73. localStorage.removeItem(cloudLibraryCacheKey);
  74. return null;
  75. }
  76. }
  77. function writeCloudLibraryCache(payload) {
  78. if (!payload) return;
  79. localStorage.setItem(cloudLibraryCacheKey, JSON.stringify(payload));
  80. }
  81. async function fetchLibrary() {
  82. const response = await fetch("/api/library/local");
  83. state.libraries.local = await response.json();
  84. state.library = state.libraries.local;
  85. applyActiveLibrary();
  86. renderPlaylists();
  87. restorePlaybackState();
  88. if (!state.cloudLoadingStarted) {
  89. state.cloudLoadingStarted = true;
  90. const cachedCloud = readCloudLibraryCache();
  91. if (cachedCloud) {
  92. state.libraries.cloud = cachedCloud;
  93. if (state.activeSource === "cloud") {
  94. applyActiveLibrary();
  95. }
  96. }
  97. queueMicrotask(() => fetchCloudLibrary());
  98. }
  99. }
  100. async function fetchCloudLibrary() {
  101. try {
  102. const response = await fetch("/api/library/cloud");
  103. state.libraries.cloud = await response.json();
  104. writeCloudLibraryCache(state.libraries.cloud);
  105. if (state.activeSource === "cloud") {
  106. applyActiveLibrary();
  107. }
  108. queueMicrotask(() => refreshCloudLibraryInBackground());
  109. } catch (error) {
  110. console.error("fetchCloudLibrary failed", error);
  111. }
  112. }
  113. async function refreshCloudLibraryInBackground() {
  114. try {
  115. const response = await fetch("/api/library/cloud/refresh");
  116. const payload = await response.json();
  117. const library = payload?.library;
  118. if (!library) return;
  119. state.libraries.cloud = library;
  120. writeCloudLibraryCache(library);
  121. if (state.activeSource === "cloud") {
  122. applyActiveLibrary();
  123. }
  124. if (payload?.started) {
  125. window.setTimeout(() => syncCloudLibraryAfterRefresh(), 1500);
  126. }
  127. } catch (error) {
  128. console.error("refreshCloudLibraryInBackground failed", error);
  129. }
  130. }
  131. async function syncCloudLibraryAfterRefresh() {
  132. try {
  133. const response = await fetch("/api/library/cloud");
  134. const payload = await response.json();
  135. state.libraries.cloud = payload;
  136. writeCloudLibraryCache(payload);
  137. if (state.activeSource === "cloud") {
  138. applyActiveLibrary();
  139. }
  140. if (payload?.cache?.refreshing) {
  141. window.setTimeout(() => syncCloudLibraryAfterRefresh(), 1500);
  142. }
  143. } catch (error) {
  144. console.error("syncCloudLibraryAfterRefresh failed", error);
  145. }
  146. }
  147. function applyActiveLibrary() {
  148. const fallback = state.libraries.local;
  149. state.library = state.libraries[state.activeSource] || fallback;
  150. if (!state.library) return;
  151. state.albums = buildAlbums(state.library.tree);
  152. state.featuredAlbums = buildFeaturedAlbums(state.albums);
  153. state.featuredIndex = 0;
  154. document.getElementById("albumCount").textContent = state.albums.length;
  155. document.getElementById("trackCount").textContent = state.library.all_tracks.length;
  156. renderHome();
  157. renderPlaylists();
  158. if (state.activeAlbumPath) {
  159. renderAlbumDetail(state.activeAlbumPath);
  160. } else {
  161. showView("home");
  162. }
  163. startFeaturedCarousel();
  164. }
  165. function syncLibraryTabs() {
  166. document.querySelectorAll(".library-tab").forEach((button) => {
  167. button.classList.toggle("is-active", button.dataset.source === state.activeSource);
  168. });
  169. }
  170. function switchLibrary(source) {
  171. if (source === state.activeSource) return;
  172. state.activeSource = source;
  173. state.activeAlbumPath = "";
  174. state.detailTrackPage = 1;
  175. state.currentFolder = source === "cloud" ? "cloud" : "";
  176. syncLibraryTabs();
  177. applyActiveLibrary();
  178. setCurrentFolder(source === "cloud" ? "cloud" : "");
  179. savePlaybackState();
  180. if (source === "cloud") {
  181. if (!state.libraries.cloud) {
  182. const cachedCloud = readCloudLibraryCache();
  183. if (cachedCloud) {
  184. state.libraries.cloud = cachedCloud;
  185. applyActiveLibrary();
  186. }
  187. }
  188. if (!state.cloudLoadingStarted) {
  189. state.cloudLoadingStarted = true;
  190. fetchCloudLibrary();
  191. } else {
  192. refreshCloudLibraryInBackground();
  193. }
  194. }
  195. }
  196. function buildAlbums(tree) {
  197. const albums = [];
  198. function visit(node, ancestors = []) {
  199. const tracks = flattenNodeTracks(node);
  200. const childAlbums = node.folders.filter((child) => flattenNodeTracks(child).length);
  201. if (node.path && tracks.length && !childAlbums.length) {
  202. const nameParts = [...ancestors, node.name].filter(Boolean);
  203. albums.push({
  204. name: nameParts.join("") || "未命名专辑",
  205. path: node.path,
  206. cover: node.cover || null,
  207. tracks,
  208. });
  209. }
  210. const nextAncestors = node.path ? [...ancestors, node.name] : ancestors;
  211. node.folders.forEach((child) => visit(child, nextAncestors));
  212. }
  213. visit(tree);
  214. return albums;
  215. }
  216. function flattenNodeTracks(node) {
  217. const tracks = [...node.tracks];
  218. node.folders.forEach((child) => tracks.push(...flattenNodeTracks(child)));
  219. return tracks;
  220. }
  221. function folderTracks(path) {
  222. const album = state.albums.find((item) => item.path === path);
  223. return album ? [...album.tracks] : [];
  224. }
  225. function shortenFolderLabel(path, maxLength = window.innerWidth <= 480 ? 16 : 28) {
  226. if (!path) return "根目录";
  227. if (path.length <= maxLength) return path;
  228. return `${path.slice(0, maxLength)}...`;
  229. }
  230. function setCurrentFolder(path) {
  231. state.currentFolder = path;
  232. const folderText = path || "根目录";
  233. document.getElementById("folderIndicator").textContent = shortenFolderLabel(folderText);
  234. document.querySelector(".header-pill")?.setAttribute("title", `当前目录:${folderText}`);
  235. }
  236. function showView(name) {
  237. state.currentView = name;
  238. document.getElementById("homeView").classList.toggle("is-hidden", name !== "home");
  239. document.getElementById("detailView").classList.toggle("is-hidden", name !== "detail");
  240. savePlaybackState();
  241. }
  242. function scrollToPageTop() {
  243. requestAnimationFrame(() => {
  244. const scroller = document.scrollingElement || document.documentElement;
  245. scroller.scrollTop = 0;
  246. scroller.scrollLeft = 0;
  247. window.scrollTo(0, 0);
  248. });
  249. }
  250. function coverLabel(name) {
  251. return name.length > 12 ? `${name.slice(0, 12)}...` : name;
  252. }
  253. function shortAlbumName(name) {
  254. return name.length > 8 ? `${name.slice(0, 8)}...` : name;
  255. }
  256. function buildCoverMarkup(album, className) {
  257. const imageMarkup = album.cover
  258. ? `<img class="cover-image" src="${album.cover}" alt="${album.name}" />`
  259. : "";
  260. return `
  261. <div class="${className}${album.cover ? " has-cover" : ""}">
  262. ${imageMarkup}
  263. ${album.cover ? "" : `<span class="cover-name">${coverLabel(album.name)}</span>`}
  264. </div>
  265. `;
  266. }
  267. function renderHome() {
  268. const latestTracks = [...state.library.all_tracks].slice(-8).reverse();
  269. const rankingAlbums = [...state.albums].sort((a, b) => b.tracks.length - a.tracks.length).slice(0, 6);
  270. renderFeaturedAlbums(state.featuredAlbums);
  271. renderAlbumGrid(state.albums);
  272. renderLatestList(latestTracks);
  273. renderRankingList(rankingAlbums);
  274. renderAlbumMenu();
  275. renderQueue();
  276. }
  277. function buildAlbumCardActions(card, album) {
  278. const detailBtn = card.querySelector("[data-action='detail']");
  279. const playBtn = card.querySelector("[data-action='play']");
  280. detailBtn.onclick = () => renderAlbumDetail(album.path);
  281. playBtn.onclick = (event) => {
  282. event.stopPropagation();
  283. setCurrentFolder(album.path);
  284. startQueue(album.tracks, 0);
  285. };
  286. card.onclick = () => renderAlbumDetail(album.path);
  287. }
  288. function renderFeaturedAlbums(items) {
  289. const container = document.getElementById("featuredAlbums");
  290. container.innerHTML = "";
  291. if (!items.length) {
  292. container.innerHTML = '<div class="empty-state">暂无可展示的专辑目录</div>';
  293. return;
  294. }
  295. const album = items[state.featuredIndex] || items[0];
  296. container.appendChild(buildFeaturedAlbumCard(album));
  297. }
  298. function buildFeaturedAlbums(albums) {
  299. const sorted = [...albums].sort((a, b) => b.tracks.length - a.tracks.length);
  300. if (sorted.length <= 5) return sorted;
  301. return sorted.slice(0, 8);
  302. }
  303. function startFeaturedCarousel() {
  304. if (featuredTimer) clearInterval(featuredTimer);
  305. if (state.featuredAlbums.length <= 1) return;
  306. featuredTimer = setInterval(() => {
  307. state.featuredIndex = (state.featuredIndex + 1) % state.featuredAlbums.length;
  308. renderFeaturedAlbums(state.featuredAlbums);
  309. }, 3000);
  310. }
  311. function buildFeaturedAlbumCard(album) {
  312. const card = document.createElement("article");
  313. card.className = "feature-card";
  314. card.innerHTML = `
  315. <div class="feature-card-inner">
  316. ${buildCoverMarkup(album, "feature-cover")}
  317. <div class="feature-body">
  318. <div>
  319. <h3>${album.name}</h3>
  320. <p class="meta-row">${album.tracks.length} 首歌曲 · ${album.path}</p>
  321. </div>
  322. <p class="subtext">点击进入详细目录页,查看歌曲列表并播放。</p>
  323. <div class="card-actions">
  324. <button class="primary-btn" data-action="detail">进入目录</button>
  325. <button class="secondary-btn" data-action="play">播放</button>
  326. </div>
  327. </div>
  328. </div>
  329. `;
  330. buildAlbumCardActions(card, album);
  331. return card;
  332. }
  333. function renderAlbumGrid(items) {
  334. const container = document.getElementById("albumGrid");
  335. container.innerHTML = "";
  336. if (!items.length) {
  337. container.innerHTML = '<div class="empty-state">当前音乐库还没有专辑目录</div>';
  338. return;
  339. }
  340. items.forEach((album) => {
  341. const card = document.createElement("article");
  342. card.className = "album-card";
  343. card.innerHTML = `
  344. ${buildCoverMarkup(album, "album-cover")}
  345. <div class="album-body">
  346. <div>
  347. <h3>${album.name}</h3>
  348. <p class="meta-row">${album.tracks.length} 首歌曲</p>
  349. </div>
  350. <div class="card-actions">
  351. <button class="secondary-btn" data-action="detail">进入</button>
  352. <button class="text-btn" data-action="play">播放</button>
  353. </div>
  354. </div>
  355. `;
  356. buildAlbumCardActions(card, album);
  357. container.appendChild(card);
  358. });
  359. }
  360. function renderAlbumDetail(path) {
  361. const album = state.albums.find((item) => item.path === path);
  362. if (!album) return;
  363. if (state.activeAlbumPath !== path) {
  364. state.detailTrackPage = 1;
  365. }
  366. state.activeAlbumPath = path;
  367. setCurrentFolder(path);
  368. showView("detail");
  369. scrollToPageTop();
  370. document.getElementById("detailTitle").textContent = album.name;
  371. document.getElementById("detailMeta").textContent = `${album.path} · ${album.tracks.length} 首歌曲`;
  372. document.getElementById("detailTrackCount").textContent = `${album.tracks.length} 首`;
  373. const coverImage = document.getElementById("detailCoverImage");
  374. const coverFallback = document.getElementById("detailCoverFallback");
  375. if (album.cover) {
  376. coverImage.src = album.cover;
  377. coverImage.classList.remove("is-hidden");
  378. coverFallback.classList.add("is-hidden");
  379. } else {
  380. coverImage.removeAttribute("src");
  381. coverImage.classList.add("is-hidden");
  382. coverFallback.classList.remove("is-hidden");
  383. coverFallback.textContent = coverLabel(album.name);
  384. }
  385. document.getElementById("detailPlayBtn").onclick = () => startQueue(album.tracks, 0);
  386. document.getElementById("detailPlayAllBtn").onclick = () => {
  387. appendToQueue(album.tracks);
  388. showIconToast("已加入播放列表");
  389. };
  390. const container = document.getElementById("detailTrackList");
  391. container.innerHTML = "";
  392. renderAlbumTracks(container, album.tracks);
  393. }
  394. function renderAlbumTracks(container, tracks) {
  395. const pageSize = 30;
  396. const totalPages = Math.max(1, Math.ceil(tracks.length / pageSize));
  397. state.detailTrackPage = Math.min(Math.max(state.detailTrackPage || 1, 1), totalPages);
  398. const start = (state.detailTrackPage - 1) * pageSize;
  399. const visibleTracks = tracks.slice(start, start + pageSize);
  400. visibleTracks.forEach((track, index) => container.appendChild(renderTrack(track, tracks, start + index, { compact: true })));
  401. if (tracks.length <= pageSize) return;
  402. const pager = document.createElement("div");
  403. pager.className = "pager";
  404. pager.innerHTML = `
  405. <button class="text-btn" data-action="prev" ${state.detailTrackPage === 1 ? "disabled" : ""}>上一页</button>
  406. <span class="pager-meta">${state.detailTrackPage} / ${totalPages}</span>
  407. <button class="text-btn" data-action="next" ${state.detailTrackPage === totalPages ? "disabled" : ""}>下一页</button>
  408. `;
  409. pager.querySelector("[data-action='prev']").onclick = () => {
  410. if (state.detailTrackPage <= 1) return;
  411. state.detailTrackPage -= 1;
  412. renderAlbumDetail(state.activeAlbumPath);
  413. };
  414. pager.querySelector("[data-action='next']").onclick = () => {
  415. if (state.detailTrackPage >= totalPages) return;
  416. state.detailTrackPage += 1;
  417. renderAlbumDetail(state.activeAlbumPath);
  418. };
  419. container.appendChild(pager);
  420. }
  421. function renderLatestList(tracks) {
  422. const container = document.getElementById("latestList");
  423. container.innerHTML = "";
  424. if (!tracks.length) {
  425. container.innerHTML = '<div class="empty-state">暂无最新更新</div>';
  426. return;
  427. }
  428. tracks.forEach((track, index) => container.appendChild(renderTrack(track, tracks, index)));
  429. }
  430. function renderRankingList(items) {
  431. const container = document.getElementById("rankingList");
  432. container.innerHTML = "";
  433. if (!items.length) {
  434. container.innerHTML = '<div class="empty-state">暂无排名数据</div>';
  435. return;
  436. }
  437. items.forEach((album, index) => {
  438. const card = document.createElement("div");
  439. card.className = "ranking-card";
  440. card.innerHTML = `
  441. <span class="rank-badge">${index + 1}</span>
  442. <div class="ranking-main">
  443. <h3>${album.name}</h3>
  444. <p class="ranking-note">${album.tracks.length} 首歌曲</p>
  445. </div>
  446. <div class="track-actions compact-actions">
  447. <button class="icon-btn small-icon-btn play-btn" aria-label="播放">${iconMarkup.play}</button>
  448. </div>
  449. `;
  450. card.onclick = () => renderAlbumDetail(album.path);
  451. card.querySelector("button").onclick = (event) => {
  452. event.stopPropagation();
  453. showIconToast("播放");
  454. setCurrentFolder(album.path);
  455. startQueue(album.tracks, 0);
  456. };
  457. container.appendChild(card);
  458. });
  459. }
  460. function renderTrack(track, sourceTracks = [track], sourceIndex = 0, options = {}) {
  461. const template = document.getElementById("trackItemTemplate");
  462. const fragment = template.content.cloneNode(true);
  463. const row = fragment.querySelector(".song-row");
  464. const trackPath = fragment.querySelector(".track-path");
  465. fragment.querySelector(".track-name").textContent = track.name;
  466. trackPath.textContent = track.path;
  467. if (options.compact) {
  468. row.classList.add("detail-track-row");
  469. }
  470. fragment.querySelector(".play-btn").onclick = () => {
  471. showIconToast("播放");
  472. if (options.compact) {
  473. startQueue([track], 0);
  474. return;
  475. }
  476. startQueue(sourceTracks, sourceIndex);
  477. };
  478. fragment.querySelector(".play-from-btn").onclick = () => {
  479. if (options.compact) {
  480. showIconToast("从此播放");
  481. const remainingTracks = sourceTracks.slice(sourceIndex);
  482. startQueue(remainingTracks, 0);
  483. return;
  484. }
  485. showIconToast("加入队列");
  486. state.currentQueue.push(track);
  487. renderQueue();
  488. savePlaybackState();
  489. };
  490. fragment.querySelector(".queue-btn").onclick = () => {
  491. showIconToast("加入队列");
  492. state.currentQueue.push(track);
  493. renderQueue();
  494. savePlaybackState();
  495. };
  496. fragment.querySelector(".move-btn").onclick = async () => {
  497. showIconToast("移动歌曲");
  498. const destination = prompt("移动到哪个目录?输入相对 mp3file 的目录路径", track.folder || "");
  499. if (destination === null) return;
  500. await fetch("/api/move", {
  501. method: "POST",
  502. headers: { "Content-Type": "application/json" },
  503. body: JSON.stringify({ source: track.path, destination_dir: destination }),
  504. });
  505. await fetchLibrary();
  506. };
  507. return fragment;
  508. }
  509. function renderQueue() {
  510. const container = document.getElementById("queueList");
  511. container.innerHTML = "";
  512. if (!state.currentQueue.length) {
  513. container.innerHTML = '<div class="empty-state">当前播放队列为空</div>';
  514. return;
  515. }
  516. state.currentQueue.forEach((track, index) => {
  517. const row = document.createElement("div");
  518. row.className = `song-row${index === state.currentTrackIndex ? " is-active" : ""}`;
  519. row.innerHTML = `
  520. <div class="song-copy">
  521. <strong class="track-name">${track.name}</strong>
  522. <p class="track-path">${track.path}</p>
  523. </div>
  524. <div class="track-actions compact-actions">
  525. <button class="icon-btn small-icon-btn play-btn" aria-label="播放">${iconMarkup.play}</button>
  526. <button class="icon-btn small-icon-btn move-btn" aria-label="移除">${iconMarkup.remove}</button>
  527. </div>
  528. `;
  529. const [playBtn, removeBtn] = row.querySelectorAll("button");
  530. playBtn.onclick = () => {
  531. showIconToast("播放");
  532. playTrack(index);
  533. };
  534. removeBtn.onclick = () => {
  535. showIconToast("移除");
  536. state.currentQueue.splice(index, 1);
  537. if (state.currentTrackIndex >= state.currentQueue.length) {
  538. state.currentTrackIndex = state.currentQueue.length - 1;
  539. }
  540. renderQueue();
  541. savePlaybackState();
  542. };
  543. container.appendChild(row);
  544. });
  545. }
  546. function clearQueue() {
  547. state.currentQueue = [];
  548. clearCurrentTrack();
  549. }
  550. function renderPlaylists() {
  551. const container = document.getElementById("playlistList");
  552. container.innerHTML = "";
  553. if (!state.library.playlists.length) {
  554. container.innerHTML = '<div class="empty-state">还没有播放列表</div>';
  555. return;
  556. }
  557. state.library.playlists.forEach((playlist) => {
  558. const item = document.createElement("div");
  559. item.className = "playlist-item";
  560. item.innerHTML = `
  561. <div>
  562. <strong>${playlist.name}</strong>
  563. <p>${playlist.tracks.length} 首歌曲</p>
  564. </div>
  565. <div class="track-actions compact-actions">
  566. <button class="icon-btn small-icon-btn play-btn" aria-label="播放">${iconMarkup.play}</button>
  567. <button class="icon-btn small-icon-btn queue-btn" aria-label="覆盖">${iconMarkup.replace}</button>
  568. <button class="icon-btn small-icon-btn move-btn" aria-label="删除">${iconMarkup.remove}</button>
  569. </div>
  570. `;
  571. const [playBtn, replaceBtn, deleteBtn] = item.querySelectorAll("button");
  572. playBtn.onclick = () => {
  573. showIconToast("播放列表");
  574. const tracks = playlist.tracks
  575. .map((path) => state.library.all_tracks.find((track) => track.path === path))
  576. .filter(Boolean);
  577. startQueue(tracks, 0);
  578. };
  579. replaceBtn.onclick = async () => {
  580. showIconToast("覆盖列表");
  581. await fetch(`/api/playlists/${playlist.id}`, {
  582. method: "PUT",
  583. headers: { "Content-Type": "application/json" },
  584. body: JSON.stringify({ tracks: state.currentQueue.map((track) => track.path) }),
  585. });
  586. await fetchLibrary();
  587. };
  588. deleteBtn.onclick = async () => {
  589. showIconToast("删除列表");
  590. await fetch(`/api/playlists/${playlist.id}`, { method: "DELETE" });
  591. await fetchLibrary();
  592. };
  593. container.appendChild(item);
  594. });
  595. }
  596. function renderAlbumMenu() {
  597. const container = document.getElementById("albumMenuList");
  598. if (!container) return;
  599. container.innerHTML = "";
  600. if (!state.albums.length) {
  601. container.innerHTML = '<div class="empty-state">暂无专辑</div>';
  602. return;
  603. }
  604. state.albums.forEach((album) => {
  605. const button = document.createElement("button");
  606. button.className = "album-menu-item";
  607. button.innerHTML = `
  608. <span>${shortAlbumName(album.name)}</span>
  609. <small>${album.tracks.length} 首</small>
  610. `;
  611. button.onclick = () => {
  612. setCurrentFolder(album.path);
  613. renderAlbumDetail(album.path);
  614. toggleAlbumMenu(false);
  615. };
  616. container.appendChild(button);
  617. });
  618. }
  619. function toggleAlbumMenu(forceOpen) {
  620. const overlay = document.getElementById("albumMenuOverlay");
  621. const open = typeof forceOpen === "boolean" ? forceOpen : overlay.classList.contains("is-hidden");
  622. overlay.classList.toggle("is-hidden", !open);
  623. }
  624. function updateToggleStates() {
  625. const modes = {
  626. shuffle: {
  627. label: "随机播放",
  628. icon: '<path d="M16 4h4v4M20 4l-5 5M4 7h5l6 6M4 17h5l2-2M15 15l5 5M20 20v-4"/>',
  629. },
  630. one: {
  631. label: "单曲循环",
  632. 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>',
  633. },
  634. list: {
  635. label: "列表播放",
  636. 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"/>',
  637. },
  638. once: {
  639. label: "单次播放",
  640. icon: '<path class="thick-icon-path" d="M5 12h13M14 8l4 4-4 4"/>',
  641. },
  642. };
  643. const mode = modes[state.playMode] || modes.list;
  644. const button = document.getElementById("playModeBtn");
  645. const icon = document.getElementById("playModeIcon");
  646. button.dataset.tip = mode.label;
  647. button.title = mode.label;
  648. icon.innerHTML = mode.icon;
  649. }
  650. function startQueue(tracks, index) {
  651. if (!tracks.length) return;
  652. state.currentQueue = [...tracks];
  653. state.currentTrackIndex = index;
  654. state.activeSource = tracks[index]?.path?.startsWith("cloud/") ? "cloud" : "local";
  655. syncLibraryTabs();
  656. renderQueue();
  657. playTrack(index);
  658. savePlaybackState();
  659. }
  660. function appendToQueue(tracks, options = {}) {
  661. if (!tracks.length) return;
  662. const { playNow = false, startIndex = 0 } = options;
  663. const insertAt = state.currentQueue.length;
  664. state.currentQueue.push(...tracks);
  665. renderQueue();
  666. savePlaybackState();
  667. if (playNow) {
  668. playTrack(insertAt + startIndex);
  669. }
  670. }
  671. function updateDock(track) {
  672. document.getElementById("nowTitle").textContent = track.name;
  673. document.getElementById("nowMeta").textContent = track.path;
  674. document.getElementById("dockTitle").textContent = track.name;
  675. document.getElementById("dockPath").textContent = track.path;
  676. const coverImg = document.getElementById("dockCoverImage");
  677. const fallback = document.getElementById("dockCoverFallback");
  678. const album = state.albums.find((item) => item.path === track.folder) || null;
  679. if (album && album.cover) {
  680. coverImg.src = album.cover;
  681. coverImg.classList.remove("is-hidden");
  682. fallback.classList.add("is-hidden");
  683. } else {
  684. coverImg.removeAttribute("src");
  685. coverImg.classList.add("is-hidden");
  686. fallback.classList.remove("is-hidden");
  687. fallback.textContent = (track.name || "乐").charAt(0).toUpperCase();
  688. }
  689. }
  690. function clearCurrentTrack() {
  691. audio.pause();
  692. audio.removeAttribute("src");
  693. audio.load();
  694. state.currentTrackIndex = -1;
  695. state.activeAlbumPath = "";
  696. state.detailTrackPage = 1;
  697. document.getElementById("nowTitle").textContent = "选择一个专辑目录开始播放";
  698. document.getElementById("nowMeta").textContent = "点击专辑进入详情页,或直接在卡片上快速播放。";
  699. document.getElementById("dockTitle").textContent = "未开始播放";
  700. document.getElementById("dockPath").textContent = "点击专辑或歌曲开始播放";
  701. document.getElementById("currentTimeLabel").textContent = "00:00";
  702. document.getElementById("durationLabel").textContent = "00:00";
  703. progressBar.value = 0;
  704. const coverImg = document.getElementById("dockCoverImage");
  705. const fallback = document.getElementById("dockCoverFallback");
  706. coverImg.removeAttribute("src");
  707. coverImg.classList.add("is-hidden");
  708. fallback.classList.remove("is-hidden");
  709. fallback.textContent = "乐";
  710. renderQueue();
  711. syncPlayButton();
  712. savePlaybackState();
  713. }
  714. function syncPlayButton() {
  715. document.getElementById("playToggleBtn").classList.toggle("is-playing", !audio.paused && !audio.ended);
  716. document.querySelector(".icon-play").classList.toggle("is-hidden", !audio.paused && !audio.ended);
  717. document.querySelector(".icon-pause").classList.toggle("is-hidden", audio.paused || audio.ended);
  718. document.getElementById("playToggleBtn").dataset.tip = audio.paused || audio.ended ? "播放" : "暂停";
  719. }
  720. async function getCachedAudioUrl(track) {
  721. if (track.path?.startsWith("cloud/")) return track.url;
  722. if (!("caches" in window)) return track.url;
  723. try {
  724. const cache = await caches.open(audioCacheName);
  725. const cached = await cache.match(track.url);
  726. if (cached) {
  727. const blob = await cached.blob();
  728. return URL.createObjectURL(blob);
  729. }
  730. fetch(track.url, { credentials: "same-origin" })
  731. .then((response) => {
  732. if (response.ok) cache.put(track.url, response.clone());
  733. })
  734. .catch(() => {});
  735. } catch {
  736. return track.url;
  737. }
  738. return track.url;
  739. }
  740. async function playTrack(index) {
  741. if (!state.currentQueue.length) return;
  742. const requestId = ++playRequestId;
  743. state.currentTrackIndex = index;
  744. const track = state.currentQueue[index];
  745. state.activeSource = track?.path?.startsWith("cloud/") ? "cloud" : "local";
  746. syncLibraryTabs();
  747. try {
  748. const resolvedUrl = await getCachedAudioUrl(track);
  749. if (requestId !== playRequestId) return;
  750. audio.pause();
  751. audio.removeAttribute("src");
  752. audio.load();
  753. audio.src = resolvedUrl;
  754. await audio.play();
  755. } catch (error) {
  756. if (requestId !== playRequestId) return;
  757. console.error("playTrack failed", track?.path, error);
  758. audio.pause();
  759. audio.removeAttribute("src");
  760. audio.load();
  761. audio.src = track.url;
  762. audio.play().catch((fallbackError) => {
  763. console.error("fallback play failed", track?.path, fallbackError);
  764. });
  765. }
  766. if (requestId !== playRequestId) return;
  767. updateDock(track);
  768. renderQueue();
  769. savePlaybackState();
  770. }
  771. function playPrevious() {
  772. if (!state.currentQueue.length) return;
  773. const prevIndex = state.currentTrackIndex > 0 ? state.currentTrackIndex - 1 : state.currentQueue.length - 1;
  774. playTrack(prevIndex);
  775. }
  776. function playNext() {
  777. if (!state.currentQueue.length) return;
  778. if (state.playMode === "shuffle") {
  779. playTrack(Math.floor(Math.random() * state.currentQueue.length));
  780. return;
  781. }
  782. const nextIndex = state.currentTrackIndex + 1 < state.currentQueue.length ? state.currentTrackIndex + 1 : 0;
  783. playTrack(nextIndex);
  784. }
  785. function nextTrack() {
  786. if (!state.currentQueue.length) return;
  787. if (state.playMode === "one") {
  788. playTrack(state.currentTrackIndex);
  789. return;
  790. }
  791. if (state.playMode === "once" || state.currentTrackIndex === state.currentQueue.length - 1) {
  792. audio.pause();
  793. syncPlayButton();
  794. return;
  795. }
  796. playNext();
  797. }
  798. function formatTime(seconds) {
  799. if (!Number.isFinite(seconds)) return "00:00";
  800. const minutes = Math.floor(seconds / 60);
  801. const secs = Math.floor(seconds % 60);
  802. return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
  803. }
  804. audio.addEventListener("ended", nextTrack);
  805. audio.addEventListener("error", () => {
  806. const currentTrack = state.currentQueue[state.currentTrackIndex];
  807. console.error("audio error", currentTrack?.path, audio.error);
  808. if (state.currentQueue.length > 1) {
  809. nextTrack();
  810. } else {
  811. syncPlayButton();
  812. }
  813. });
  814. audio.addEventListener("play", syncPlayButton);
  815. audio.addEventListener("pause", syncPlayButton);
  816. audio.addEventListener("timeupdate", () => {
  817. const progress = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
  818. progressBar.value = progress;
  819. document.getElementById("currentTimeLabel").textContent = formatTime(audio.currentTime);
  820. document.getElementById("durationLabel").textContent = formatTime(audio.duration);
  821. savePlaybackState();
  822. });
  823. audio.addEventListener("loadedmetadata", () => {
  824. document.getElementById("durationLabel").textContent = formatTime(audio.duration);
  825. });
  826. progressBar.addEventListener("input", () => {
  827. if (!audio.duration) return;
  828. audio.currentTime = (Number(progressBar.value) / 100) * audio.duration;
  829. });
  830. document.getElementById("backHomeBtn").onclick = () => {
  831. showView("home");
  832. scrollToPageTop();
  833. };
  834. document.getElementById("saveQueueBtn").onclick = () => renderQueue();
  835. document.getElementById("clearQueueBtn").onclick = () => {
  836. if (!state.currentQueue.length) return;
  837. showIconToast("清空队列");
  838. clearQueue();
  839. };
  840. document.getElementById("playModeBtn").onclick = () => {
  841. const modes = ["shuffle", "one", "list", "once"];
  842. const nextIndex = (modes.indexOf(state.playMode) + 1) % modes.length;
  843. state.playMode = modes[nextIndex];
  844. updateToggleStates();
  845. showIconToast(document.getElementById("playModeBtn").dataset.tip);
  846. savePlaybackState();
  847. };
  848. document.getElementById("albumMenuBtn").onclick = () => toggleAlbumMenu();
  849. document.getElementById("closeAlbumMenuBtn").onclick = () => toggleAlbumMenu(false);
  850. document.getElementById("albumMenuHomeBtn").onclick = () => {
  851. toggleAlbumMenu(false);
  852. showView("home");
  853. clearCurrentTrack();
  854. scrollToPageTop();
  855. };
  856. document.getElementById("albumMenuOverlay").onclick = (event) => {
  857. if (event.target.id === "albumMenuOverlay") toggleAlbumMenu(false);
  858. };
  859. document.getElementById("prevBtn").onclick = () => {
  860. showIconToast("上一曲");
  861. playPrevious();
  862. };
  863. document.getElementById("nextBtn").onclick = () => {
  864. showIconToast("下一曲");
  865. playNext();
  866. };
  867. document.getElementById("playToggleBtn").onclick = () => {
  868. if (!state.currentQueue.length && state.library?.all_tracks?.length) {
  869. startQueue(state.library.all_tracks, 0);
  870. showIconToast("开始播放");
  871. return;
  872. }
  873. if (audio.paused) {
  874. audio.play();
  875. showIconToast("播放");
  876. } else {
  877. audio.pause();
  878. showIconToast("暂停");
  879. }
  880. };
  881. document.getElementById("uploadBtn").onclick = async () => {
  882. const files = document.getElementById("uploadInput").files;
  883. if (!files.length) {
  884. alert("请选择文件");
  885. return;
  886. }
  887. const formData = new FormData();
  888. Array.from(files).forEach((file) => formData.append("files", file));
  889. formData.append("target_dir", document.getElementById("targetDir").value.trim());
  890. await fetch("/api/upload", { method: "POST", body: formData });
  891. document.getElementById("uploadInput").value = "";
  892. await fetchLibrary();
  893. };
  894. document.getElementById("createFolderBtn").onclick = async () => {
  895. const path = document.getElementById("newFolderInput").value.trim();
  896. if (!path) {
  897. alert("请输入目录名");
  898. return;
  899. }
  900. await fetch("/api/folder", {
  901. method: "POST",
  902. headers: { "Content-Type": "application/json" },
  903. body: JSON.stringify({ path }),
  904. });
  905. document.getElementById("newFolderInput").value = "";
  906. await fetchLibrary();
  907. };
  908. document.getElementById("createPlaylistBtn").onclick = async () => {
  909. const name = document.getElementById("playlistNameInput").value.trim();
  910. if (!name) {
  911. alert("请输入播放列表名称");
  912. return;
  913. }
  914. await fetch("/api/playlists", {
  915. method: "POST",
  916. headers: { "Content-Type": "application/json" },
  917. body: JSON.stringify({ name, tracks: state.currentQueue.map((track) => track.path) }),
  918. });
  919. document.getElementById("playlistNameInput").value = "";
  920. await fetchLibrary();
  921. };
  922. document.querySelectorAll(".library-tab").forEach((button) => {
  923. button.onclick = () => switchLibrary(button.dataset.source);
  924. });
  925. updateToggleStates();
  926. fetchLibrary();