app.js 26 KB

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