Jelajahi Sumber

first commit

sequoia00 3 minggu lalu
melakukan
30996490a8
10 mengubah file dengan 883 tambahan dan 0 penghapusan
  1. 6 0
      .gitignore
  2. 21 0
      README.md
  3. 1 0
      app/__init__.py
  4. 227 0
      app/main.py
  5. 1 0
      playlists.json
  6. 4 0
      requirements.txt
  7. 2 0
      start.sh
  8. 236 0
      static/css/style.css
  9. 282 0
      static/js/app.js
  10. 103 0
      templates/index.html

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+__pycache__/
+*.py[cod]
+.*
+!.gitignore
+/mp3file/
+nohup.out

+ 21 - 0
README.md

@@ -0,0 +1,21 @@
+# MusicWeb
+
+FastAPI 在线音乐播放器,音乐文件默认存储在 `mp3file/` 目录。
+
+## 功能
+
+- 按目录浏览和播放
+- 随机播放、列表循环、单曲循环
+- 上传音乐文件
+- 创建文件夹
+- 移动文件到其他目录
+- 自定义播放列表
+
+## 运行
+
+```bash
+conda activate py311
+cd /home/myprojector/musicweb
+pip install -r requirements.txt
+uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+```

+ 1 - 0
app/__init__.py

@@ -0,0 +1 @@
+"""Music web app package."""

+ 227 - 0
app/main.py

@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import json
+import mimetypes
+import shutil
+import uuid
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+MUSIC_DIR = BASE_DIR / "mp3file"
+PLAYLISTS_FILE = BASE_DIR / "playlists.json"
+SUPPORTED_EXTENSIONS = {
+    ".mp3",
+    ".wav",
+    ".flac",
+    ".m3u",
+    ".m3u8",
+    ".ogg",
+    ".aac",
+    ".wma",
+    ".opus",
+    ".oga",
+    ".mp4",
+    ".m4a",
+    ".webm",
+}
+
+app = FastAPI(title="MusicWeb", version="1.0.0")
+app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+
+class FolderCreateRequest(BaseModel):
+    path: str
+
+
+class MoveRequest(BaseModel):
+    source: str
+    destination_dir: str
+
+
+class PlaylistCreateRequest(BaseModel):
+    name: str
+    tracks: list[str]
+
+
+class PlaylistUpdateRequest(BaseModel):
+    tracks: list[str]
+
+
+def ensure_storage() -> None:
+    MUSIC_DIR.mkdir(parents=True, exist_ok=True)
+    if not PLAYLISTS_FILE.exists():
+        PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
+
+
+def safe_music_path(relative_path: str) -> Path:
+    candidate = (MUSIC_DIR / relative_path).resolve()
+    if candidate != MUSIC_DIR.resolve() and MUSIC_DIR.resolve() not in candidate.parents:
+        raise HTTPException(status_code=400, detail="Invalid path")
+    return candidate
+
+
+def load_playlists() -> list[dict[str, Any]]:
+    ensure_storage()
+    return json.loads(PLAYLISTS_FILE.read_text(encoding="utf-8"))
+
+
+def save_playlists(playlists: list[dict[str, Any]]) -> None:
+    PLAYLISTS_FILE.write_text(
+        json.dumps(playlists, ensure_ascii=False, indent=2), encoding="utf-8"
+    )
+
+
+def is_supported_file(path: Path) -> bool:
+    return path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
+
+
+def track_url(relative_path: str) -> str:
+    return f"/api/stream/{relative_path}"
+
+
+def build_track(relative_path: str) -> dict[str, str]:
+    filename = Path(relative_path).name
+    return {
+        "id": relative_path,
+        "name": filename,
+        "path": relative_path,
+        "url": track_url(relative_path),
+        "folder": str(Path(relative_path).parent).replace(".", "").strip("/"),
+    }
+
+
+def build_tree(root: Path) -> dict[str, Any]:
+    rel_root = root.relative_to(MUSIC_DIR) if root != MUSIC_DIR else Path(".")
+    node = {
+        "name": "音乐库" if root == MUSIC_DIR else root.name,
+        "path": "" if rel_root == Path(".") else rel_root.as_posix(),
+        "folders": [],
+        "tracks": [],
+    }
+    for child in sorted(root.iterdir(), key=lambda item: (item.is_file(), item.name.lower())):
+        if child.is_dir():
+            node["folders"].append(build_tree(child))
+        elif is_supported_file(child):
+            node["tracks"].append(build_track(child.relative_to(MUSIC_DIR).as_posix()))
+    return node
+
+
+def collect_tracks(root: Path) -> list[dict[str, str]]:
+    tracks: list[dict[str, str]] = []
+    for path in sorted(root.rglob("*")):
+        if is_supported_file(path):
+            tracks.append(build_track(path.relative_to(MUSIC_DIR).as_posix()))
+    return tracks
+
+
+@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:
+    ensure_storage()
+    return JSONResponse(
+        {
+            "tree": build_tree(MUSIC_DIR),
+            "all_tracks": collect_tracks(MUSIC_DIR),
+            "playlists": load_playlists(),
+        }
+    )
+
+
+@app.get("/api/stream/{file_path:path}")
+def stream_file(file_path: str) -> FileResponse:
+    file = safe_music_path(file_path)
+    if not file.exists() or not file.is_file():
+        raise HTTPException(status_code=404, detail="File not found")
+    media_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
+    return FileResponse(file, media_type=media_type, filename=file.name)
+
+
+@app.post("/api/upload")
+async def upload_files(
+    files: list[UploadFile] = File(...),
+    target_dir: str = Form(default=""),
+) -> JSONResponse:
+    destination = safe_music_path(target_dir)
+    destination.mkdir(parents=True, exist_ok=True)
+    saved: list[str] = []
+    for upload in files:
+        suffix = Path(upload.filename or "").suffix.lower()
+        if suffix not in SUPPORTED_EXTENSIONS:
+            continue
+        filename = Path(upload.filename or f"upload-{uuid.uuid4().hex}").name
+        file_path = destination / filename
+        with file_path.open("wb") as buffer:
+            shutil.copyfileobj(upload.file, buffer)
+        saved.append(file_path.relative_to(MUSIC_DIR).as_posix())
+    return JSONResponse({"saved": saved})
+
+
+@app.post("/api/folder")
+def create_folder(payload: FolderCreateRequest) -> JSONResponse:
+    folder = safe_music_path(payload.path)
+    folder.mkdir(parents=True, exist_ok=True)
+    return JSONResponse({"created": folder.relative_to(MUSIC_DIR).as_posix()})
+
+
+@app.post("/api/move")
+def move_file(payload: MoveRequest) -> JSONResponse:
+    source = safe_music_path(payload.source)
+    destination_dir = safe_music_path(payload.destination_dir)
+    if not source.exists():
+        raise HTTPException(status_code=404, detail="Source not found")
+    if not destination_dir.exists():
+        destination_dir.mkdir(parents=True, exist_ok=True)
+    destination = destination_dir / source.name
+    shutil.move(str(source), str(destination))
+    return JSONResponse({"moved": destination.relative_to(MUSIC_DIR).as_posix()})
+
+
+@app.post("/api/playlists")
+def create_playlist(payload: PlaylistCreateRequest) -> JSONResponse:
+    playlists = load_playlists()
+    playlist = {
+        "id": uuid.uuid4().hex,
+        "name": payload.name.strip() or "未命名播放列表",
+        "tracks": payload.tracks,
+    }
+    playlists.append(playlist)
+    save_playlists(playlists)
+    return JSONResponse(playlist)
+
+
+@app.put("/api/playlists/{playlist_id}")
+def update_playlist(playlist_id: str, payload: PlaylistUpdateRequest) -> JSONResponse:
+    playlists = load_playlists()
+    for playlist in playlists:
+        if playlist["id"] == playlist_id:
+            playlist["tracks"] = payload.tracks
+            save_playlists(playlists)
+            return JSONResponse(playlist)
+    raise HTTPException(status_code=404, detail="Playlist not found")
+
+
+@app.delete("/api/playlists/{playlist_id}")
+def delete_playlist(playlist_id: str) -> JSONResponse:
+    playlists = load_playlists()
+    filtered = [playlist for playlist in playlists if playlist["id"] != playlist_id]
+    if len(filtered) == len(playlists):
+        raise HTTPException(status_code=404, detail="Playlist not found")
+    save_playlists(filtered)
+    return JSONResponse({"deleted": playlist_id})

+ 1 - 0
playlists.json

@@ -0,0 +1 @@
+[]

+ 4 - 0
requirements.txt

@@ -0,0 +1,4 @@
+fastapi==0.115.12
+uvicorn[standard]==0.34.2
+jinja2==3.1.6
+python-multipart==0.0.20

+ 2 - 0
start.sh

@@ -0,0 +1,2 @@
+#!/bin/bash
+nohup uvicorn app.main:app --host 0.0.0.0 --port 8006 &

+ 236 - 0
static/css/style.css

@@ -0,0 +1,236 @@
+:root {
+  --bg: #09111f;
+  --bg-soft: rgba(12, 23, 44, 0.72);
+  --panel: rgba(20, 33, 58, 0.88);
+  --line: rgba(255, 255, 255, 0.1);
+  --text: #eff6ff;
+  --muted: #9fb3c8;
+  --accent: #61d7b6;
+  --accent-2: #f6b15f;
+  --danger: #ff7b7b;
+  --shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  font-family: "Segoe UI", "PingFang SC", sans-serif;
+  color: var(--text);
+  background:
+    radial-gradient(circle at top left, rgba(97, 215, 182, 0.22), transparent 30%),
+    radial-gradient(circle at top right, rgba(246, 177, 95, 0.18), transparent 28%),
+    linear-gradient(135deg, #08101d 0%, #0d1d35 54%, #10284c 100%);
+}
+
+.page-shell {
+  display: grid;
+  grid-template-columns: 340px 1fr;
+  gap: 24px;
+  padding: 24px;
+}
+
+.glass {
+  backdrop-filter: blur(18px);
+  background: var(--bg-soft);
+  border: 1px solid var(--line);
+  border-radius: 24px;
+  box-shadow: var(--shadow);
+}
+
+.sidebar,
+.content {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.sidebar {
+  padding: 24px;
+}
+
+.brand h1,
+.hero h2 {
+  margin: 0;
+  font-size: 2rem;
+}
+
+.eyebrow {
+  margin: 0 0 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.18em;
+  font-size: 0.72rem;
+  color: var(--accent);
+}
+
+.subtext {
+  color: var(--muted);
+  line-height: 1.6;
+}
+
+.panel,
+.hero,
+.library-panel,
+.queue-panel {
+  padding: 20px;
+}
+
+.panel-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+  margin-bottom: 14px;
+}
+
+.panel-head h2 {
+  margin: 0;
+  font-size: 1.05rem;
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  margin-bottom: 12px;
+  color: var(--muted);
+  font-size: 0.92rem;
+}
+
+input {
+  width: 100%;
+  border: 1px solid var(--line);
+  background: rgba(255, 255, 255, 0.04);
+  color: var(--text);
+  border-radius: 14px;
+  padding: 12px 14px;
+}
+
+button {
+  border: 0;
+  border-radius: 999px;
+  padding: 11px 16px;
+  cursor: pointer;
+  transition: transform 0.18s ease, opacity 0.18s ease, background 0.18s ease;
+}
+
+button:hover {
+  transform: translateY(-1px);
+}
+
+.primary-btn {
+  background: linear-gradient(135deg, var(--accent), #2eb6f0);
+  color: #072035;
+  font-weight: 700;
+}
+
+.secondary-btn,
+.ghost-btn {
+  background: rgba(255, 255, 255, 0.07);
+  color: var(--text);
+}
+
+.secondary-btn.active,
+.ghost-btn.active {
+  background: rgba(97, 215, 182, 0.18);
+  color: var(--accent);
+}
+
+.content {
+  min-width: 0;
+}
+
+.hero {
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+}
+
+.hero-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+audio {
+  width: 100%;
+}
+
+.library-grid {
+  display: grid;
+  grid-template-columns: 1.1fr 1fr;
+  gap: 24px;
+}
+
+.folder-tree,
+.queue-list,
+.playlist-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  max-height: 62vh;
+  overflow: auto;
+  padding-right: 4px;
+}
+
+.folder-node {
+  border: 1px solid var(--line);
+  border-radius: 18px;
+  padding: 14px;
+  background: rgba(255, 255, 255, 0.03);
+}
+
+.folder-node h3,
+.folder-node h4 {
+  margin: 0 0 8px;
+}
+
+.folder-actions,
+.track-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.track-item,
+.playlist-item {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  align-items: center;
+  border: 1px solid var(--line);
+  border-radius: 18px;
+  padding: 14px;
+  background: rgba(255, 255, 255, 0.03);
+}
+
+.track-name {
+  display: block;
+  margin-bottom: 5px;
+}
+
+.track-path {
+  margin: 0;
+  color: var(--muted);
+  font-size: 0.88rem;
+  word-break: break-all;
+}
+
+.playlist-item p {
+  margin: 4px 0 0;
+  color: var(--muted);
+}
+
+@media (max-width: 1080px) {
+  .page-shell,
+  .library-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .sidebar {
+    order: 2;
+  }
+}

+ 282 - 0
static/js/app.js

@@ -0,0 +1,282 @@
+const state = {
+  library: null,
+  currentFolder: "",
+  currentQueue: [],
+  currentTrackIndex: -1,
+  shuffle: false,
+  loopMode: "list",
+};
+
+const audio = document.getElementById("audioPlayer");
+
+async function fetchLibrary() {
+  const response = await fetch("/api/library");
+  state.library = await response.json();
+  renderTree();
+  renderPlaylists();
+  if (!state.currentQueue.length) {
+    state.currentQueue = [...state.library.all_tracks];
+    renderQueue();
+  }
+}
+
+function toggleButton(id, active, label) {
+  const button = document.getElementById(id);
+  button.classList.toggle("active", active);
+  button.textContent = label;
+}
+
+function folderTracks(path, node = state.library.tree) {
+  if (node.path === path) return flattenNodeTracks(node);
+  for (const child of node.folders) {
+    const result = folderTracks(path, child);
+    if (result) return result;
+  }
+  return null;
+}
+
+function flattenNodeTracks(node) {
+  const tracks = [...node.tracks];
+  for (const child of node.folders) {
+    tracks.push(...flattenNodeTracks(child));
+  }
+  return tracks;
+}
+
+function renderTree() {
+  const container = document.getElementById("folderTree");
+  container.innerHTML = "";
+  container.appendChild(renderFolderNode(state.library.tree));
+}
+
+function renderFolderNode(node) {
+  const wrapper = document.createElement("div");
+  wrapper.className = "folder-node";
+
+  const title = document.createElement(node.path ? "h4" : "h3");
+  title.textContent = node.name;
+  wrapper.appendChild(title);
+
+  const actions = document.createElement("div");
+  actions.className = "folder-actions";
+
+  const useBtn = document.createElement("button");
+  useBtn.className = "ghost-btn";
+  useBtn.textContent = "设为当前目录";
+  useBtn.onclick = () => {
+    state.currentFolder = node.path;
+    document.getElementById("folderIndicator").textContent = `当前目录:${node.path || "根目录"}`;
+  };
+
+  const playBtn = document.createElement("button");
+  playBtn.className = "ghost-btn";
+  playBtn.textContent = "播放此目录";
+  playBtn.onclick = () => {
+    const tracks = flattenNodeTracks(node);
+    if (!tracks.length) return alert("该目录下没有可播放文件");
+    startQueue(tracks, 0);
+    state.currentFolder = node.path;
+    document.getElementById("folderIndicator").textContent = `当前目录:${node.path || "根目录"}`;
+  };
+
+  actions.append(useBtn, playBtn);
+  wrapper.appendChild(actions);
+
+  node.tracks.forEach((track) => wrapper.appendChild(renderTrack(track)));
+  node.folders.forEach((folder) => wrapper.appendChild(renderFolderNode(folder)));
+  return wrapper;
+}
+
+function renderTrack(track) {
+  const template = document.getElementById("trackItemTemplate");
+  const fragment = template.content.cloneNode(true);
+  fragment.querySelector(".track-name").textContent = track.name;
+  fragment.querySelector(".track-path").textContent = track.path;
+  fragment.querySelector(".play-btn").onclick = () => startQueue([track], 0);
+  fragment.querySelector(".queue-btn").onclick = () => {
+    state.currentQueue.push(track);
+    renderQueue();
+  };
+  fragment.querySelector(".move-btn").onclick = async () => {
+    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 = "";
+  state.currentQueue.forEach((track, index) => {
+    const item = document.createElement("div");
+    item.className = "track-item";
+    if (index === state.currentTrackIndex) item.style.borderColor = "var(--accent)";
+    item.innerHTML = `
+      <div>
+        <strong class="track-name">${track.name}</strong>
+        <p class="track-path">${track.path}</p>
+      </div>
+      <div class="track-actions">
+        <button class="ghost-btn">播放</button>
+        <button class="ghost-btn">移除</button>
+      </div>
+    `;
+    const [playBtn, removeBtn] = item.querySelectorAll("button");
+    playBtn.onclick = () => playTrack(index);
+    removeBtn.onclick = () => {
+      state.currentQueue.splice(index, 1);
+      if (state.currentTrackIndex >= state.currentQueue.length) {
+        state.currentTrackIndex = state.currentQueue.length - 1;
+      }
+      renderQueue();
+    };
+    container.appendChild(item);
+  });
+}
+
+function renderPlaylists() {
+  const container = document.getElementById("playlistList");
+  container.innerHTML = "";
+  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">
+        <button class="ghost-btn">播放</button>
+        <button class="ghost-btn">覆盖</button>
+        <button class="ghost-btn">删除</button>
+      </div>
+    `;
+    const [playBtn, replaceBtn, deleteBtn] = item.querySelectorAll("button");
+    playBtn.onclick = () => {
+      const tracks = playlist.tracks
+        .map((path) => state.library.all_tracks.find((track) => track.path === path))
+        .filter(Boolean);
+      startQueue(tracks, 0);
+    };
+    replaceBtn.onclick = async () => {
+      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 () => {
+      await fetch(`/api/playlists/${playlist.id}`, { method: "DELETE" });
+      await fetchLibrary();
+    };
+    container.appendChild(item);
+  });
+}
+
+function startQueue(tracks, index) {
+  state.currentQueue = [...tracks];
+  state.currentTrackIndex = index;
+  renderQueue();
+  playTrack(index);
+}
+
+function playTrack(index) {
+  if (!state.currentQueue.length) return;
+  state.currentTrackIndex = index;
+  const track = state.currentQueue[index];
+  audio.src = track.url;
+  audio.play();
+  document.getElementById("nowTitle").textContent = track.name;
+  document.getElementById("nowMeta").textContent = track.path;
+  renderQueue();
+}
+
+function nextTrack() {
+  if (!state.currentQueue.length) return;
+  if (state.loopMode === "one") {
+    playTrack(state.currentTrackIndex);
+    return;
+  }
+
+  if (state.shuffle) {
+    const randomIndex = Math.floor(Math.random() * state.currentQueue.length);
+    playTrack(randomIndex);
+    return;
+  }
+
+  const nextIndex = state.currentTrackIndex + 1;
+  if (nextIndex < state.currentQueue.length) {
+    playTrack(nextIndex);
+    return;
+  }
+
+  if (state.loopMode === "list") {
+    playTrack(0);
+  }
+}
+
+audio.addEventListener("ended", nextTrack);
+
+document.getElementById("playAllBtn").onclick = () => startQueue(state.library.all_tracks, 0);
+document.getElementById("playFolderBtn").onclick = () => {
+  const tracks = folderTracks(state.currentFolder || "") || [];
+  if (!tracks.length) return alert("当前目录没有歌曲");
+  startQueue(tracks, 0);
+};
+
+document.getElementById("shuffleBtn").onclick = () => {
+  state.shuffle = !state.shuffle;
+  toggleButton("shuffleBtn", state.shuffle, `随机: ${state.shuffle ? "开" : "关"}`);
+};
+
+document.getElementById("loopBtn").onclick = () => {
+  state.loopMode = state.loopMode === "list" ? "one" : state.loopMode === "one" ? "off" : "list";
+  const labels = { list: "循环: 列表", one: "循环: 单曲", off: "循环: 关" };
+  toggleButton("loopBtn", state.loopMode !== "off", labels[state.loopMode]);
+};
+
+document.getElementById("uploadBtn").onclick = async () => {
+  const files = document.getElementById("uploadInput").files;
+  if (!files.length) return alert("请选择文件");
+  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) return alert("请输入目录名");
+  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) return alert("请输入播放列表名称");
+  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.getElementById("saveQueueBtn").onclick = () => renderQueue();
+
+fetchLibrary();

+ 103 - 0
templates/index.html

@@ -0,0 +1,103 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>MusicWeb Player</title>
+    <link rel="stylesheet" href="/static/css/style.css" />
+  </head>
+  <body>
+    <div class="page-shell">
+      <aside class="sidebar glass">
+        <div class="brand">
+          <p class="eyebrow">FastAPI MusicWeb</p>
+          <h1>在线音乐播放器</h1>
+          <p class="subtext">支持目录管理、随机播放、循环模式与自定义播放列表。</p>
+        </div>
+
+        <section class="panel">
+          <div class="panel-head">
+            <h2>文件操作</h2>
+          </div>
+          <label class="field">
+            <span>目标目录</span>
+            <input id="targetDir" type="text" placeholder="如:流行/周杰伦" />
+          </label>
+          <label class="field">
+            <span>上传音乐</span>
+            <input id="uploadInput" type="file" multiple />
+          </label>
+          <button id="uploadBtn" class="primary-btn">上传文件</button>
+          <label class="field">
+            <span>新建文件夹</span>
+            <input id="newFolderInput" type="text" placeholder="如:轻音乐/夜晚" />
+          </label>
+          <button id="createFolderBtn" class="secondary-btn">创建文件夹</button>
+        </section>
+
+        <section class="panel">
+          <div class="panel-head">
+            <h2>播放列表</h2>
+          </div>
+          <label class="field">
+            <span>新列表名称</span>
+            <input id="playlistNameInput" type="text" placeholder="我的收藏" />
+          </label>
+          <button id="createPlaylistBtn" class="secondary-btn">用当前队列创建</button>
+          <div id="playlistList" class="playlist-list"></div>
+        </section>
+      </aside>
+
+      <main class="content">
+        <section class="hero glass">
+          <div>
+            <p class="eyebrow">Now Playing</p>
+            <h2 id="nowTitle">请选择一首歌</h2>
+            <p id="nowMeta" class="subtext">从目录、全部歌曲或播放列表开始。</p>
+          </div>
+          <div class="hero-actions">
+            <button id="playAllBtn" class="secondary-btn">播放全部</button>
+            <button id="playFolderBtn" class="secondary-btn">播放当前目录</button>
+            <button id="shuffleBtn" class="secondary-btn">随机: 关</button>
+            <button id="loopBtn" class="secondary-btn">循环: 列表</button>
+          </div>
+          <audio id="audioPlayer" controls preload="metadata"></audio>
+        </section>
+
+        <section class="library-grid">
+          <div class="library-panel glass">
+            <div class="panel-head">
+              <h2>音乐目录</h2>
+              <span id="folderIndicator">当前目录:根目录</span>
+            </div>
+            <div id="folderTree" class="folder-tree"></div>
+          </div>
+
+          <div class="queue-panel glass">
+            <div class="panel-head">
+              <h2>播放队列</h2>
+              <button id="saveQueueBtn" class="ghost-btn">刷新队列</button>
+            </div>
+            <div id="queueList" class="queue-list"></div>
+          </div>
+        </section>
+      </main>
+    </div>
+
+    <template id="trackItemTemplate">
+      <div class="track-item">
+        <div>
+          <strong class="track-name"></strong>
+          <p class="track-path"></p>
+        </div>
+        <div class="track-actions">
+          <button class="play-btn ghost-btn">播放</button>
+          <button class="queue-btn ghost-btn">加入队列</button>
+          <button class="move-btn ghost-btn">移动</button>
+        </div>
+      </div>
+    </template>
+
+    <script src="/static/js/app.js"></script>
+  </body>
+  </html>