baseline: 音乐在线电台 v1(重构前基线)
This commit is contained in:
commit
fa6eeb25b9
11 files changed
+5483
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# EditorConfig — 统一跨编辑器代码风格
|
||||
# https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,jsonc}]
|
||||
indent_size = 2
|
||||
|
||||
[*.html]
|
||||
indent_size = 4
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
|
||||
# 运行时生成的 HLS 产物(可随时重建)
|
||||
hls/
|
||||
hls_cache/
|
||||
|
||||
# 运行时数据(数据库)
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# 旧的 JSON 数据(已废弃)
|
||||
playlist.json
|
||||
heat.json
|
||||
metadata.json
|
||||
|
||||
# 本地化封面与第三方前端资源
|
||||
public/covers/
|
||||
public/vendor/
|
||||
|
||||
# 上传临时目录
|
||||
tmp/
|
||||
uploads/
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
npm-debug.log*
|
||||
*.tmp
|
||||
|
||||
# 编辑器 / 系统
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,252 @@
|
||||
# 音乐在线电台
|
||||
|
||||
把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成一条永不停播的直播流,并提供 Web 控制面板管理播放列表、从网易云导入音源。前后端一体的 Node.js 应用,无数据库依赖。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎵 音频文件自动转码切片(HLS),拼成 24 小时不间断的直播流
|
||||
- 📋 拖拽排序的播放列表(支持添加、移除、清空、切歌)
|
||||
- 🔥 热度统计 + 缓存自动清理(上限 1GB,按热度淘汰)
|
||||
- ☁️ 网易云音源导入:输入歌曲 ID,一键下载入库并显示封面/歌名/作者
|
||||
- 🖼️ 资源卡片展示:封面图高斯模糊背景 + 时长/大小信息(hover 显示)
|
||||
- 🔒 可选 API 鉴权(Bearer Token)
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 依赖 | 版本要求 |
|
||||
|------|----------|
|
||||
| Node.js | >= 14.14 |
|
||||
| npm | 随 Node 附带 |
|
||||
| FFmpeg | 任意较新版本(需包含 `ffmpeg` 与 `ffprobe`,且在系统 PATH 中) |
|
||||
|
||||
> ⚠️ FFmpeg 是硬性依赖。缺少它程序能启动但无法转码切片,直播流不会有内容。安装后请在终端运行 `ffmpeg -version` 与 `ffprobe -version` 确认可用。
|
||||
|
||||
## 安装
|
||||
|
||||
### 1. 安装 Node.js
|
||||
|
||||
**Windows**
|
||||
|
||||
- 前往 <https://nodejs.org> 下载 LTS 版本安装包(`.msi`),一路下一步即可。
|
||||
- 安装完成后重新打开终端,运行 `node -v` 应显示版本号(≥ v14.14)。
|
||||
|
||||
**Linux(Debian/Ubuntu 示例)**
|
||||
|
||||
```bash
|
||||
# 使用 NodeSource 源安装新版 Node
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
node -v
|
||||
```
|
||||
|
||||
> 其它发行版请参考 Node.js 官方安装文档,或使用包管理器安装 `nodejs` / `npm`。
|
||||
|
||||
### 2. 安装 FFmpeg
|
||||
|
||||
**Windows(推荐)**
|
||||
|
||||
方式一:用 winget(Windows 11 自带):
|
||||
|
||||
```powershell
|
||||
winget install Gyan.FFmpeg
|
||||
```
|
||||
|
||||
方式二:手动下载
|
||||
|
||||
1. 打开 <https://www.gyan.dev/ffmpeg/builds/>,下载 `release essentials` 的 zip 包。
|
||||
2. 解压到固定目录(如 `C:\ffmpeg`)。
|
||||
3. 把解压后 **`bin` 子目录**的完整路径(如 `C:\ffmpeg\bin`)加入系统环境变量 `Path`:
|
||||
- `Win` 键 → 搜索「编辑系统环境变量」→ 「环境变量」→ 双击「Path」→ 新建 → 粘贴路径 → 确定。
|
||||
4. **重新打开终端**,运行 `ffmpeg -version` 验证。
|
||||
|
||||
方式三:用 Chocolatey / Scoop:
|
||||
|
||||
```powershell
|
||||
choco install ffmpeg # 或
|
||||
scoop install ffmpeg
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt update && sudo apt install -y ffmpeg
|
||||
|
||||
# CentOS / RHEL / Fedora
|
||||
sudo dnf install -y ffmpeg # 部分发行版需先启用 EPEL 或 RPM Fusion 源
|
||||
|
||||
# Arch
|
||||
sudo pacman -S ffmpeg
|
||||
```
|
||||
|
||||
验证安装:
|
||||
|
||||
```bash
|
||||
ffmpeg -version
|
||||
ffprobe -version
|
||||
```
|
||||
|
||||
### 3. 安装项目依赖
|
||||
|
||||
在项目根目录(`demo/`)下:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
启动成功后控制台会输出:
|
||||
|
||||
```
|
||||
[INFO] 资源: N 个文件
|
||||
[CACHE] 启动扫描: ...
|
||||
[LIVE] 已启动 | 切片 5s | 窗口 15 段 ≈75s | ...
|
||||
[HTTP] 面板: http://127.0.0.1:3000
|
||||
[HTTP] 直播: http://127.0.0.1:3000/hls/index.m3u8
|
||||
```
|
||||
|
||||
- **控制面板**:浏览器打开 <http://127.0.0.1:3000>
|
||||
- **直播流地址**:<http://127.0.0.1:3000/hls/index.m3u8>(可用 VLC、PotPlayer、ffplay 等播放器打开)
|
||||
|
||||
停止服务:在终端按 `Ctrl + C`。
|
||||
|
||||
> 💡 Windows 下如果端口被旧进程占用导致启动失败(`EADDRINUSE`),见下文「常见问题」。
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 添加音频
|
||||
|
||||
把音频文件(支持 `.mp3` / `.m4a` / `.flac` / `.wav`)直接放进 `audio/` 目录。程序用 chokidar 监听目录,**无需重启**,新文件会自动扫描并开始转码切片。
|
||||
|
||||
> 文件名建议用「作者 - 歌名.mp3」格式,程序会据此自动解析出作者与歌名,用于面板展示。
|
||||
|
||||
### 网易云音源导入
|
||||
|
||||
1. 在控制面板左侧「资源列表」顶部输入框填入网易云歌曲 ID。
|
||||
2. 点「查询」,预览区会显示封面、歌名、作者。
|
||||
3. 点「下载」,音频会自动保存到 `audio/` 并入库切片,封面/歌名/作者写入元数据。
|
||||
|
||||
**网易云歌曲 ID 在哪找?** 打开网易云网页版某首歌的页面,地址栏 `https://music.163.com/song?id=33894312` 中 `id=` 后面的数字即为歌曲 ID。
|
||||
|
||||
> ⚠️ 版权限制:只有免费、可播的歌曲能下载。VIP / 灰色无版权歌曲会被拦截并提示「无版权或需 VIP」,这是网易云服务端的限制,无法绕过。
|
||||
|
||||
### 管理播放列表
|
||||
|
||||
控制面板右侧「播放列表」支持:
|
||||
|
||||
- ➕ 点资源卡片右侧的加号把歌曲加入播放列表
|
||||
- 🖱️ 拖拽排序(SortableJS)
|
||||
- ❌ 移除单曲
|
||||
- 🗑️ 清空列表(清空后自动随机补一首,保证直播不断流)
|
||||
- 歌曲播完自动从列表移除,列表空了自动随机补一首
|
||||
|
||||
播放列表与热度会持久化到磁盘,重启后保留。
|
||||
|
||||
## 目录结构与数据文件
|
||||
|
||||
```
|
||||
demo/
|
||||
├── index.js # 主程序(后端 + HLS 直播 + HTTP API)
|
||||
├── package.json # 依赖与启动脚本
|
||||
├── public/ # 前端控制面板(静态文件,含 favicon)
|
||||
│ ├── index.html
|
||||
│ └── favicon.png
|
||||
├── audio/ # 【你的音频】放这里,程序自动监听
|
||||
└── (以下为运行时自动生成,可随时删除,重启自动重建)
|
||||
├── hls/ # 直播流滑动窗口 index.m3u8
|
||||
├── hls_cache/ # 每首歌的 HLS 切片缓存(上限 1GB,自动清理)
|
||||
├── heat.json # 歌曲热度(缓存清理优先级)
|
||||
├── playlist.json # 播放列表(持久化)
|
||||
└── metadata.json # 歌曲元数据(歌名/作者/封面图/时长)
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `AUTH_TOKEN` | (空) | 设置后,所有 `/api/*` 接口需携带请求头 `Authorization: Bearer <token>` |
|
||||
| `USE_DISCONTINUITY` | `false` | 设为 `true` 时在歌曲切换处插入 `#EXT-X-DISCONTINUITY` 标签(兼容性更好则保持关闭) |
|
||||
| `PORT` | `3000` | HTTP 服务端口 |
|
||||
| `HLS_TIME` | `5` | 单个 HLS 切片时长(秒),同时决定直播 tick 间隔 |
|
||||
| `SLIDE_WINDOW` | `15` | 直播滑动窗口段数(≈ 窗口秒数 = HLS_TIME × 该值) |
|
||||
| `MAX_CACHE_MB` | `1000` | HLS 缓存上限(MB),超出按热度淘汰 |
|
||||
| `FFMPEG_CONCURRENCY` | `3` | FFmpeg 转码并发数 |
|
||||
| `MAX_RETRIES` | `3` | 单文件转码失败重试上限 |
|
||||
| `FFMPEG_TIMEOUT_MS` | `300000` | 单次 FFmpeg 转码超时(毫秒) |
|
||||
|
||||
设置方式(三平台):
|
||||
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
$env:AUTH_TOKEN="your_token"; npm start
|
||||
```
|
||||
|
||||
**Windows CMD**
|
||||
|
||||
```cmd
|
||||
set AUTH_TOKEN=your_token && npm start
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
AUTH_TOKEN=your_token npm start
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 端口 3000 被占用
|
||||
|
||||
**Windows**
|
||||
|
||||
```powershell
|
||||
# 找到占用 3000 端口的 PID
|
||||
netstat -ano | findstr :3000
|
||||
# 结束该进程(把 <PID> 换成上一条命令查到的数字)
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
# 查看占用进程
|
||||
sudo lsof -i :3000
|
||||
# 或
|
||||
sudo ss -tlnp | grep 3000
|
||||
# 结束进程(把 <PID> 换成实际值)
|
||||
kill <PID>
|
||||
```
|
||||
|
||||
### 启动成功但直播流没声音 / 一直缓冲
|
||||
|
||||
1. 确认 `audio/` 目录里确有音频文件。
|
||||
2. 确认 FFmpeg 已正确安装并在 PATH 中:`ffmpeg -version`。
|
||||
3. 看启动日志里 `[PRESEG]` 是否报错——若某首歌转码失败会打印 `[PRESEG] 失败`,可检查该文件是否损坏。
|
||||
4. 首次启动需对所有歌曲转码,耗时随歌曲数量与长度增加,请耐心等待日志出现 `[PRESEG] 完成`。
|
||||
|
||||
### 网易云下载报「无版权或需 VIP」
|
||||
|
||||
正常现象,见上文「网易云音源导入」的版权说明。换一首免费歌曲即可。
|
||||
|
||||
### 局域网内其它设备访问不到
|
||||
|
||||
- 确认目标设备能 ping 通本机 IP。
|
||||
- **Windows**:需在防火墙中放行 Node.js(或放行 3000 端口)。首次启动时若弹出防火墙授权,点「允许」;否则在「Windows 安全中心 → 防火墙 → 允许应用通过防火墙」中手动添加 node.exe。
|
||||
- **Linux**:若有 `ufw` / `firewalld` 需放行端口:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 3000 # ufw
|
||||
sudo firewall-cmd --add-port=3000/tcp --permanent && sudo firewall-cmd --reload # firewalld
|
||||
```
|
||||
|
||||
然后用本机局域网 IP 访问:`http://<本机IP>:3000`。
|
||||
|
||||
## 技术栈
|
||||
|
||||
Node.js · Express · Chokidar · FFmpeg(HLS 切片)· SortableJS · Bootstrap Icons
|
||||
Generated
+1022
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "music-online-demo",
|
||||
"version": "1.0.0",
|
||||
"description": "音乐在线电台:FFmpeg HLS 直播流服务",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,397 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1"
|
||||
/>
|
||||
<meta name="theme-color" content="#f4f5f9" />
|
||||
<title>Xiao の 电台</title>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-bg"></div>
|
||||
<div class="bg-shapes">
|
||||
<div class="s"></div>
|
||||
<div class="s"></div>
|
||||
<div class="s"></div>
|
||||
</div>
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="top-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">
|
||||
<img src="favicon.png" alt="logo" />
|
||||
</div>
|
||||
<span>Xiao の 电台</span>
|
||||
<span class="live-pill"
|
||||
><span class="live-dot"></span>LIVE</span
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span class="cache-pill" id="cacheInfo"
|
||||
><i class="bi bi-database"></i> --</span
|
||||
>
|
||||
<a
|
||||
href="/hls/index.m3u8"
|
||||
class="btn-link-sm"
|
||||
target="_blank"
|
||||
><i class="bi bi-link-45deg"></i> 直播流</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pages -->
|
||||
<div class="page-container">
|
||||
<!-- 1. Search -->
|
||||
<div class="page active" id="pageSearch">
|
||||
<div class="search-card">
|
||||
<div class="search-header">
|
||||
<h2>导入音源</h2>
|
||||
<p>输入网易云歌曲 ID、链接或歌名查询并下载</p>
|
||||
</div>
|
||||
<div class="search-input-wrap">
|
||||
<div class="search-field">
|
||||
<input
|
||||
type="text"
|
||||
id="musicId"
|
||||
placeholder="歌曲 ID、链接或歌名,如 33894312 / 知我"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button class="btn-search" id="btnSearch">
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="parse-hint" id="parseHint">
|
||||
<i class="bi bi-magic"></i>
|
||||
已自动解析为 ID:<code id="parsedId"></code>
|
||||
</div>
|
||||
<!-- Preview: info only -->
|
||||
<div id="musicPreview" style="display: none">
|
||||
<div class="preview-card">
|
||||
<div class="preview-cover-section">
|
||||
<div class="preview-cover-wrap">
|
||||
<img
|
||||
id="musicCover"
|
||||
class="preview-cover"
|
||||
src=""
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="preview-info">
|
||||
<div
|
||||
class="preview-title"
|
||||
id="musicName"
|
||||
></div>
|
||||
<div
|
||||
class="preview-artist"
|
||||
id="musicArtist"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="preview-details"
|
||||
id="musicDetails"
|
||||
></div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn-download" id="btnDownload">
|
||||
<i class="bi bi-download"></i> 下载到资源库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="searchResults"
|
||||
class="searchResults"
|
||||
style="display: none"
|
||||
></div>
|
||||
<div class="search-tips" id="searchTips">
|
||||
<div class="tips-icon">
|
||||
<i class="bi bi-music-note-beamed"></i>
|
||||
</div>
|
||||
<div class="tips-title">搜索并下载音乐</div>
|
||||
<div class="tips-desc">
|
||||
在网易云音乐中复制歌曲 ID 或完整链接<br />粘贴到上方输入框即可自动识别
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Resources -->
|
||||
<div class="page" id="pageResources">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<i
|
||||
class="bi bi-folder2-open"
|
||||
style="color: var(--accent)"
|
||||
></i>
|
||||
已有音乐
|
||||
<span class="section-count" id="resourceCount"
|
||||
>0</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="resource-search"
|
||||
id="resourceSearch"
|
||||
placeholder="搜索已有音乐..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-body" id="resourceList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</div>
|
||||
<div class="empty-title">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Playlist -->
|
||||
<div class="page" id="pagePlaylist">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<i
|
||||
class="bi bi-music-note-list"
|
||||
style="color: var(--accent)"
|
||||
></i>
|
||||
播放列表
|
||||
<span class="section-count" id="playlistCount"
|
||||
>0</span
|
||||
>
|
||||
</div>
|
||||
<button class="btn-clear" id="btnClear">
|
||||
<i class="bi bi-trash3"></i> 清空
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-body" id="playlistList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</div>
|
||||
<div class="empty-title">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. About — 保持原有风格体系 -->
|
||||
<div class="page" id="pageAbout">
|
||||
<div class="about-card">
|
||||
<!-- Hero -->
|
||||
<div class="about-hero">
|
||||
<div class="about-logo">
|
||||
<img src="favicon.png" alt="logo" />
|
||||
</div>
|
||||
<h2>Xiao の 电台</h2>
|
||||
<p>基于 HLS 的个人在线音乐电台</p>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="about-body">
|
||||
<!-- 项目简介 -->
|
||||
<div class="about-section">
|
||||
<div class="about-section-icon">
|
||||
<i class="bi bi-book"></i>
|
||||
</div>
|
||||
<div class="about-section-content">
|
||||
<h3>项目简介</h3>
|
||||
<p>
|
||||
一个基于 Web 的个人在线音乐电台,支持通过网易云音乐歌曲 ID、链接或歌名进行搜索、试听与下载,提供 HLS 直播流播放、资源管理与播放列表拖拽排序等功能。界面采用简约玻璃拟态风格,适配移动端与桌面端多种屏幕尺寸。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 核心功能 -->
|
||||
<div class="about-section">
|
||||
<div class="about-section-icon">
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
</div>
|
||||
<div class="about-section-content">
|
||||
<h3>核心功能</h3>
|
||||
<div class="about-feature-list">
|
||||
<div class="about-feat">
|
||||
<i class="bi bi-search" style="color: var(--accent)"></i>
|
||||
<span>智能搜索 — 歌曲 ID、链接或歌名关键词,自动解析并一键导入</span>
|
||||
</div>
|
||||
<div class="about-feat">
|
||||
<i class="bi bi-broadcast" style="color: var(--success)"></i>
|
||||
<span>直播流 — FFmpeg HLS 切片 + 滑动窗口,5 秒精度连续输出</span>
|
||||
</div>
|
||||
<div class="about-feat">
|
||||
<i class="bi bi-database-check" style="color: var(--warning)"></i>
|
||||
<span>智能缓存 — 预生成切片、增量追踪、LRU 淘汰与自动清理</span>
|
||||
</div>
|
||||
<div class="about-feat">
|
||||
<i class="bi bi-hand-index-thumb" style="color: #8b5cf6"></i>
|
||||
<span>播放列表 — 拖拽排序、添加/移除/跳转,SSE 实时状态推送</span>
|
||||
</div>
|
||||
<div class="about-feat">
|
||||
<i class="bi bi-phone" style="color: #06b6d4"></i>
|
||||
<span>全端适配 — 玻璃拟态 UI,手势滑动、键盘快捷键、拖拽进度条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术栈 -->
|
||||
<div class="about-section">
|
||||
<div class="about-section-icon">
|
||||
<i class="bi bi-tools"></i>
|
||||
</div>
|
||||
<div class="about-section-content">
|
||||
<h3>技术栈</h3>
|
||||
<div class="tech-tags">
|
||||
<span>HTML5</span>
|
||||
<span>CSS3</span>
|
||||
<span>JavaScript ES6+</span>
|
||||
<span>Bootstrap 5</span>
|
||||
<span>SortableJS</span>
|
||||
<span>HLS.js</span>
|
||||
<span>Node.js</span>
|
||||
<span>Express</span>
|
||||
<span>FFmpeg</span>
|
||||
<span>Chokidar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开发语言 -->
|
||||
<div class="about-section">
|
||||
<div class="about-section-icon">
|
||||
<i class="bi bi-translate"></i>
|
||||
</div>
|
||||
<div class="about-section-content">
|
||||
<h3>开发语言</h3>
|
||||
<p>前端:HTML / CSS / JavaScript | 后端:Node.js</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作者 -->
|
||||
<div class="about-section">
|
||||
<div class="about-section-icon">
|
||||
<i class="bi bi-person"></i>
|
||||
</div>
|
||||
<div class="about-section-content">
|
||||
<h3>作者</h3>
|
||||
<p style="margin-bottom: 0.45rem">Xiao — 独立开发者</p>
|
||||
<a
|
||||
href="https://github.com/Xiao-no-love/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="about-gh-link"
|
||||
>
|
||||
<i class="bi bi-github"></i>
|
||||
<span>Xiao-no-love</span>
|
||||
<i class="bi bi-box-arrow-up-right" style="font-size: 0.6rem; opacity: 0.5"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<div class="about-section disclaimer-section">
|
||||
<div class="about-section-icon">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="about-section-content">
|
||||
<h3>免责声明</h3>
|
||||
<p>
|
||||
本项目所有音源均来自网易云音乐,仅供个人学习与技术研究使用,严禁用于任何商业用途。音乐版权归原创作者及网易云音乐平台所有。如有侵权,请联系删除。请于下载后 24 小时内清除,支持正版音乐。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="about-version">
|
||||
v1.0.0 · Built with ♥ ·
|
||||
<a
|
||||
href="https://github.com/Xiao-no-love/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="color: var(--accent); text-decoration: none"
|
||||
>GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini Player — full background progress + drag seek -->
|
||||
<div class="mini-player" id="miniPlayer">
|
||||
<div class="mp-progress" id="mpProgress"></div>
|
||||
<div class="mp-body" id="mpBody">
|
||||
<img
|
||||
class="mp-cover"
|
||||
id="mpCover"
|
||||
src=""
|
||||
alt=""
|
||||
onerror="this.style.display = 'none'"
|
||||
/>
|
||||
<div class="mp-info">
|
||||
<div class="mp-name" id="mpName">未在播放</div>
|
||||
<div class="mp-artist" id="mpArtist">--</div>
|
||||
</div>
|
||||
<div class="mp-controls">
|
||||
<span class="mp-time" id="mpTime">0:00</span>
|
||||
<button class="mp-play" id="mpPlay">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<button class="mp-expand" id="mpExpand" title="查看详情">
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Nav — 4 tabs, full-width aligned with content -->
|
||||
<nav class="bottom-nav">
|
||||
<div class="bottom-nav-inner">
|
||||
<button class="nav-tab active" data-page="pageSearch">
|
||||
<i class="bi bi-search"></i><span>搜索</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pageResources">
|
||||
<i class="bi bi-folder"></i><span>资源</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pagePlaylist">
|
||||
<i class="bi bi-list-ul"></i><span>列表</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pageAbout">
|
||||
<i class="bi bi-info-circle"></i><span>关于</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Global audio element -->
|
||||
<audio id="musicPlayer" preload="none" style="display: none"></audio>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,758 @@
|
||||
/* ============================================================
|
||||
Xiao の 电台 — Enhanced JS
|
||||
============================================================ */
|
||||
|
||||
// ==================== Toast ====================
|
||||
function showToast(msg, type = "") {
|
||||
const c = document.getElementById("toastContainer");
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast-msg" + (type ? " " + type : "");
|
||||
el.textContent = msg;
|
||||
c.appendChild(el);
|
||||
setTimeout(() => el.remove(), 3000);
|
||||
}
|
||||
|
||||
// ==================== Navigation ====================
|
||||
const tabs = document.querySelectorAll(".nav-tab");
|
||||
const pages = document.querySelectorAll(".page");
|
||||
|
||||
function switchPage(pageId) {
|
||||
pages.forEach((p) => p.classList.remove("active"));
|
||||
tabs.forEach((t) => t.classList.remove("active"));
|
||||
const page = document.getElementById(pageId);
|
||||
if (page) page.classList.add("active");
|
||||
tabs.forEach((t) => {
|
||||
if (t.dataset.page === pageId) t.classList.add("active");
|
||||
});
|
||||
// Eager-load data on tab switch
|
||||
if (pageId === "pageResources") loadResources();
|
||||
if (pageId === "pagePlaylist") loadPlaylist();
|
||||
}
|
||||
tabs.forEach((tab) =>
|
||||
tab.addEventListener("click", () =>
|
||||
switchPage(tab.dataset.page),
|
||||
),
|
||||
);
|
||||
|
||||
// ==================== Helpers ====================
|
||||
function esc(s) {
|
||||
return String(s ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[c],
|
||||
);
|
||||
}
|
||||
function formatDuration(sec) {
|
||||
if (!sec && sec !== 0) return "--:--";
|
||||
const s = Math.round(sec),
|
||||
m = Math.floor(s / 60),
|
||||
ss = s % 60;
|
||||
return m + ":" + String(ss).padStart(2, "0");
|
||||
}
|
||||
function formatBytes(b) {
|
||||
if (!b) return "0 B";
|
||||
if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB";
|
||||
if (b >= 1024) return (b / 1024).toFixed(1) + " KB";
|
||||
return b + " B";
|
||||
}
|
||||
async function api(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ==================== ID Extraction ====================
|
||||
function extractMusicId(input) {
|
||||
input = input.trim();
|
||||
if (!input) return "";
|
||||
if (/^\d+$/.test(input)) return input;
|
||||
const patterns = [
|
||||
/music\.163\.com\/#\/song\?.*id=(\d+)/,
|
||||
/music\.163\.com\/song\?.*id=(\d+)/,
|
||||
/music\.163\.com\/song\/(\d+)/,
|
||||
/y\.music\.163\.com\/m\/song\?.*id=(\d+)/,
|
||||
/[?&]id=(\d+)/,
|
||||
/(\d{5,})/,
|
||||
];
|
||||
for (const p of patterns) {
|
||||
const m = input.match(p);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
// ==================== Parse Hint ====================
|
||||
const musicIdInput = document.getElementById("musicId");
|
||||
const parseHint = document.getElementById("parseHint");
|
||||
const parsedIdEl = document.getElementById("parsedId");
|
||||
function updateParseHint() {
|
||||
const val = musicIdInput.value.trim(),
|
||||
extracted = extractMusicId(val);
|
||||
if (val && extracted !== val && /^\d+$/.test(extracted)) {
|
||||
parsedIdEl.textContent = extracted;
|
||||
parseHint.classList.add("visible");
|
||||
} else {
|
||||
parseHint.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
musicIdInput.addEventListener("input", updateParseHint);
|
||||
musicIdInput.addEventListener("paste", () =>
|
||||
requestAnimationFrame(updateParseHint),
|
||||
);
|
||||
|
||||
// ==================== List Diff ====================
|
||||
function reconcileList(container, items, keyOf, create, update) {
|
||||
const nodes = Array.from(
|
||||
container.querySelectorAll("[data-key]"),
|
||||
);
|
||||
const byKey = new Map(nodes.map((n) => [n.dataset.key, n]));
|
||||
const seen = new Set();
|
||||
items.forEach((item, idx) => {
|
||||
const key = String(keyOf(item));
|
||||
seen.add(key);
|
||||
let el = byKey.get(key);
|
||||
if (!el) {
|
||||
el = create(item);
|
||||
el.dataset.key = key;
|
||||
el.classList.add("enter");
|
||||
}
|
||||
update(el, item, idx);
|
||||
if (container.children[idx] !== el)
|
||||
container.insertBefore(
|
||||
el,
|
||||
container.children[idx] || null,
|
||||
);
|
||||
});
|
||||
nodes.forEach((el) => {
|
||||
if (!seen.has(el.dataset.key)) {
|
||||
el.classList.add("leave");
|
||||
el.addEventListener("animationend", () => el.remove(), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Resources ====================
|
||||
function renderResItem(item) {
|
||||
const cover = item.albumImage
|
||||
? `<img class="res-cover" src="${esc(item.albumImage)}" alt="" loading="lazy">`
|
||||
: `<div class="res-cover-ph"><i class="bi bi-music-note"></i></div>`;
|
||||
const bg = item.albumImage
|
||||
? `<div class="res-bg" style="background-image:url('${esc(item.albumImage)}')"></div>`
|
||||
: "";
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = `<div class="res-item">${bg}
|
||||
<div class="res-row">${cover}
|
||||
<div class="res-meta">
|
||||
<div class="res-title" title="${esc(item.title)}">${esc(item.title)}</div>
|
||||
<div class="res-artist">${esc(item.artist) || "未知作者"}</div>
|
||||
</div>
|
||||
<div class="res-actions">
|
||||
<span class="heat-chip"><i class="bi bi-fire"></i><span class="js-heat">${item.heat}</span></span>
|
||||
<button class="btn-icon" onclick="addToPlaylist('${esc(item.file)}')" title="添加到播放列表">
|
||||
<i class="bi bi-plus-circle-fill" style="color:var(--accent)"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="res-detail">
|
||||
<span><i class="bi bi-clock"></i> ${formatDuration(item.duration)}</span>
|
||||
<span class="detail-sep">|</span>
|
||||
<span><i class="bi bi-file-earmark"></i> ${formatBytes(item.fileSize)}</span>
|
||||
<span class="detail-sep">|</span>
|
||||
<span><i class="bi bi-database"></i> 缓存 <span class="js-cache">${formatBytes(item.cacheSize)}</span></span>
|
||||
</div>
|
||||
</div>`;
|
||||
return tpl.content.firstElementChild;
|
||||
}
|
||||
function updateResItem(el, item) {
|
||||
const h = el.querySelector(".js-heat");
|
||||
if (h.textContent !== String(item.heat))
|
||||
h.textContent = item.heat;
|
||||
const c = el.querySelector(".js-cache"),
|
||||
cs = formatBytes(item.cacheSize);
|
||||
if (c.textContent !== cs) c.textContent = cs;
|
||||
}
|
||||
let resourceKeyword = "";
|
||||
function applyResourceFilter() {
|
||||
document
|
||||
.getElementById("resourceList")
|
||||
.querySelectorAll(".res-item")
|
||||
.forEach((el) => {
|
||||
const t = (
|
||||
el.querySelector(".res-title")?.textContent || ""
|
||||
).toLowerCase();
|
||||
const a = (
|
||||
el.querySelector(".res-artist")?.textContent || ""
|
||||
).toLowerCase();
|
||||
el.style.display =
|
||||
!resourceKeyword ||
|
||||
t.includes(resourceKeyword) ||
|
||||
a.includes(resourceKeyword)
|
||||
? ""
|
||||
: "none";
|
||||
});
|
||||
}
|
||||
async function loadResources() {
|
||||
try {
|
||||
const resources = await api("/api/resources");
|
||||
const container = document.getElementById("resourceList");
|
||||
document.getElementById("resourceCount").textContent =
|
||||
resources.length;
|
||||
if (resources.length === 0) {
|
||||
container.innerHTML = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-folder-x"></i></div><div class="empty-title">暂无资源</div><div class="empty-desc">前往搜索页导入音乐</div></div>`;
|
||||
return;
|
||||
}
|
||||
if (!container.querySelector("[data-key]"))
|
||||
container.innerHTML = "";
|
||||
reconcileList(
|
||||
container,
|
||||
resources,
|
||||
(r) => r.file,
|
||||
renderResItem,
|
||||
updateResItem,
|
||||
);
|
||||
applyResourceFilter();
|
||||
} catch (err) {
|
||||
console.error("加载资源失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Playlist ====================
|
||||
let sortableInstance = null;
|
||||
function renderPlItem(item) {
|
||||
const tpl = document.createElement("template");
|
||||
tpl.innerHTML = `<div class="pl-item" data-id="${esc(item.path)}">
|
||||
<i class="bi bi-grip-vertical pl-grip"></i>
|
||||
<span class="pl-num"></span>
|
||||
<div class="pl-info">
|
||||
<div class="pl-name" title="${esc(item.name)}">${esc(item.name)}</div>
|
||||
<div class="pl-sub">
|
||||
<span class="heat-chip pl-heat"><i class="bi bi-fire"></i><span class="js-heat"></span></span>
|
||||
<span class="pl-cache"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon danger" title="移除"><i class="bi bi-x-circle-fill"></i></button>
|
||||
</div>`;
|
||||
return tpl.content.firstElementChild;
|
||||
}
|
||||
function updatePlItem(el, item, idx) {
|
||||
const numEl = el.querySelector(".pl-num");
|
||||
if (numEl.textContent !== String(idx + 1))
|
||||
numEl.textContent = idx + 1;
|
||||
const heatEl = el.querySelector(".js-heat");
|
||||
if (heatEl.textContent !== String(item.heat))
|
||||
heatEl.textContent = item.heat;
|
||||
const cacheEl = el.querySelector(".pl-cache");
|
||||
const st =
|
||||
item.cacheStatus === "ready"
|
||||
? "ready"
|
||||
: item.cacheStatus === "pending"
|
||||
? "pending"
|
||||
: "none";
|
||||
if (cacheEl.dataset.status !== st) {
|
||||
cacheEl.dataset.status = st;
|
||||
cacheEl.className = "pl-cache " + st;
|
||||
cacheEl.innerHTML =
|
||||
st === "ready"
|
||||
? '<i class="bi bi-check-circle-fill"></i> 已缓存'
|
||||
: st === "pending"
|
||||
? '<i class="bi bi-hourglass-split"></i> 生成中'
|
||||
: '<i class="bi bi-cloud"></i> 未缓存';
|
||||
}
|
||||
el.querySelector("button").setAttribute(
|
||||
"onclick",
|
||||
"removeFromPlaylist(" + idx + ")",
|
||||
);
|
||||
}
|
||||
async function loadPlaylist() {
|
||||
try {
|
||||
const playlistData = await api("/api/playlist");
|
||||
const container = document.getElementById("playlistList");
|
||||
document.getElementById("playlistCount").textContent =
|
||||
playlistData.length;
|
||||
if (playlistData.length === 0) {
|
||||
container.innerHTML = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-music-note"></i></div><div class="empty-title">播放列表为空</div><div class="empty-desc">从资源列表中添加音乐</div></div>`;
|
||||
if (sortableInstance) {
|
||||
sortableInstance.destroy();
|
||||
sortableInstance = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!container.querySelector("[data-key]"))
|
||||
container.innerHTML = "";
|
||||
reconcileList(
|
||||
container,
|
||||
playlistData,
|
||||
(p) => p.path,
|
||||
renderPlItem,
|
||||
updatePlItem,
|
||||
);
|
||||
if (!sortableInstance) {
|
||||
sortableInstance = new Sortable(container, {
|
||||
animation: 200,
|
||||
ghostClass: "sortable-ghost",
|
||||
handle: ".pl-grip",
|
||||
delay: 100,
|
||||
delayOnTouchOnly: true,
|
||||
onEnd: async () => {
|
||||
const items = [
|
||||
...container.querySelectorAll(".pl-item"),
|
||||
];
|
||||
const newOrder = items.map(
|
||||
(el) => el.dataset.id,
|
||||
);
|
||||
try {
|
||||
await api("/api/playlist/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ newOrder }),
|
||||
});
|
||||
items.forEach((el, i) => {
|
||||
el.querySelector(
|
||||
".pl-num",
|
||||
).textContent = i + 1;
|
||||
el.querySelector("button").setAttribute(
|
||||
"onclick",
|
||||
"removeFromPlaylist(" + i + ")",
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("排序更新失败:", err);
|
||||
loadPlaylist();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("加载播放列表失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Actions ====================
|
||||
async function addToPlaylist(fileName) {
|
||||
try {
|
||||
await api("/api/playlist/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ file: fileName }),
|
||||
});
|
||||
showToast("已添加到播放列表", "success");
|
||||
await loadPlaylist();
|
||||
await loadResources();
|
||||
} catch (err) {
|
||||
showToast("添加失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
async function removeFromPlaylist(index) {
|
||||
try {
|
||||
await api("/api/playlist/remove", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ index }),
|
||||
});
|
||||
await loadPlaylist();
|
||||
} catch (err) {
|
||||
showToast("移除失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
async function clearPlaylist() {
|
||||
if (!confirm("确定清空播放列表?清空后将自动随机添加一首。"))
|
||||
return;
|
||||
try {
|
||||
await api("/api/playlist/clear", { method: "POST" });
|
||||
await loadPlaylist();
|
||||
} catch (err) {
|
||||
showToast("清空失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Cache Info ====================
|
||||
async function loadCacheInfo() {
|
||||
try {
|
||||
const info = await api("/api/cache/size");
|
||||
document.getElementById("cacheInfo").innerHTML =
|
||||
`<i class="bi bi-database"></i> ${(info.total / 1048576).toFixed(1)}/${(info.limit / 1048576).toFixed(0)}MB (${info.usedPercent}%)`;
|
||||
} catch (err) {
|
||||
console.error("加载缓存信息失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Mini Player ==================== //
|
||||
const musicPlayer = document.getElementById("musicPlayer");
|
||||
const miniPlayer = document.getElementById("miniPlayer");
|
||||
const mpBody = document.getElementById("mpBody");
|
||||
const mpProgress = document.getElementById("mpProgress");
|
||||
const mpCover = document.getElementById("mpCover");
|
||||
const mpName = document.getElementById("mpName");
|
||||
const mpArtist = document.getElementById("mpArtist");
|
||||
const mpPlay = document.getElementById("mpPlay");
|
||||
const mpTime = document.getElementById("mpTime");
|
||||
const mpExpand = document.getElementById("mpExpand");
|
||||
|
||||
function showMiniPlayer(info) {
|
||||
mpName.textContent = info.name || "未知";
|
||||
mpArtist.textContent = info.artist || "--";
|
||||
if (info.albumImage) {
|
||||
mpCover.src = info.albumImage;
|
||||
mpCover.style.display = "";
|
||||
} else {
|
||||
mpCover.style.display = "none";
|
||||
}
|
||||
miniPlayer.classList.add("show");
|
||||
document.body.classList.add("mini-player-visible");
|
||||
}
|
||||
function hideMiniPlayer() {
|
||||
miniPlayer.classList.remove("show");
|
||||
document.body.classList.remove("mini-player-visible");
|
||||
}
|
||||
|
||||
mpPlay.addEventListener("click", () => {
|
||||
if (musicPlayer.paused) musicPlayer.play();
|
||||
else musicPlayer.pause();
|
||||
});
|
||||
musicPlayer.addEventListener("play", () => {
|
||||
mpPlay.innerHTML = '<i class="bi bi-pause-fill"></i>';
|
||||
});
|
||||
musicPlayer.addEventListener("pause", () => {
|
||||
mpPlay.innerHTML = '<i class="bi bi-play-fill"></i>';
|
||||
});
|
||||
musicPlayer.addEventListener("timeupdate", () => {
|
||||
if (!musicPlayer.duration) return;
|
||||
const pct =
|
||||
(musicPlayer.currentTime / musicPlayer.duration) * 100;
|
||||
mpProgress.style.width = pct + "%";
|
||||
mpTime.textContent = formatDuration(musicPlayer.currentTime);
|
||||
});
|
||||
musicPlayer.addEventListener("ended", () => {
|
||||
mpPlay.innerHTML = '<i class="bi bi-play-fill"></i>';
|
||||
});
|
||||
|
||||
mpExpand.addEventListener("click", () => {
|
||||
switchPage("pageSearch");
|
||||
const preview = document.getElementById("musicPreview");
|
||||
if (preview.style.display !== "none")
|
||||
preview.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Drag-to-seek on the entire mini player bar ----
|
||||
let isSeeking = false;
|
||||
function seekFromPointer(e) {
|
||||
const rect = mpBody.getBoundingClientRect();
|
||||
const x = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (x - rect.left) / rect.width),
|
||||
);
|
||||
if (musicPlayer.duration) {
|
||||
musicPlayer.currentTime = ratio * musicPlayer.duration;
|
||||
mpProgress.style.width = ratio * 100 + "%";
|
||||
}
|
||||
}
|
||||
mpBody.addEventListener("mousedown", (e) => {
|
||||
if (e.target.closest("button")) return;
|
||||
isSeeking = true;
|
||||
seekFromPointer(e);
|
||||
});
|
||||
mpBody.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
if (e.target.closest("button")) return;
|
||||
isSeeking = true;
|
||||
seekFromPointer(e);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (isSeeking) seekFromPointer(e);
|
||||
});
|
||||
document.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (isSeeking) seekFromPointer(e);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
document.addEventListener("mouseup", () => {
|
||||
isSeeking = false;
|
||||
});
|
||||
document.addEventListener("touchend", () => {
|
||||
isSeeking = false;
|
||||
});
|
||||
|
||||
// ==================== Music Search ====================
|
||||
let musicInfo = null,
|
||||
selectedId = null;
|
||||
|
||||
async function showDetail(id) {
|
||||
const info = await api("/api/music/detail", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
musicInfo = info;
|
||||
selectedId = id;
|
||||
document.getElementById("musicName").textContent = info.name;
|
||||
document.getElementById("musicArtist").textContent =
|
||||
info.artist || "未知作者";
|
||||
document.getElementById("musicCover").src =
|
||||
info.albumImage || "";
|
||||
|
||||
let tags = "";
|
||||
if (info.album)
|
||||
tags += `<span class="detail-tag album-tag"><i class="bi bi-disc"></i> ${esc(info.album)}</span>`;
|
||||
if (info.duration)
|
||||
tags += `<span class="detail-tag duration-tag"><i class="bi bi-clock"></i> ${formatDuration(info.duration)}</span>`;
|
||||
if (info.quality)
|
||||
tags += `<span class="detail-tag quality-tag"><i class="bi bi-badge-hd"></i> ${esc(info.quality)}</span>`;
|
||||
if (info.fileSize)
|
||||
tags += `<span class="detail-tag"><i class="bi bi-file-earmark-binary"></i> ${formatBytes(info.fileSize)}</span>`;
|
||||
if (info.publishTime) {
|
||||
const d = new Date(info.publishTime);
|
||||
tags += `<span class="detail-tag"><i class="bi bi-calendar3"></i> ${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}</span>`;
|
||||
}
|
||||
if (id)
|
||||
tags += `<span class="detail-tag"><i class="bi bi-hash"></i> ${id}</span>`;
|
||||
document.getElementById("musicDetails").innerHTML = tags;
|
||||
|
||||
document.getElementById("musicPreview").style.display = "block";
|
||||
document.getElementById("searchTips").style.display = "none";
|
||||
|
||||
musicPlayer.pause();
|
||||
musicPlayer.src =
|
||||
"http://music.163.com/song/media/outer/url?id=" +
|
||||
id +
|
||||
".mp3";
|
||||
|
||||
showMiniPlayer({
|
||||
name: info.name,
|
||||
artist: info.artist,
|
||||
albumImage: info.albumImage,
|
||||
});
|
||||
mpProgress.style.width = "0%";
|
||||
mpTime.textContent = "0:00";
|
||||
}
|
||||
|
||||
function renderSearchResults(data, keyword) {
|
||||
const container = document.getElementById("searchResults");
|
||||
musicInfo = null;
|
||||
selectedId = null;
|
||||
document.getElementById("musicPreview").style.display = "none";
|
||||
|
||||
if (!data.list || data.list.length === 0) {
|
||||
container.innerHTML = `<div class="empty-state"><div class="empty-icon"><i class="bi bi-search"></i></div><div class="empty-title">未找到相关歌曲</div><div class="empty-desc">换个关键词试试</div></div>`;
|
||||
container.style.display = "block";
|
||||
document.getElementById("searchTips").style.display =
|
||||
"none";
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="sr-head">"${esc(keyword)}" 共 <b>${data.total}</b> 首相关歌曲,点击选择:</div>
|
||||
<div class="sr-list">${data.list
|
||||
.map(
|
||||
(item, i) => `
|
||||
<div class="sr-item" data-id="${item.id}" style="animation-delay:${i * 0.04}s">
|
||||
<div class="sr-main">
|
||||
<div class="sr-name">${esc(item.name)}</div>
|
||||
<div class="sr-artist">${esc(item.artists.join(" / ") || "未知作者")}</div>
|
||||
</div>
|
||||
${item.alias && item.alias[0] ? `<div class="sr-alias" title="${esc(item.alias.join(" / "))}">${esc(item.alias[0])}</div>` : ""}
|
||||
<div class="sr-duration">${formatDuration(item.duration / 1000)}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("")}</div>`;
|
||||
container.style.display = "block";
|
||||
document.getElementById("searchTips").style.display = "none";
|
||||
|
||||
container.querySelectorAll(".sr-item").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
container
|
||||
.querySelectorAll(".sr-item")
|
||||
.forEach((x) => x.classList.remove("active"));
|
||||
el.classList.add("active");
|
||||
showDetail(el.dataset.id).catch((err) =>
|
||||
showToast("查询失败: " + err.message, "error"),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function searchMusic() {
|
||||
const rawInput = musicIdInput.value.trim();
|
||||
if (!rawInput)
|
||||
return showToast("请输入歌曲 ID、链接或歌名", "error");
|
||||
const id = extractMusicId(rawInput);
|
||||
const btn = document.getElementById("btnSearch");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "查询中...";
|
||||
try {
|
||||
if (id && /^\d+$/.test(id)) {
|
||||
document.getElementById("searchResults").style.display =
|
||||
"none";
|
||||
await showDetail(id);
|
||||
} else {
|
||||
renderSearchResults(
|
||||
await api(
|
||||
"/api/music/search?keyword=" +
|
||||
encodeURIComponent(rawInput),
|
||||
),
|
||||
rawInput,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("查询失败: " + err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "查询";
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMusic() {
|
||||
if (!musicInfo || !selectedId) return;
|
||||
const btn = document.getElementById("btnDownload");
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-sm"></span> 下载中';
|
||||
try {
|
||||
const r = await api("/api/music/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: selectedId,
|
||||
name: musicInfo.name,
|
||||
artist: musicInfo.artist,
|
||||
albumImage: musicInfo.albumImage,
|
||||
}),
|
||||
});
|
||||
musicIdInput.value = "";
|
||||
musicInfo = null;
|
||||
selectedId = null;
|
||||
document.getElementById("musicPreview").style.display =
|
||||
"none";
|
||||
document.getElementById("searchResults").style.display =
|
||||
"none";
|
||||
document.getElementById("searchTips").style.display =
|
||||
"block";
|
||||
parseHint.classList.remove("visible");
|
||||
loadResources();
|
||||
showToast("已下载: " + r.file, "success");
|
||||
} catch (err) {
|
||||
showToast("下载失败: " + err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML =
|
||||
'<i class="bi bi-download"></i> 下载到资源库';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Event Listeners ====================
|
||||
document
|
||||
.getElementById("btnSearch")
|
||||
.addEventListener("click", searchMusic);
|
||||
document
|
||||
.getElementById("btnDownload")
|
||||
.addEventListener("click", downloadMusic);
|
||||
document
|
||||
.getElementById("btnClear")
|
||||
.addEventListener("click", clearPlaylist);
|
||||
musicIdInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") searchMusic();
|
||||
});
|
||||
musicPlayer.addEventListener("error", () =>
|
||||
showToast("试听失败:该歌曲可能无版权", "error"),
|
||||
);
|
||||
document
|
||||
.getElementById("resourceSearch")
|
||||
.addEventListener("input", (e) => {
|
||||
resourceKeyword = e.target.value.trim().toLowerCase();
|
||||
applyResourceFilter();
|
||||
});
|
||||
|
||||
// ==================== Keyboard Shortcuts ====================
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (
|
||||
e.target.tagName === "INPUT" ||
|
||||
e.target.tagName === "TEXTAREA"
|
||||
)
|
||||
return;
|
||||
if (e.code === "Space") {
|
||||
e.preventDefault();
|
||||
if (musicPlayer.paused) musicPlayer.play();
|
||||
else musicPlayer.pause();
|
||||
}
|
||||
if (e.code === "ArrowRight")
|
||||
musicPlayer.currentTime = Math.min(
|
||||
musicPlayer.duration || 0,
|
||||
musicPlayer.currentTime + 5,
|
||||
);
|
||||
if (e.code === "ArrowLeft")
|
||||
musicPlayer.currentTime = Math.max(
|
||||
0,
|
||||
musicPlayer.currentTime - 5,
|
||||
);
|
||||
});
|
||||
|
||||
// ==================== Swipe Navigation ====================
|
||||
let touchStartX = 0,
|
||||
touchStartY = 0,
|
||||
touchStartTime = 0;
|
||||
const pageOrder = [
|
||||
"pageSearch",
|
||||
"pageResources",
|
||||
"pagePlaylist",
|
||||
"pageAbout",
|
||||
];
|
||||
document.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchStartTime = Date.now();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
document.addEventListener(
|
||||
"touchend",
|
||||
(e) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY;
|
||||
const dt = Date.now() - touchStartTime;
|
||||
if (
|
||||
Math.abs(dx) < 60 ||
|
||||
Math.abs(dx) < Math.abs(dy) * 1.5 ||
|
||||
dt > 500
|
||||
)
|
||||
return;
|
||||
const idx = pageOrder.findIndex((id) =>
|
||||
document
|
||||
.getElementById(id)
|
||||
?.classList.contains("active"),
|
||||
);
|
||||
if (idx < 0) return;
|
||||
if (dx < 0 && idx < pageOrder.length - 1)
|
||||
switchPage(pageOrder[idx + 1]);
|
||||
else if (dx > 0 && idx > 0) switchPage(pageOrder[idx - 1]);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
// ==================== Init ====================
|
||||
loadResources();
|
||||
loadPlaylist();
|
||||
loadCacheInfo();
|
||||
setInterval(() => {
|
||||
loadResources();
|
||||
loadPlaylist();
|
||||
loadCacheInfo();
|
||||
}, 5000);
|
||||
Reference in new issue
Block a user