Ver Fonte

修改一些逻辑

sequoia00 há 2 semanas atrás
pai
commit
dff7b2942e
5 ficheiros alterados com 216 adições e 96 exclusões
  1. 43 22
      app/main.py
  2. 24 7
      playlists.json
  3. 46 7
      static/css/style.css
  4. 93 57
      static/js/app.js
  5. 10 3
      templates/index.html

+ 43 - 22
app/main.py

@@ -25,7 +25,7 @@ CLOUD_ALIST_BASE = "http://110.42.102.94:5244"
 CLOUD_WEBDAV_USER = "sequoia00"
 CLOUD_WEBDAV_PASSWORD = "792199bb"
 MUSIC_ROOTS: list[tuple[str, Path, str]] = [
-    ("", LOCAL_MUSIC_DIR, "音乐"),
+    ("", LOCAL_MUSIC_DIR, "本地音乐"),
     ("cloud", CLOUD_MUSIC_DIR, "百度网盘"),
 ]
 PLAYLISTS_FILE = BASE_DIR / "playlists.json"
@@ -257,47 +257,63 @@ def collect_tracks(root: Path, prefix: str = "") -> list[dict[str, str]]:
     return tracks
 
 
-@app.on_event("startup")
-def startup_event() -> None:
-    ensure_storage()
+def root_entry(source: str) -> tuple[str, Path, str]:
+    for prefix, root, label in MUSIC_ROOTS:
+        if prefix == source:
+            return prefix, root, label
+    raise HTTPException(status_code=404, detail="Library source not found")
 
 
-@app.get("/", response_class=HTMLResponse)
-def index(request: Request) -> HTMLResponse:
-    return templates.TemplateResponse("index.html", {"request": request})
-
-
-@app.get("/api/library")
-def library() -> JSONResponse:
+def build_library_payload(source: str | None = None) -> dict[str, Any]:
     ensure_storage()
+    entries = MUSIC_ROOTS if source is None else [root_entry(source)]
     tree = {
         "name": "音乐库",
         "path": "",
         "cover": None,
         "folders": [
             build_tree(root, prefix, label)
-            for prefix, root, label in MUSIC_ROOTS
+            for prefix, root, label in entries
             if root.exists()
         ],
         "tracks": [],
     }
     all_tracks: list[dict[str, str]] = []
-    for prefix, root, _ in MUSIC_ROOTS:
+    for prefix, root, _ in entries:
         if root.exists():
             all_tracks.extend(collect_tracks(root, prefix))
-    return JSONResponse(
-        {
-            "tree": tree,
-            "all_tracks": all_tracks,
-            "playlists": load_playlists(),
-        }
-    )
+    return {
+        "tree": tree,
+        "all_tracks": all_tracks,
+        "playlists": load_playlists(),
+    }
+
+
+@app.on_event("startup")
+def startup_event() -> None:
+    ensure_storage()
+
+
+@app.get("/", response_class=HTMLResponse)
+def index(request: Request) -> HTMLResponse:
+    return templates.TemplateResponse("index.html", {"request": request})
+
+
+@app.get("/api/library")
+def library() -> JSONResponse:
+    return JSONResponse(build_library_payload())
+
+
+@app.get("/api/library/{source}")
+def library_by_source(source: str) -> JSONResponse:
+    normalized = "" if source == "local" else source
+    return JSONResponse(build_library_payload(normalized))
 
 
 @app.get("/api/stream/{file_path:path}")
 def stream_file(request: Request, file_path: str) -> StreamingResponse:
     if file_path.startswith("cloud/"):
-        return RedirectResponse(cloud_raw_url(file_path), status_code=307)
+        return proxy_cloud_stream(cloud_raw_url(file_path), request.headers.get("range"))
 
     file = safe_music_path(file_path)
     if not file.exists() or not file.is_file():
@@ -369,7 +385,12 @@ def proxy_cloud_stream(remote_url: str, range_header: str | None = None) -> Stre
 
 
 @app.get("/api/cover/{file_path:path}")
-def cover_file(file_path: str) -> FileResponse:
+def cover_file(request: Request, file_path: str):
+    if file_path.startswith("cloud/"):
+        if Path(file_path).suffix.lower() not in IMAGE_EXTENSIONS:
+            raise HTTPException(status_code=404, detail="Cover not found")
+        return proxy_cloud_stream(cloud_raw_url(file_path), request.headers.get("range"))
+
     file = safe_music_path(file_path)
     if not file.exists() or not file.is_file() or file.suffix.lower() not in IMAGE_EXTENSIONS:
         raise HTTPException(status_code=404, detail="Cover not found")

+ 24 - 7
playlists.json

@@ -1,11 +1,4 @@
 [
-  {
-    "id": "25a46a5ac7524491a8d6a1399d0cdbaa",
-    "name": "我的",
-    "tracks": [
-      "萨克斯风/《回家》CD1.mp3"
-    ]
-  },
   {
     "id": "d5e397954b4c48f9a4c6a4a345b261cb",
     "name": "我的2",
@@ -18,5 +11,29 @@
       "经典老歌/059 黄安-新鸳鸯蝴蝶梦.mp3",
       "经典老歌/086 李宗盛-凡人歌.mp3"
     ]
+  },
+  {
+    "id": "69f403626eee44de9bb8c93d1b4cbd83",
+    "name": "o8",
+    "tracks": [
+      "李宗盛/CD 1/山丘.m4a",
+      "李宗盛/CD 1/爱的代价.flac",
+      "陈小春/13 友情岁月.mp3",
+      "陈小春/04 献世.mp3",
+      "陈小春/16 成王败寇.mp3"
+    ]
+  },
+  {
+    "id": "37ef81d77b7e4c2097c004e7b98d6384",
+    "name": "新",
+    "tracks": [
+      "李宗盛/CD 1/山丘.m4a",
+      "李宗盛/CD 1/爱的代价.flac",
+      "陈小春/13 友情岁月.mp3",
+      "周深/216.周深-生活总该迎着光亮.mp4",
+      "经典老歌/014 陈慧琳-记事本.mp3",
+      "经典老歌/019 陈琳-你的柔情我永远不懂.mp3",
+      "经典老歌/020 陈明真-梦醒时分.mp3"
+    ]
   }
 ]

+ 46 - 7
static/css/style.css

@@ -17,6 +17,8 @@
 body {
   margin: 0;
   min-height: 100vh;
+  min-height: 100dvh;
+  overflow-x: hidden;
   font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
   color: var(--text);
   background:
@@ -51,7 +53,7 @@ input[type="file"] {
 .app-shell {
   max-width: 1180px;
   margin: 0 auto;
-  padding: 10px 10px 188px;
+  padding: 10px 10px 228px;
 }
 
 .app-header,
@@ -174,6 +176,29 @@ input[type="file"] {
   font-size: 0.8rem;
 }
 
+.library-tabs {
+  display: inline-flex;
+  gap: 8px;
+  padding: 4px;
+  border-radius: 999px;
+  background: rgba(35, 136, 232, 0.08);
+}
+
+.library-tab {
+  min-width: 92px;
+  padding: 9px 16px;
+  border-radius: 999px;
+  background: transparent;
+  color: var(--muted);
+  font-weight: 600;
+}
+
+.library-tab.is-active {
+  background: linear-gradient(135deg, var(--accent), #5db8ff);
+  color: #f7fbff;
+  box-shadow: 0 8px 18px rgba(35, 136, 232, 0.22);
+}
+
 .featured-list,
 .song-list,
 .ranking-list,
@@ -492,12 +517,16 @@ input[type="file"] {
 
 .player-dock {
   position: fixed;
-  left: 8px;
-  right: 8px;
-  bottom: max(8px, env(safe-area-inset-bottom));
+  left: 50%;
+  right: auto;
+  bottom: max(8px, env(safe-area-inset-bottom, 0px));
+  transform: translateX(-50%);
+  width: min(calc(100vw - 16px), 1180px);
+  max-width: calc(100vw - 16px);
   z-index: 999;
   padding: 12px 14px;
   border-radius: 24px;
+  box-sizing: border-box;
   background:
     linear-gradient(180deg, rgba(247, 251, 255, 0.97), rgba(231, 243, 255, 0.99)),
     var(--panel);
@@ -507,6 +536,7 @@ input[type="file"] {
   display: flex;
   align-items: center;
   gap: 12px;
+  min-width: 0;
 }
 
 .dock-cover-wrap {
@@ -563,6 +593,7 @@ input[type="file"] {
   margin-top: 10px;
   color: var(--muted);
   font-size: 0.76rem;
+  min-width: 0;
 }
 
 input[type="range"] {
@@ -571,10 +602,11 @@ input[type="range"] {
 }
 
 .icon-actions {
-  display: flex;
-  justify-content: space-between;
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
   align-items: center;
   margin-top: 12px;
+  gap: 8px;
 }
 
 .icon-btn {
@@ -588,6 +620,7 @@ input[type="range"] {
   display: inline-flex;
   align-items: center;
   justify-content: center;
+  justify-self: center;
 }
 
 .icon-btn.active {
@@ -742,7 +775,7 @@ audio {
 
 @media (min-width: 760px) {
   .app-shell {
-    padding: 12px 14px 184px;
+    padding: 12px 14px 212px;
   }
 
   .stack-section {
@@ -753,3 +786,9 @@ audio {
     width: 96px;
   }
 }
+
+@supports (-webkit-touch-callout: none) {
+  .player-dock {
+    bottom: max(10px, env(safe-area-inset-bottom, 0px));
+  }
+}

+ 93 - 57
static/js/app.js

@@ -1,5 +1,11 @@
 const state = {
   library: null,
+  libraries: {
+    local: null,
+    cloud: null,
+  },
+  activeSource: "local",
+  cloudLoadingStarted: false,
   currentFolder: "",
   currentQueue: [],
   currentTrackIndex: -1,
@@ -26,12 +32,8 @@ const iconMarkup = {
 };
 
 function savePlaybackState() {
-  const currentTrack = state.currentQueue[state.currentTrackIndex] || null;
   const payload = {
     currentFolder: state.currentFolder,
-    currentQueue: state.currentQueue.map((track) => track.path),
-    currentTrackPath: currentTrack ? currentTrack.path : "",
-    currentTime: audio.currentTime || 0,
     playMode: state.playMode,
     activeAlbumPath: state.activeAlbumPath,
   };
@@ -55,44 +57,12 @@ function restorePlaybackState() {
     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 (Array.isArray(saved.currentQueue) && saved.currentQueue.length) {
-      state.currentQueue = saved.currentQueue
-        .map((path) => state.library.all_tracks.find((track) => track.path === path))
-        .filter(Boolean);
-    }
-
-    const trackPath = saved.currentTrackPath || "";
-    if (trackPath) {
-      const queueIndex = state.currentQueue.findIndex((track) => track.path === trackPath);
-      if (queueIndex >= 0) {
-        state.currentTrackIndex = queueIndex;
-      } else {
-        const single = state.library.all_tracks.find((track) => track.path === trackPath);
-        if (single) {
-          state.currentQueue = [single];
-          state.currentTrackIndex = 0;
-        }
-      }
-    }
-
-    if (state.currentQueue.length && state.currentTrackIndex >= 0) {
-      const track = state.currentQueue[state.currentTrackIndex];
-      audio.src = track.url;
-      updateDock(track);
-      renderQueue();
-      syncPlayButton();
-      audio.addEventListener(
-        "loadedmetadata",
-        () => {
-          audio.currentTime = Number(saved.currentTime || 0);
-        },
-        { once: true }
-      );
-    }
-
     if (state.activeAlbumPath) {
       renderAlbumDetail(state.activeAlbumPath);
     }
@@ -102,26 +72,71 @@ function restorePlaybackState() {
 }
 
 async function fetchLibrary() {
-  const response = await fetch("/api/library");
-  state.library = await response.json();
+  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;
+    queueMicrotask(() => fetchCloudLibrary());
+  }
+}
+
+async function fetchCloudLibrary() {
+  try {
+    const response = await fetch("/api/library/cloud");
+    state.libraries.cloud = await response.json();
+    if (state.activeSource === "cloud") {
+      applyActiveLibrary();
+    }
+  } catch (error) {
+    console.error("fetchCloudLibrary 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();
-  restorePlaybackState();
-
   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" && !state.libraries.cloud && !state.cloudLoadingStarted) {
+    state.cloudLoadingStarted = true;
+    fetchCloudLibrary();
+  }
+}
+
 function buildAlbums(tree) {
   const albums = [];
 
@@ -333,10 +348,8 @@ function renderAlbumDetail(path) {
 
   document.getElementById("detailPlayBtn").onclick = () => startQueue(album.tracks, 0);
   document.getElementById("detailPlayAllBtn").onclick = () => {
-    state.currentQueue = [...album.tracks, ...state.currentQueue];
-    state.currentTrackIndex = 0;
-    renderQueue();
-    playTrack(0);
+    appendToQueue(album.tracks);
+    showIconToast("已加入播放列表");
   };
 
   const container = document.getElementById("detailTrackList");
@@ -606,11 +619,25 @@ 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;
@@ -667,13 +694,7 @@ function syncPlayButton() {
 }
 
 async function getCachedAudioUrl(track) {
-  if (track.path?.startsWith("cloud/")) {
-    showIconToast("云端加载中");
-    const response = await fetch(`/api/cloud-url/${track.path}`);
-    if (!response.ok) throw new Error(`cloud url request failed: ${response.status}`);
-    const data = await response.json();
-    return data.url || track.url;
-  }
+  if (track.path?.startsWith("cloud/")) return track.url;
   if (!("caches" in window)) return track.url;
 
   try {
@@ -701,6 +722,8 @@ async function playTrack(index) {
   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;
@@ -764,6 +787,15 @@ function formatTime(seconds) {
 }
 
 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", () => {
@@ -878,5 +910,9 @@ document.getElementById("createPlaylistBtn").onclick = async () => {
   await fetchLibrary();
 };
 
+document.querySelectorAll(".library-tab").forEach((button) => {
+  button.onclick = () => switchLibrary(button.dataset.source);
+});
+
 updateToggleStates();
 fetchLibrary();

+ 10 - 3
templates/index.html

@@ -19,6 +19,13 @@
 
       <main class="page-content">
         <section id="homeView" class="view-stack">
+          <section class="content-section">
+            <div class="library-tabs" id="libraryTabs">
+              <button class="library-tab is-active" data-source="local">本地</button>
+              <button class="library-tab" data-source="cloud">百度网盘</button>
+            </div>
+          </section>
+
           <section class="content-section slim-hero">
             <div>
               <h2 id="nowTitle">选择一个专辑目录开始播放</h2>
@@ -78,8 +85,8 @@
               <h2 id="detailTitle">专辑详情</h2>
               <p id="detailMeta" class="subtext"></p>
               <div class="card-actions">
-                <button id="detailPlayBtn" class="primary-btn">播放当前目录</button>
-                <button id="detailPlayAllBtn" class="secondary-btn">加入播放</button>
+                <button id="detailPlayBtn" class="primary-btn">替换队列并播放</button>
+                <button id="detailPlayAllBtn" class="secondary-btn">加入播放列表</button>
                 <button id="backHomeBtn" class="back-btn detail-back-home">返回首页</button>
               </div>
             </div>
@@ -221,6 +228,6 @@
       </div>
     </template>
 
-    <script src="/static/js/app.js?v=20260509-1"></script>
+    <script src="/static/js/app.js?v=20260511-1"></script>
   </body>
 </html>