mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3535e2e8a0 | |||
| e40f82cc51 | |||
| 68f29c8ea8 | |||
| 57aa9dae61 | |||
| c275461015 | |||
| 6da544cffb | |||
| 65ed71503e | |||
| 970fb15f5a | |||
| cf21c96eaf | |||
| 987d34f58b | |||
| baa6235c56 | |||
| 38c079ed5c | |||
| 68e3b92a6a | |||
| d718ee5b42 | |||
| 966825c885 | |||
| e8efc7275a | |||
| 0740d9be29 | |||
| d2546ca93c | |||
| 29df8ca491 | |||
| 3158338d0b | |||
| 79fb001ae7 | |||
| fd4bbb4a0a | |||
| 3b800e451f | |||
| 718d3ed641 | |||
| 02f7df4201 | |||
| 7847a9f6b2 |
81
.github/workflows/release.yml
vendored
Normal file
81
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
name: Release with Updater
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
platform: windows
|
||||
- os: macos-latest
|
||||
platform: macos
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libasound2-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libssl-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Extract release notes for version
|
||||
id: release_notes
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
# 从 CHANGELOG.md 中提取 "## v0.3.0" 到下一个 "## " 之间的内容
|
||||
NOTES=$(awk -v ver="## $VERSION" '$0 ~ ver {flag=1; next} /^## / && flag {exit} flag' CHANGELOG.md)
|
||||
|
||||
# 如果没有提取到内容,使用默认文本
|
||||
if [ -z "$NOTES" ]; then
|
||||
NOTES="See CHANGELOG.md for details."
|
||||
fi
|
||||
|
||||
# 将多行内容写入环境变量(GitHub Actions 支持多行输入)
|
||||
{
|
||||
echo 'NOTES<<EOF'
|
||||
echo "$NOTES"
|
||||
echo EOF
|
||||
} >> $GITHUB_ENV
|
||||
|
||||
- name: Build and publish with Tauri Action
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'v__VERSION__'
|
||||
releaseBody: ${{ env.NOTES }}
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
176
CHANGELOG.md
Normal file
176
CHANGELOG.md
Normal file
@ -0,0 +1,176 @@
|
||||
## v0.7.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **音乐云盘**:新增云盘页面,可浏览、播放云盘中的歌曲,查看文件详情(文件名、大小、比特率、上传时间),删除云盘歌曲,查看存储空间使用情况
|
||||
- **云盘上传**:支持上传本地音频文件到云盘,上传过程显示实时进度,支持 mp3/flac/wav/ogg/aac/m4a 格式
|
||||
- **下载音乐**:本地音乐拆分为「本地音乐」和「下载音乐」两个独立页面,下载音乐只显示通过应用下载的歌曲
|
||||
- **本地音乐多文件夹**:本地音乐支持添加多个扫描文件夹,通过三点按钮+弹窗管理文件夹路径
|
||||
- **歌手关注**:歌手详情页新增关注/取关按钮,关注状态在离开页面后不会丢失
|
||||
- **粘性导航栏**:页面滚动较深时顶部自动显示返回按钮和功能按钮,渐变模糊效果,不影响阅读
|
||||
- **骨架屏加载**:首页、歌单、歌手、专辑、云盘等多个页面加载时显示骨架占位动画,不再只有"加载中"文字
|
||||
|
||||
### 🐛 修复
|
||||
- 网络较差时播放启动超时,音乐实际已开始播放但界面仍显示暂停
|
||||
- 全屏漫游抽屉打开时点击评论按钮无法切换到评论页
|
||||
- 关闭漫游抽屉后再打开,始终显示评论而非歌词
|
||||
- 歌手详情页关注后离开再回来,关注状态丢失
|
||||
- 切歌时偶尔触发上一首歌的播放结束事件导致异常
|
||||
- 评论点赞无限叠加(改为服务端状态驱动)
|
||||
- 播放栏进度条上方多余分隔线
|
||||
|
||||
### 🎨 变更
|
||||
- 歌手详情页头像改为圆形,简介从独立标签页移至头部内嵌显示,溢出时可展开查看完整介绍
|
||||
- 歌单详情页描述溢出时显示"查看完整介绍"按钮
|
||||
- 首页推荐和排行榜加载失败时显示重试按钮,支持分别重试
|
||||
- 多个页面的返回按钮统一为粘性导航栏组件
|
||||
- 消息提示增加去重和数量限制,避免重复弹出
|
||||
|
||||
### ⚡ 优化
|
||||
- 页面切换更流畅,路由全部改为懒加载
|
||||
- 页面缓存管理优化,30 秒未访问自动释放,多级跳转时保留导航链上的页面,「我喜欢的音乐」常驻缓存
|
||||
- 本地音乐扫描不再阻塞界面导航
|
||||
- 应用启动不再等待网络请求完成
|
||||
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **亮色主题**:新增浅色外观模式,支持深色/浅色切换,7 种主题色各有对应亮色变体
|
||||
- **封面主色背景**:全屏漫游抽屉背景自动提取封面图主色调,沉浸感更强;抽屉打开时底部播放栏也跟随封面主色,视觉融为一体
|
||||
- **发现页重做**:支持多类型搜索(歌曲/歌手/专辑),输入时自动显示搜索建议,搜索历史和热门搜索
|
||||
- **漫游增强**:全屏抽屉支持歌词/评论切换,播放栏新增减少推荐按钮
|
||||
- **减少推荐**:FM 模式下可标记"不推荐这首歌"或"不推荐这个歌手",后续不会再收到类似推荐
|
||||
- **列表风格统一**:正在播放的歌曲序号位置显示跳动动画,鼠标悬停显示播放图标;红心/下载等图标统一使用图标库
|
||||
|
||||
### 🐛 修复
|
||||
- 专辑页艺术家过多时窗口缩小会竖排显示,现在支持自动换行
|
||||
- FM 播放时退出登录后首页仍可点击下一首
|
||||
- 本地音乐播放时缓冲进度条未重置
|
||||
- 亮色主题下多处文字看不见
|
||||
- 退出 FM 模式时状态未正确清理
|
||||
- 暗色模式下关闭抽屉时播放栏短暂闪烁亮色
|
||||
|
||||
### 🎨 变更
|
||||
- 移除播放列表按钮上的数字角标
|
||||
- 主页每日推荐和 FM 卡片标题固定为白色,不随主题变化
|
||||
|
||||
### 🧹 清理
|
||||
- 内部代码优化和冗余清理
|
||||
|
||||
|
||||
## v0.5.1
|
||||
|
||||
### 🐛 修复
|
||||
- 修复页面缓存不刷新的问题:切换回已缓存的页面时数据永远不更新,现在超过 5 分钟会自动重新加载
|
||||
- 修复本地音乐页面空列表时刷新按钮不显示的问题
|
||||
- 修复修改下载路径后本地音乐列表不更新的问题,现在会自动刷新
|
||||
- 修复私人 FM 播放约二三十首后循环重复的问题:新增听歌打卡上报,服务端推荐不再重复
|
||||
- 修复歌词界面切换翻译开关时歌词未居中的问题
|
||||
- 修复 Linux 下从外部控制暂停时进度条跳回 0 的问题:MPRIS 现在正确报告播放进度位置
|
||||
|
||||
### ⚡ 优化
|
||||
- 私人 FM 预取队列优化,队列剩余不足时自动后台拉取下一批
|
||||
|
||||
|
||||
## v0.5.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **蓝牙耳机/键盘媒体键控制**:支持通过蓝牙耳机按钮、键盘媒体键、系统通知栏/锁屏面板控制播放、暂停、切歌(Windows / Linux / macOS)
|
||||
- **网络状态检测**:断网和恢复时弹出提示,网络恢复后自动重新加载页面内容
|
||||
- **音量记忆**:关闭应用后音量设置不丢失,下次打开自动恢复
|
||||
- **歌词翻译**:支持显示歌词翻译,可在漫游页面切换开关
|
||||
- **登录页优化**:已登录用户访问登录页会自动跳转回首页
|
||||
|
||||
### 🎨 变更
|
||||
- 默认主题色改为天蓝色
|
||||
- 全局快捷键显示顺序调整为 Ctrl + Alt(之前是 Alt + Ctrl)
|
||||
- 快捷键显示优化:按键名更简洁,如 KeyP 显示为 P
|
||||
- 页面缓存优化:更多页面切换时保留状态,窗口隐藏时自动释放
|
||||
- 登录页等待确认时的文字颜色修正
|
||||
|
||||
### 🐛 修复
|
||||
- 手动检查更新时,之前跳过的版本现在会正常弹出更新提示
|
||||
- 点击正在播放的歌曲无法恢复播放的问题
|
||||
- 部分内部类型定义问题导致的潜在隐患
|
||||
|
||||
### ⚡ 底层优化
|
||||
- 音频播放引擎全面重构,播放更稳定
|
||||
- 后端 API 调用模式统一,代码更易维护
|
||||
- 歌曲数据模型统一,各页面显示更一致
|
||||
|
||||
|
||||
## v0.4.1
|
||||
|
||||
添加音频输出外设选择
|
||||
|
||||
|
||||
## v0.4.0
|
||||
|
||||
### ✨ 新功能
|
||||
- 添加歌曲的艺术家入口,歌曲的艺术家现可点击查看其他歌曲、专辑和介绍
|
||||
- 添加歌曲的评论查看功能
|
||||
|
||||
### 🐛 修复
|
||||
- 修复私人漫游自动播放下一首调用多次问题
|
||||
|
||||
### ⚡ 优化
|
||||
- 优化播放逻辑,歌曲列表在点击时候不再单首累加,而是直接获取当前列表所有的歌曲作为播放内容
|
||||
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### ✨ 新功能
|
||||
- **本地音乐页面**:支持浏览、播放本地歌曲,横向菜单添加「从磁盘删除」功能
|
||||
- **下载系统**:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长)
|
||||
- **封面补全**:本地音乐缺少封面时尝试从网易云 API 获取
|
||||
- **更新信息**:添加查看最新版更新日志按钮
|
||||
- **下载路径**:支持自定义下载路径
|
||||
- **本地音乐**:支持本地音乐播放
|
||||
- **下载提示**:下载进度与完成提示
|
||||
- **快捷键绑定**:支持自定义全局和本地快捷键(播放/暂停、上一首/下一首、音量调节)
|
||||
|
||||
### 🐛 修复
|
||||
- 修复私人漫游播完一首歌后跳三首的问题
|
||||
- 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片
|
||||
- 修复 PlayerBar 无封面歌曲显示破损图片
|
||||
- 修复播放网络歌曲时进度条先走但无声音
|
||||
|
||||
### ⚡ 优化
|
||||
- **流式播放**:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载
|
||||
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### 🎵 播放
|
||||
- 优化私人漫游(个人 FM)功能
|
||||
- 新增歌曲喜欢/取消喜欢红心
|
||||
- 新增播放历史本地记录(最近 200 首)
|
||||
|
||||
### 📋 歌单
|
||||
- 修改逻辑 我的歌单 不再显示收藏按钮
|
||||
- 收藏歌单支持取消收藏
|
||||
- 实现我的音乐功能
|
||||
- 实现历史播放记录功能
|
||||
|
||||
### 🎨 外观
|
||||
- 全局复选框与选择框优化
|
||||
- 部分UI优化,统一风格
|
||||
|
||||
### 🖥️ 窗口
|
||||
- 关闭窗口弹出确认弹窗:最小化到托盘 / 退出程序
|
||||
- 支持"不再询问"选项,可在设置中修改
|
||||
- 修复退出时 WebView2 报错(Error 1410)
|
||||
- 修复歌词抽屉全屏时候顶栏无法接收事件问题
|
||||
|
||||
### 💾 持久化
|
||||
- Cookie 存储迁移至 Tauri app_data_dir
|
||||
- 播放历史持久化到 localStorage
|
||||
|
||||
### ⚙️ 其他
|
||||
- 添加设置功能
|
||||
- 关于添加链接可直接访问仓库
|
||||
|
||||
|
||||
## v0.1.0
|
||||
|
||||
Nekosonic 是一款基于 Tauri 2 + Rust 的跨平台桌面音乐播放器,音源主要来自网易云音乐,开箱即用。
|
||||
103
README.md
103
README.md
@ -1,7 +1,102 @@
|
||||
# Tauri + Vue + TypeScript
|
||||
<div align="center">
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
# Nekosonic
|
||||
|
||||
## Recommended IDE Setup
|
||||
轻量跨平台桌面音乐播放器 · 网易云音乐
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
[](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
[](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
[](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
[下载安装](https://github.com/atdunbg/Nekosonic-Music/releases)
|
||||
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
## 🎵 播放
|
||||
|
||||
- 多音质选择(标准 / 较高 / HQ / SQ / Hi-Res)
|
||||
- 私人漫游 FM(个性化推荐)
|
||||
- 系统媒体控制集成(MPRIS / SMTC / Now Playing)
|
||||
- 音频输出设备选择
|
||||
|
||||
## 🔍 发现
|
||||
|
||||
- 关键词搜索(歌曲 / 歌手 / 专辑)+ 搜索建议 + 热门搜索
|
||||
- 歌单浏览(推荐 / 排行榜 / 用户 / 收藏)
|
||||
- 歌手详情(热门歌曲 / 专辑 / 简介 + 关注)
|
||||
- 专辑详情
|
||||
- 每日推荐歌曲
|
||||
|
||||
## 🎤 歌词与评论
|
||||
|
||||
- 实时滚动歌词(ease-out 缓动 / 点击跳转 / 渐变透明度)
|
||||
- 歌词翻译
|
||||
- 全屏漫游模式(封面主色提取 + 歌词/评论双标签)
|
||||
- 歌曲评论(无限滚动 + 点赞)
|
||||
|
||||
## ❤️ 收藏与下载
|
||||
|
||||
- 一键喜欢 / 取消喜欢(同步到网易云账号)
|
||||
- 歌曲下载
|
||||
- 音乐云盘(上传 / 删除 / 详情 / 存储空间 / 上传进度)
|
||||
- 本地音乐(多文件夹扫描 / 封面补全)
|
||||
- 下载音乐(独立管理 / 删除)
|
||||
|
||||
## 🎨 个性化
|
||||
|
||||
- 多主题色(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
|
||||
- 自定义快捷键(应用内 + 系统全局)
|
||||
- 关闭行为设置
|
||||
- 自动更新
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
前往 [Releases](https://github.com/atdunbg/Nekosonic-Music/releases) 下载对应平台安装包。
|
||||
|
||||
## 配置开发环境
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run tauri dev # 开发
|
||||
npm run tauri build # 构建
|
||||
```
|
||||
|
||||
> 环境要求:Node.js ≥ 18 · Rust ≥ 1.70 · Tauri CLI 2
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|:------|:------|
|
||||
| 桌面框架 | Tauri 2 |
|
||||
| 前端 | Vue 3 + TypeScript + Pinia |
|
||||
| 样式 | Tailwind CSS v4 + CSS 变量主题 |
|
||||
| 音频解码 | symphonia + ringbuf |
|
||||
| 媒体控制 | souvlaki |
|
||||
| 网易云 API | ncm-api-rs |
|
||||
| 构建 | Vite 6 |
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] 评论查看
|
||||
- [x] 歌曲下载
|
||||
- [x] 本地音乐
|
||||
- [x] 歌手详情页
|
||||
- [x] 专辑详情页
|
||||
- [x] 自定义全局快捷键
|
||||
- [x] 自动更新
|
||||
- [x] 歌词翻译
|
||||
- [x] 更多主题
|
||||
- [x] 音乐云盘
|
||||
- [ ] MV 播放
|
||||
- [ ] 桌面歌词
|
||||
|
||||
欢迎提 Issue 和 Pull request。
|
||||
|
||||
## 开源许可
|
||||
|
||||
本项目仅供个人学习研究使用,禁止用于商业及非法用途。基于 [MIT License](https://opensource.org/licenses/MIT) 开源。
|
||||
|
||||
773
package-lock.json
generated
773
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -11,22 +11,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"axios": "^1.16.0",
|
||||
"howler": "^2.2.4",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.110",
|
||||
"@iconify/utils": "^3.1.3",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~5.6.2",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
|
||||
1930
src-tauri/Cargo.lock
generated
1930
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.1.0"
|
||||
version = "0.7.0"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
@ -12,7 +12,7 @@ edition = "2021"
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "demo_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
@ -20,12 +20,39 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
rodio = "0.20"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "wav", "ogg", "vorbis", "isomp4", "mkv"] }
|
||||
ringbuf = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
cpal = { version = "0.15" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "stream"] }
|
||||
futures-util = "0.3"
|
||||
dirs = "5"
|
||||
lofty = "0.22"
|
||||
base64 = "0.22"
|
||||
md5 = "0.7"
|
||||
|
||||
ncm-api-rs = "0.1"
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
souvlaki = "0.8"
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
souvlaki = "0.8"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
opt-level = "s"
|
||||
|
||||
@ -10,7 +10,17 @@
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize"
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"dialog:allow-open",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
NMTID=00OvETy78e8ay9VhUTKgcUSdB6-yKQAAAGeAJacOg
|
||||
1275
src-tauri/src/api.rs
1275
src-tauri/src/api.rs
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,60 +1,84 @@
|
||||
use tauri::{
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
Manager, LogicalSize, Emitter,
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
Manager, Emitter,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
mod api;
|
||||
mod audio;
|
||||
mod media_controls;
|
||||
use api::ApiController;
|
||||
use audio::AppAudio;
|
||||
|
||||
static ALLOW_EXIT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
// 窗口最小尺寸
|
||||
window.set_min_size(Some(LogicalSize::new(1280.0, 700.0)))?;
|
||||
|
||||
// 注入控制器
|
||||
let api_controller = ApiController::new();
|
||||
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
||||
let api_controller = ApiController::new(app_data_dir);
|
||||
app.manage(api_controller);
|
||||
|
||||
let audio_controller = audio::AudioController::new(app.handle().clone());
|
||||
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
|
||||
app.manage(app_audio);
|
||||
|
||||
// 托盘菜单
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
use raw_window_handle::RawWindowHandle;
|
||||
let hwnd = if let Some(win) = app.get_webview_window("main") {
|
||||
win.window_handle().ok().and_then(|h| {
|
||||
if let RawWindowHandle::Win32(h) = h.as_raw() {
|
||||
Some(h.hwnd.get() as *mut std::ffi::c_void)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
media_controls::start_media_controls(app.handle().clone(), hwnd);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
media_controls::start_media_controls(app.handle().clone(), None);
|
||||
|
||||
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
|
||||
let _sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
let play_pause = MenuItemBuilder::with_id("play_pause", "播放/暂停").build(app)?;
|
||||
let next = MenuItemBuilder::with_id("next", "下一首").build(app)?;
|
||||
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
|
||||
let _sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "退出").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show)
|
||||
.separator()
|
||||
.item(&play_pause)
|
||||
.item(&next)
|
||||
.item(&prev)
|
||||
.items(&[&prev, &play_pause, &next])
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
// 托盘图标(使用应用默认图标)
|
||||
let icon = app.default_window_icon().cloned().unwrap();
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||
.tooltip("Nekosonic")
|
||||
.tooltip("Nekosonic Music")
|
||||
.icon(icon)
|
||||
.show_menu_on_left_click(false)
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}
|
||||
"play_pause" => {
|
||||
let _ = app.emit("tray-play-pause", ());
|
||||
@ -66,7 +90,10 @@ pub fn run() {
|
||||
let _ = app.emit("tray-prev", ());
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
ALLOW_EXIT.store(true, Ordering::SeqCst);
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.close();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@ -79,19 +106,27 @@ pub fn run() {
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// 点击关闭按钮时隐藏到托盘
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
let window_clone = window.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
|
||||
close_api.prevent_close(); // 阻止窗口关闭
|
||||
let _ = window_clone.hide(); // 隐藏到托盘
|
||||
if ALLOW_EXIT.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
close_api.prevent_close();
|
||||
let _ = window_clone.hide();
|
||||
let _ = app_handle.emit("window-hidden", ());
|
||||
}
|
||||
});
|
||||
|
||||
@ -102,6 +137,8 @@ pub fn run() {
|
||||
api::logout,
|
||||
|
||||
api::search_songs,
|
||||
api::cloudsearch,
|
||||
api::search_suggest,
|
||||
api::get_song_url,
|
||||
api::get_hot_search,
|
||||
api::get_playlist_detail,
|
||||
@ -110,21 +147,70 @@ pub fn run() {
|
||||
api::recommend_resource,
|
||||
api::recommend_songs,
|
||||
api::personal_fm,
|
||||
api::personal_fm_mode,
|
||||
api::fm_trash,
|
||||
api::scrobble,
|
||||
api::get_song_detail,
|
||||
api::get_qr_key,
|
||||
api::create_qr,
|
||||
api::check_qr_status,
|
||||
api::get_login_status,
|
||||
api::likelist,
|
||||
api::user_record,
|
||||
api::like_song,
|
||||
api::record_recent_song,
|
||||
api::playlist_subscribe,
|
||||
api::playlist_track_all,
|
||||
api::exit_app,
|
||||
|
||||
audio::play_audio,
|
||||
audio::play_local_audio,
|
||||
audio::pause_audio,
|
||||
audio::resume_audio,
|
||||
audio::stop_audio,
|
||||
audio::get_output_devices,
|
||||
audio::set_output_device,
|
||||
audio::seek_audio,
|
||||
audio::set_volume
|
||||
audio::get_audio_position,
|
||||
audio::set_volume,
|
||||
audio::is_audio_playing,
|
||||
|
||||
api::download_song,
|
||||
api::list_local_songs,
|
||||
api::scan_local_folders,
|
||||
api::delete_local_song,
|
||||
api::check_local_song,
|
||||
api::get_default_download_path,
|
||||
|
||||
api::artist_detail,
|
||||
api::artist_songs,
|
||||
api::artist_album,
|
||||
api::artist_desc,
|
||||
api::artist_sub,
|
||||
api::artist_sublist,
|
||||
api::album_detail,
|
||||
api::comment_new,
|
||||
api::comment_hot,
|
||||
api::comment_floor,
|
||||
api::comment_like,
|
||||
api::user_cloud,
|
||||
api::user_cloud_detail,
|
||||
api::user_cloud_del,
|
||||
api::cloud_upload,
|
||||
])
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
let _ = app.emit("window-shown", ());
|
||||
}
|
||||
}))
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Nekosonic");
|
||||
}
|
||||
}
|
||||
|
||||
127
src-tauri/src/media_controls.rs
Normal file
127
src-tauri/src/media_controls.rs
Normal file
@ -0,0 +1,127 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Listener};
|
||||
use souvlaki::{
|
||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback,
|
||||
MediaPosition, PlatformConfig, SeekDirection,
|
||||
};
|
||||
|
||||
struct MediaState {
|
||||
controls: MediaControls,
|
||||
}
|
||||
|
||||
pub fn start_media_controls(app_handle: AppHandle, hwnd: Option<*mut std::ffi::c_void>) {
|
||||
let config = PlatformConfig {
|
||||
dbus_name: "nekosonic",
|
||||
display_name: "Nekosonic",
|
||||
hwnd,
|
||||
};
|
||||
|
||||
let mut controls = match MediaControls::new(config) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create media controls: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ah = app_handle.clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
let cmd = match &event {
|
||||
MediaControlEvent::Play => "Play",
|
||||
MediaControlEvent::Pause => "Pause",
|
||||
MediaControlEvent::Toggle => "PlayPause",
|
||||
MediaControlEvent::Next => "Next",
|
||||
MediaControlEvent::Previous => "Previous",
|
||||
MediaControlEvent::Stop => "Stop",
|
||||
MediaControlEvent::Raise => "Raise",
|
||||
MediaControlEvent::Quit => "Quit",
|
||||
MediaControlEvent::SetVolume(v) => {
|
||||
let _ = ah.emit("mpris-command", format!("SetVolume:{v}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::Seek(dir) => {
|
||||
let offset_us = match dir {
|
||||
SeekDirection::Forward => 5_000_000i64,
|
||||
SeekDirection::Backward => -5_000_000i64,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SeekBy(dir, duration) => {
|
||||
let offset_us: i64 = match dir {
|
||||
SeekDirection::Forward => duration.as_micros() as i64,
|
||||
SeekDirection::Backward => -(duration.as_micros() as i64),
|
||||
};
|
||||
let _ = ah.emit("mpris-command", format!("Seek:{offset_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::SetPosition(pos) => {
|
||||
let pos_us = pos.0.as_micros() as i64;
|
||||
let _ = ah.emit("mpris-command", format!("SetPosition:{pos_us}"));
|
||||
return;
|
||||
}
|
||||
MediaControlEvent::OpenUri(_) => return,
|
||||
};
|
||||
let _ = ah.emit("mpris-command", cmd);
|
||||
}) {
|
||||
eprintln!("Failed to attach media control handler: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let state = Arc::new(Mutex::new(MediaState { controls }));
|
||||
let state_for_listener = state.clone();
|
||||
|
||||
app_handle.listen("playback-state", move |event| {
|
||||
if let Ok(data) = serde_json::from_str::<serde_json::Value>(event.payload()) {
|
||||
let mut s = match state_for_listener.lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(status) = data.get("status").and_then(|v| v.as_str()) {
|
||||
let position_us = data.get("positionUs").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let progress = if position_us > 0 {
|
||||
Some(MediaPosition(std::time::Duration::from_micros(position_us as u64)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let playback = match status {
|
||||
"playing" => MediaPlayback::Playing { progress },
|
||||
"paused" => MediaPlayback::Paused { progress },
|
||||
_ => MediaPlayback::Stopped,
|
||||
};
|
||||
let _ = s.controls.set_playback(playback);
|
||||
}
|
||||
|
||||
let title = data.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let album = data.get("album").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let artists = data
|
||||
.get("artists")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_owned()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let artist_str = artists.join(", ");
|
||||
let cover_url = data.get("coverUrl").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let duration_us = data.get("durationUs").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let metadata = MediaMetadata {
|
||||
title: if title.is_empty() { None } else { Some(title) },
|
||||
album: if album.is_empty() { None } else { Some(album) },
|
||||
artist: if artist_str.is_empty() { None } else { Some(&artist_str) },
|
||||
cover_url: if cover_url.is_empty() { None } else { Some(cover_url) },
|
||||
duration: if duration_us > 0 {
|
||||
Some(std::time::Duration::from_micros(duration_us as u64))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
let _ = s.controls.set_metadata(metadata);
|
||||
}
|
||||
});
|
||||
|
||||
std::mem::forget(state);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.0",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@ -13,10 +13,10 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Nekosonic",
|
||||
"width": 1200,
|
||||
"height": 700,
|
||||
"minWidth": 1200,
|
||||
"minHeight": 700,
|
||||
"width": 1100,
|
||||
"height": 680,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"decorations": false
|
||||
}
|
||||
@ -27,6 +27,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@ -34,6 +35,21 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://github.com/atdunbg/Nekosonic-Music/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": false,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1MDdCMTJCRTE3MUI4N0QKUldSOXVISGhLN0VITmM3ZkJlbjF3UGJrK3h6ellWZ2xSUG03b3d1RWlDeldSWk1nc0pic2J2MVkK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
464
src/App.vue
464
src/App.vue
@ -1,259 +1,196 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-gray-950 text-white overflow-hidden">
|
||||
<!-- ========= 自定义标题栏(可拖拽、无边框) ========= -->
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="h-10 flex items-center justify-between px-4 bg-gray-900/90 backdrop-blur select-none flex-shrink-0"
|
||||
>
|
||||
<span class="text-xs text-gray-400 font-medium ml-2">Nekosonic Music</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- 最小化 -->
|
||||
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
|
||||
<!-- 最大化 / 还原 -->
|
||||
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
|
||||
<!-- 关闭 -->
|
||||
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
|
||||
<TitleBar @close="closeWindow" />
|
||||
|
||||
<!-- 主体内容区 -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- 左侧导航(无边框) -->
|
||||
<nav class="w-56 flex-shrink-0 flex flex-col bg-gray-900/80 backdrop-blur">
|
||||
<div class="flex-1 p-4 overflow-y-auto pb-24 flex flex-col">
|
||||
<!-- 推荐 & 发现 -->
|
||||
<div class="space-y-0.5">
|
||||
<router-link to="/"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
|
||||
active-class="!text-white !bg-white/10">
|
||||
<span>🏠</span> 推荐
|
||||
</router-link>
|
||||
<router-link to="/discover"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
|
||||
active-class="!text-white !bg-white/10">
|
||||
<span>🔍</span> 发现
|
||||
</router-link>
|
||||
<button
|
||||
@click="openRoamFromSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
<span>🌀</span> 漫游
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
||||
<Sidebar />
|
||||
|
||||
<!-- 我的 -->
|
||||
<div class="mt-4 mb-1 pt-2">
|
||||
<p class="text-xs text-gray-500 px-3 mb-1">我的</p>
|
||||
<router-link to="/favorites"
|
||||
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
|
||||
<span>❤️</span> 我喜欢的音乐
|
||||
</router-link>
|
||||
<router-link to="/recent"
|
||||
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
|
||||
<span>🕐</span> 最近播放
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 创建的歌单(可折叠) -->
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
|
||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||
<p class="text-xs text-gray-500">我的歌单</p>
|
||||
<span class="text-xs text-gray-500 transition-transform"
|
||||
:class="{ 'rotate-90': showCreatedPlaylists }">▶</span>
|
||||
</div>
|
||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
|
||||
{{ pl.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收藏的歌单(可折叠) -->
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
|
||||
@click="showSubPlaylists = !showSubPlaylists">
|
||||
<p class="text-xs text-gray-500">收藏的歌单</p>
|
||||
<span class="text-xs text-gray-500 transition-transform" :class="{ 'rotate-90': showSubPlaylists }">▶</span>
|
||||
</div>
|
||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
|
||||
{{ pl.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户区域 -->
|
||||
<div class="mt-auto pt-4">
|
||||
<div v-if="!userStore.isLoggedIn" class="px-2 space-y-2">
|
||||
<p class="text-xs text-gray-500">登录后享受个人歌单</p>
|
||||
<router-link to="/login"
|
||||
class="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 transition text-sm font-medium text-green-400">
|
||||
<span>🔑</span> 立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3 px-2">
|
||||
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-green-400/50" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
||||
<button @click="userStore.logout()"
|
||||
class="text-xs text-gray-500 hover:text-red-400 transition">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="flex-1 overflow-y-auto pb-24">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="3" include="HomeView,DiscoverView">
|
||||
<keep-alive :max="10" :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 全屏漫游抽屉 -->
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="player.showRoamDrawer"
|
||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
|
||||
>
|
||||
<div class="h-16 flex items-center px-6 flex-shrink-0">
|
||||
<button @click="player.closeRoamDrawer()" class="text-white/80 hover:text-white transition">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8">
|
||||
<div class="flex-shrink-0 mr-12 flex flex-col items-center self-center">
|
||||
<img
|
||||
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
|
||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||
/>
|
||||
<h1 class="text-2xl font-bold text-white">{{ roamSong?.name }}</h1>
|
||||
<p class="text-gray-400 mt-2">{{ roamArtists }}</p>
|
||||
</div>
|
||||
<div ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center space-y-3 py-8">
|
||||
<p
|
||||
v-for="(line, idx) in lyrics"
|
||||
:key="idx"
|
||||
:class="idx === currentLyricIdx ? 'text-green-400 font-medium text-lg transition' : 'text-gray-400 text-base'"
|
||||
>
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-gray-500 text-center mt-8">暂无歌词</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<RoamDrawer :visible="windowVisible && player.showRoamDrawer" />
|
||||
|
||||
<!-- 底部播放栏 -->
|
||||
<PlayerBar />
|
||||
<PlayerBar v-if="player.currentSong" />
|
||||
<ToastContainer />
|
||||
|
||||
<UpdateDialog
|
||||
:visible="updater.updateAvailable.value && !!updater.updateInfo.value"
|
||||
:info="{ version: updater.updateInfo.value?.version || '', date: updater.updateInfo.value?.date ?? null, body: updater.updateInfo.value?.body ?? null, currentVersion: updater.currentVersion.value }"
|
||||
:downloading="updater.downloading.value"
|
||||
:download-progress="updater.downloadProgress.value"
|
||||
@update="updater.downloadAndInstall()"
|
||||
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
|
||||
/>
|
||||
|
||||
<CloseModal
|
||||
:visible="showCloseModal"
|
||||
@confirm="handleCloseAction"
|
||||
@cancel="showCloseModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useUserStore } from './stores/user';
|
||||
import PlayerBar from './components/PlayerBar.vue';
|
||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||
import { usePlayerStore } from './stores/player';
|
||||
import { useLyric } from './composables/UserLyric';
|
||||
import TitleBar from './components/TitleBar.vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import RoamDrawer from './components/RoamDrawer.vue';
|
||||
import PlayerBar from './components/PlayerBar.vue';
|
||||
import ToastContainer from './components/ToastContainer.vue';
|
||||
import CloseModal from './components/CloseModal.vue';
|
||||
import UpdateDialog from './components/UpdateDialog.vue';
|
||||
import { useOnlineStatus } from './composables/useOnlineStatus';
|
||||
import { showToast } from './composables/useToast';
|
||||
import { useUpdater } from './composables/useUpdater';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
|
||||
import { MusicApi, AudioApi, DeviceApi, AppApi } from './api';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
const updater = useUpdater();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
const showCreatedPlaylists = ref(true);
|
||||
const showSubPlaylists = ref(true);
|
||||
|
||||
// 歌词
|
||||
const { lyrics, currentLyricIdx } = useLyric();
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamSong = computed(() => player.currentSong);
|
||||
const roamArtists = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old) showToast('网络已恢复', 'success');
|
||||
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
|
||||
});
|
||||
|
||||
watch(currentLyricIdx, () => {
|
||||
if (player.showRoamDrawer && lyricScrollContainer.value) {
|
||||
nextTick(() => {
|
||||
const active = lyricScrollContainer.value?.querySelector('.text-green-400');
|
||||
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
});
|
||||
const showCloseModal = ref(false);
|
||||
const windowVisible = ref(true);
|
||||
|
||||
async function openRoamFromSidebar() {
|
||||
if (player.isFmMode) {
|
||||
player.openRoamDrawer();
|
||||
// --- Keep-alive 缓存管理 ---
|
||||
// 规则:30秒未访问的页面自动清除缓存;多级跳转时保留导航链上的页面;FavoriteSongs 常驻
|
||||
const route = useRoute();
|
||||
|
||||
const ROUTE_COMPONENT: Record<string, string> = {
|
||||
home: 'HomeView', discover: 'DiscoverView', search: 'DiscoverView',
|
||||
favorites: 'FavoriteSongsView', daily: 'DailySongsView',
|
||||
'local-music': 'LocalMusicView', 'downloaded-music': 'DownloadedMusicView',
|
||||
'cloud-music': 'CloudMusicView',
|
||||
playlist: 'PlaylistDetailView', artist: 'ArtistDetailView', album: 'AlbumDetailView',
|
||||
};
|
||||
const ALL_CACHEABLE = [...new Set(Object.values(ROUTE_COMPONENT))];
|
||||
const PERMANENT = new Set(['FavoriteSongsView']);
|
||||
const CACHE_TTL = 30_000;
|
||||
|
||||
const lastActivatedAt: Record<string, number> = {};
|
||||
const navStack = ref<string[]>([]);
|
||||
const currentComp = ref('');
|
||||
for (const name of ALL_CACHEABLE) lastActivatedAt[name] = Date.now();
|
||||
|
||||
watch(() => route.name, (newName, oldName) => {
|
||||
// 离开旧页面时刷新其计时(30s 从离开时算起)
|
||||
const oldComp = ROUTE_COMPONENT[oldName as string];
|
||||
if (oldComp) lastActivatedAt[oldComp] = Date.now();
|
||||
|
||||
const comp = ROUTE_COMPONENT[newName as string];
|
||||
if (!comp) return;
|
||||
currentComp.value = comp;
|
||||
lastActivatedAt[comp] = Date.now();
|
||||
const idx = navStack.value.indexOf(comp);
|
||||
if (idx !== -1) {
|
||||
// 返回:截断到该位置
|
||||
navStack.value = navStack.value.slice(0, idx + 1);
|
||||
} else {
|
||||
await player.loadFm();
|
||||
navStack.value.push(comp);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function computeInclude(): string[] {
|
||||
const now = Date.now();
|
||||
const include = new Set<string>(PERMANENT);
|
||||
if (currentComp.value) include.add(currentComp.value);
|
||||
for (const name of navStack.value) include.add(name);
|
||||
for (const name of ALL_CACHEABLE) {
|
||||
if (lastActivatedAt[name] && now - lastActivatedAt[name] < CACHE_TTL) include.add(name);
|
||||
}
|
||||
return [...include];
|
||||
}
|
||||
|
||||
async function loadPlaylists() {
|
||||
if (!userStore.isLoggedIn || !userStore.user) return;
|
||||
try {
|
||||
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
|
||||
const data = JSON.parse(jsonStr);
|
||||
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed);
|
||||
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
const keepAliveInclude = ref<string[]>(computeInclude());
|
||||
let cleanupTimer: ReturnType<typeof setInterval>;
|
||||
function startCleanup() { cleanupTimer = setInterval(() => { keepAliveInclude.value = computeInclude(); }, 10_000); }
|
||||
function stopCleanup() { clearInterval(cleanupTimer); }
|
||||
|
||||
function goPlaylist(id: number) {
|
||||
router.push({ name: 'playlist', params: { id } });
|
||||
}
|
||||
watch(() => settings.dataTheme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => userStore.isLoggedIn, (val) => {
|
||||
if (val) loadPlaylists();
|
||||
if (val) {
|
||||
player.loadLikedIds();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.isLoggedIn) loadPlaylists();
|
||||
try { await invoke('stop_audio'); } catch {}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_login_status');
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.account || data.profile) {
|
||||
const profile = data.profile || data.account;
|
||||
userStore.setUser({
|
||||
userId: profile.userId,
|
||||
nickname: profile.nickname,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
onMounted(() => {
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
startCleanup();
|
||||
|
||||
AudioApi.stopAudio().catch(() => {});
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
player.loadLikedIds();
|
||||
MusicApi.getLoginStatus().then(jsonStr => {
|
||||
if (!jsonStr) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.account || data.profile) {
|
||||
const profile = data.profile || data.account;
|
||||
userStore.setUser({
|
||||
userId: profile.userId,
|
||||
nickname: profile.nickname,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
updater.checkForUpdate(true);
|
||||
|
||||
if (settings.outputDevice) {
|
||||
DeviceApi.setOutputDevice(settings.outputDevice).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 窗口控制 ----------
|
||||
const currentWindow = getCurrentWindow();
|
||||
function minimizeWindow() { currentWindow.minimize(); }
|
||||
async function toggleMaximize() {
|
||||
const isMaximized = await currentWindow.isMaximized();
|
||||
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
|
||||
|
||||
function closeWindow() {
|
||||
if (settings.closeAction === 'ask') {
|
||||
showCloseModal.value = true;
|
||||
} else if (settings.closeAction === 'minimize') {
|
||||
currentWindow.hide();
|
||||
} else {
|
||||
AppApi.exitApp();
|
||||
}
|
||||
}
|
||||
function closeWindow() { currentWindow.close(); }
|
||||
|
||||
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
function handleCloseAction(action: CloseAction, remember: boolean) {
|
||||
if (remember) {
|
||||
settings.setCloseAction(action);
|
||||
}
|
||||
showCloseModal.value = false;
|
||||
if (action === 'minimize') {
|
||||
currentWindow.hide();
|
||||
} else {
|
||||
AppApi.exitApp();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const unlisten1 = listen('tray-play-pause', () => {
|
||||
player.toggle(); // 假设 player 是 usePlayerStore 的实例
|
||||
player.toggle();
|
||||
});
|
||||
const unlisten2 = listen('tray-next', () => {
|
||||
player.next();
|
||||
@ -261,25 +198,98 @@ onMounted(() => {
|
||||
const unlisten3 = listen('tray-prev', () => {
|
||||
player.prev();
|
||||
});
|
||||
const unlisten4 = listen('window-hidden', () => {
|
||||
windowVisible.value = false;
|
||||
keepAliveInclude.value = [];
|
||||
stopCleanup();
|
||||
});
|
||||
const unlisten5 = listen('window-shown', () => {
|
||||
windowVisible.value = true;
|
||||
keepAliveInclude.value = computeInclude();
|
||||
startCleanup();
|
||||
});
|
||||
|
||||
// 在组件卸载时取消监听
|
||||
onBeforeUnmount(() => {
|
||||
onBeforeUnmount(() => {
|
||||
unlisten1.then(fn => fn());
|
||||
unlisten2.then(fn => fn());
|
||||
unlisten3.then(fn => fn());
|
||||
unlisten4.then(fn => fn());
|
||||
unlisten5.then(fn => fn());
|
||||
stopCleanup();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active { transition: transform 0.3s ease; }
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to { transform: translateY(100%); }
|
||||
.custom-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
async function registerGlobalShortcuts() {
|
||||
const globalActions: Record<string, () => void> = {
|
||||
globalPlayPause: () => player.toggle(),
|
||||
globalPrev: () => player.prev(),
|
||||
globalNext: () => player.next(),
|
||||
globalVolUp: () => player.adjustVolume(5),
|
||||
globalVolDown: () => player.adjustVolume(-5),
|
||||
};
|
||||
for (const [id, action] of Object.entries(globalActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
try { await unregister(key); } catch { /* 忽略 */ }
|
||||
try {
|
||||
await register(key, (event) => {
|
||||
if (event.state === 'Pressed') action();
|
||||
});
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
watch(() => settings.shortcuts, () => {
|
||||
registerGlobalShortcuts();
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
registerGlobalShortcuts();
|
||||
});
|
||||
|
||||
function parseShortcutKey(combo: string): { ctrl: boolean; alt: boolean; shift: boolean; code: string } {
|
||||
const parts = combo.split('+');
|
||||
return {
|
||||
ctrl: parts.includes('Control'),
|
||||
alt: parts.includes('Alt'),
|
||||
shift: parts.includes('Shift'),
|
||||
code: parts.find(p => !['Control', 'Alt', 'Shift'].includes(p)) || '',
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const el = e.target as HTMLElement;
|
||||
const isEditable = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
|
||||
if (e.code === 'Space' && !isEditable) {
|
||||
e.preventDefault();
|
||||
player.toggle();
|
||||
}
|
||||
|
||||
const localActions: Record<string, () => void> = {
|
||||
playPause: () => player.toggle(),
|
||||
prev: () => player.prev(),
|
||||
next: () => player.next(),
|
||||
volUp: () => player.adjustVolume(5),
|
||||
volDown: () => player.adjustVolume(-5),
|
||||
};
|
||||
for (const [id, action] of Object.entries(localActions)) {
|
||||
const key = settings.shortcuts[id]?.key;
|
||||
if (!key) continue;
|
||||
const parsed = parseShortcutKey(key);
|
||||
const ctrlMatch = parsed.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||||
const altMatch = parsed.alt ? e.altKey : !e.altKey;
|
||||
const shiftMatch = parsed.shift ? e.shiftKey : !e.shiftKey;
|
||||
if (ctrlMatch && altMatch && shiftMatch && e.code === parsed.code) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
225
src/api.ts
Normal file
225
src/api.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export namespace MusicApi {
|
||||
export async function getLoginStatus(): Promise<string> {
|
||||
return invoke('get_login_status');
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
return invoke('logout');
|
||||
}
|
||||
|
||||
export async function getQrKey(): Promise<string> {
|
||||
return invoke('get_qr_key');
|
||||
}
|
||||
|
||||
export async function checkQrStatus(key: string): Promise<string> {
|
||||
return invoke('check_qr_status', { query: { key } });
|
||||
}
|
||||
|
||||
export async function likelist(uid: number): Promise<string> {
|
||||
return invoke('likelist', { uid });
|
||||
}
|
||||
|
||||
export async function likeSong(id: number, like: boolean): Promise<void> {
|
||||
return invoke('like_song', { query: { id, like: like ? 'true' : 'false' } });
|
||||
}
|
||||
|
||||
export async function userPlaylist(uid: number): Promise<string> {
|
||||
return invoke('user_playlist', { uid });
|
||||
}
|
||||
|
||||
export async function getPlaylistDetail(id: number): Promise<string> {
|
||||
return invoke('get_playlist_detail', { id });
|
||||
}
|
||||
|
||||
export async function playlistTrackAll(id: number): Promise<string> {
|
||||
return invoke('playlist_track_all', { query: { id } });
|
||||
}
|
||||
|
||||
export async function playlistSubscribe(id: number, subscribe: boolean): Promise<void> {
|
||||
return invoke('playlist_subscribe', { query: { id, subscribe } });
|
||||
}
|
||||
|
||||
export async function recommendResource(): Promise<string> {
|
||||
return invoke('recommend_resource');
|
||||
}
|
||||
|
||||
export async function recommendSongs(): Promise<string> {
|
||||
return invoke('recommend_songs');
|
||||
}
|
||||
|
||||
export async function getSongDetail(id: string): Promise<string> {
|
||||
return invoke('get_song_detail', { id });
|
||||
}
|
||||
|
||||
export async function getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise<string> {
|
||||
return invoke('get_song_url', { query });
|
||||
}
|
||||
|
||||
export async function getLyric(id: number): Promise<string> {
|
||||
return invoke('get_lyric', { id });
|
||||
}
|
||||
|
||||
export async function searchSuggest(keyword: string): Promise<string> {
|
||||
return invoke('search_suggest', { query: { keyword } });
|
||||
}
|
||||
|
||||
export async function getHotSearch(): Promise<string> {
|
||||
return invoke('get_hot_search');
|
||||
}
|
||||
|
||||
export async function cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise<string> {
|
||||
return invoke('cloudsearch', { query });
|
||||
}
|
||||
|
||||
export async function albumDetail(id: number): Promise<string> {
|
||||
return invoke('album_detail', { id });
|
||||
}
|
||||
|
||||
export async function artistDetail(id: number): Promise<string> {
|
||||
return invoke('artist_detail', { id });
|
||||
}
|
||||
|
||||
export async function artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise<string> {
|
||||
return invoke('artist_songs', { query });
|
||||
}
|
||||
|
||||
export async function artistAlbum(id: number, limit: number, offset: number): Promise<string> {
|
||||
return invoke('artist_album', { id, limit, offset });
|
||||
}
|
||||
|
||||
export async function artistDesc(id: number): Promise<string> {
|
||||
return invoke('artist_desc', { id });
|
||||
}
|
||||
|
||||
export async function artistSub(id: number, sub: boolean): Promise<string> {
|
||||
return invoke('artist_sub', { query: { id, sub } });
|
||||
}
|
||||
|
||||
export async function artistSublist(limit = 100, offset = 0): Promise<string> {
|
||||
return invoke('artist_sublist', { query: { limit, offset } });
|
||||
}
|
||||
|
||||
export async function commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise<string> {
|
||||
return invoke('comment_hot', { query });
|
||||
}
|
||||
|
||||
export async function commentLike(query: { t: number; type: number; id: number; cid: number }): Promise<void> {
|
||||
return invoke('comment_like', { query });
|
||||
}
|
||||
|
||||
export async function personalFm(): Promise<string> {
|
||||
return invoke('personal_fm');
|
||||
}
|
||||
|
||||
export async function personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise<string> {
|
||||
return invoke('personal_fm_mode', { query });
|
||||
}
|
||||
|
||||
export async function fmTrash(id: number, time: number): Promise<void> {
|
||||
return invoke('fm_trash', { query: { id, time } });
|
||||
}
|
||||
|
||||
export async function scrobble(query: { id: number; sourceid: string; time: number }): Promise<void> {
|
||||
return invoke('scrobble', { query });
|
||||
}
|
||||
|
||||
// 云盘
|
||||
export async function userCloud(limit = 30, offset = 0): Promise<string> {
|
||||
return invoke('user_cloud', { limit, offset });
|
||||
}
|
||||
|
||||
export async function userCloudDel(id: number): Promise<string> {
|
||||
return invoke('user_cloud_del', { id });
|
||||
}
|
||||
|
||||
export async function cloudUpload(filePath: string): Promise<string> {
|
||||
return invoke('cloud_upload', { filePath });
|
||||
}
|
||||
}
|
||||
|
||||
export namespace AudioApi {
|
||||
export async function playAudio(url: string): Promise<void> {
|
||||
return invoke('play_audio', { url });
|
||||
}
|
||||
|
||||
export async function playLocalAudio(path: string): Promise<void> {
|
||||
return invoke('play_local_audio', { path });
|
||||
}
|
||||
|
||||
export async function pauseAudio(): Promise<void> {
|
||||
return invoke('pause_audio');
|
||||
}
|
||||
|
||||
export async function resumeAudio(): Promise<void> {
|
||||
return invoke('resume_audio');
|
||||
}
|
||||
|
||||
export async function stopAudio(): Promise<void> {
|
||||
return invoke('stop_audio');
|
||||
}
|
||||
|
||||
export async function seekAudio(time: number): Promise<void> {
|
||||
return invoke('seek_audio', { time });
|
||||
}
|
||||
|
||||
export async function setVolume(vol: number): Promise<void> {
|
||||
return invoke('set_volume', { vol });
|
||||
}
|
||||
|
||||
export async function getAudioPosition(): Promise<number> {
|
||||
return invoke('get_audio_position');
|
||||
}
|
||||
|
||||
export async function isAudioPlaying(): Promise<boolean> {
|
||||
return invoke('is_audio_playing');
|
||||
}
|
||||
}
|
||||
|
||||
export namespace DeviceApi {
|
||||
export async function getOutputDevices(): Promise<string[]> {
|
||||
return invoke('get_output_devices');
|
||||
}
|
||||
|
||||
export async function setOutputDevice(device: string | null): Promise<void> {
|
||||
return invoke('set_output_device', { device });
|
||||
}
|
||||
}
|
||||
|
||||
export namespace DownloadApi {
|
||||
export async function downloadSong(query: {
|
||||
id: number;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string | null;
|
||||
duration: number | null;
|
||||
coverUrl: string | null;
|
||||
level: string;
|
||||
downloadPath: string | null;
|
||||
}): Promise<void> {
|
||||
return invoke('download_song', { query });
|
||||
}
|
||||
|
||||
export async function listLocalSongs(downloadPath: string | null): Promise<any[]> {
|
||||
return invoke('list_local_songs', { downloadPath });
|
||||
}
|
||||
|
||||
export async function scanLocalFolders(paths: string[]): Promise<any[]> {
|
||||
return invoke('scan_local_folders', { paths });
|
||||
}
|
||||
|
||||
export async function deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise<void> {
|
||||
return invoke('delete_local_song', { query });
|
||||
}
|
||||
|
||||
export async function getDefaultDownloadPath(): Promise<string> {
|
||||
return invoke('get_default_download_path');
|
||||
}
|
||||
}
|
||||
|
||||
export namespace AppApi {
|
||||
export function exitApp(): Promise<void> {
|
||||
return invoke('exit_app');
|
||||
}
|
||||
}
|
||||
BIN
src/assets/app-icon.png
Normal file
BIN
src/assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
69
src/components/CloseModal.vue
Normal file
69
src/components/CloseModal.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="$emit('cancel')">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
|
||||
<p class="text-sm text-content-2 mb-5">你希望如何处理?</p>
|
||||
<div class="space-y-2.5 mb-4">
|
||||
<button @click="handleAction('minimize')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
|
||||
<IconMaximize2 class="w-[18px] h-[18px] text-accent-text" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">最小化到托盘</p>
|
||||
<p class="text-xs text-content-3">程序继续在后台运行</p>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="handleAction('exit')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
|
||||
<IconX class="w-[18px] h-[18px] text-danger" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-content">退出程序</p>
|
||||
<p class="text-xs text-content-3">完全关闭应用程序</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
|
||||
<input type="checkbox" v-model="dontAskAgain" />
|
||||
<span class="text-xs text-content-2">不再询问,记住我的选择</span>
|
||||
</label>
|
||||
<button @click="$emit('cancel')"
|
||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { CloseAction } from '../stores/settings';
|
||||
import IconMaximize2 from '~icons/lucide/maximize-2';
|
||||
import IconX from '~icons/lucide/x';
|
||||
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [action: CloseAction, remember: boolean];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const dontAskAgain = ref(false);
|
||||
|
||||
function handleAction(action: CloseAction) {
|
||||
emit('confirm', action, dontAskAgain.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
157
src/components/CommentSection.vue
Normal file
157
src/components/CommentSection.vue
Normal file
@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="bg-subtle rounded-xl p-3" ref="scrollContainer">
|
||||
<div v-if="loading" class="py-8 text-center text-sm" :class="darkMode ? 'text-white/60' : 'text-content-2'">加载中...</div>
|
||||
|
||||
<div v-else-if="comments.length === 0" class="py-8 text-center">
|
||||
<IconMessageSquare class="mx-auto mb-2 w-10 h-10" :class="darkMode ? 'text-white/40' : 'text-content-3'" />
|
||||
<p class="text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">暂无评论</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.commentId"
|
||||
class="p-3 rounded-xl"
|
||||
:class="darkMode ? 'bg-white/8' : 'bg-surface/50'"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="comment.user.avatarUrl" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" :class="darkMode ? 'text-white/90' : 'text-content'">{{ comment.user.nickname }}</p>
|
||||
<p class="text-xs" :class="darkMode ? 'text-white/40' : 'text-content-3'">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-relaxed" :class="darkMode ? 'text-white/70' : 'text-content-2'">{{ comment.content }}</p>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
@click="likeComment(comment.commentId)"
|
||||
class="flex items-center gap-1 transition text-xs"
|
||||
:class="comment.liked ? 'text-danger' : (darkMode ? 'text-white/40 hover:text-danger' : 'text-content-3 hover:text-danger')"
|
||||
>
|
||||
<IconHeart style="font-size: 14px" :class="comment.liked ? '[&>path]:fill-current [&>path]:stroke-0' : ''" />
|
||||
<span>{{ comment.likedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="sentinel" class="h-1"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMore" class="py-4 text-center text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">加载中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { MusicApi } from '../api'
|
||||
import IconMessageSquare from '~icons/lucide/message-square'
|
||||
import IconHeart from '~icons/lucide/heart'
|
||||
|
||||
const props = defineProps<{
|
||||
type: number
|
||||
id: number
|
||||
darkMode?: boolean
|
||||
}>()
|
||||
|
||||
const comments = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const pageNo = ref(1)
|
||||
const pageSize = 20
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
async function fetchComments(reset = false) {
|
||||
if (reset) {
|
||||
pageNo.value = 1
|
||||
comments.value = []
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
if (!hasMore.value) return
|
||||
|
||||
if (reset) {
|
||||
loading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.commentHot({
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
limit: pageSize,
|
||||
offset: (pageNo.value - 1) * pageSize
|
||||
})
|
||||
const data = JSON.parse(jsonStr)
|
||||
const list = data.hotComments || []
|
||||
if (reset) {
|
||||
comments.value = list
|
||||
} else {
|
||||
comments.value.push(...list)
|
||||
}
|
||||
hasMore.value = list.length >= pageSize
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
pageNo.value++
|
||||
fetchComments()
|
||||
}
|
||||
|
||||
const likingSet = ref(new Set<number>())
|
||||
|
||||
async function likeComment(cid: number) {
|
||||
if (likingSet.value.has(cid)) return
|
||||
const target = comments.value.find(c => c.commentId === cid)
|
||||
if (!target) return
|
||||
const liked = !!target.liked
|
||||
likingSet.value.add(cid)
|
||||
try {
|
||||
await MusicApi.commentLike({
|
||||
t: liked ? 0 : 1,
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
cid
|
||||
})
|
||||
target.liked = !liked
|
||||
target.likedCount += liked ? -1 : 1
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
likingSet.value.delete(cid)
|
||||
}
|
||||
}
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect()
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore()
|
||||
}
|
||||
}, { rootMargin: '200px' })
|
||||
nextTick(() => {
|
||||
if (sentinel.value) {
|
||||
observer!.observe(sentinel.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchComments(true).then(() => setupObserver())
|
||||
})
|
||||
|
||||
watch(() => props.id, () => {
|
||||
fetchComments(true).then(() => setupObserver())
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
81
src/components/CustomSelect.vue
Normal file
81
src/components/CustomSelect.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="relative" ref="container">
|
||||
<button
|
||||
@click="toggle"
|
||||
class="flex items-center justify-between bg-subtle border border-line rounded-lg px-3 py-1.5 text-sm text-content outline-none transition min-w-[140px] max-w-[320px] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]"
|
||||
:class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
|
||||
>
|
||||
<span class="truncate">{{ currentLabel }}</span>
|
||||
<IconChevronDown style="font-size: 12px" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }" />
|
||||
</button>
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full max-w-[360px] overflow-hidden">
|
||||
<button
|
||||
v-for="(label, key) in options"
|
||||
:key="key"
|
||||
@click="select(key)"
|
||||
class="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between gap-2"
|
||||
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
|
||||
>
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<IconCheck v-if="modelValue === key" style="font-size: 14px" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
import IconCheck from '~icons/lucide/check';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
options: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
|
||||
const currentLabel = computed(() => props.options[props.modelValue] || '');
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
|
||||
function select(key: string) {
|
||||
emit('update:modelValue', key);
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (container.value && !container.value.contains(e.target as Node)) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
56
src/components/PageHeader.vue
Normal file
56
src/components/PageHeader.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<!-- 普通头部:随内容滚动,返回独占一行,标题和按钮在第二行 -->
|
||||
<div ref="headerRef" class="-mx-8 px-8 pt-3 pb-2">
|
||||
<button @click="$router.back()" class="mb-1 text-content-2 hover:text-content transition text-sm">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<slot />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 粘性操作栏:滚动后显示,只有返回+小功能按钮 -->
|
||||
<div class="sticky top-0 z-10 -mx-8 px-8 py-1.5">
|
||||
<div
|
||||
class="absolute inset-0 backdrop-blur-md transition-opacity duration-300"
|
||||
:class="isStuck ? 'opacity-100' : 'opacity-0'"
|
||||
style="mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);"
|
||||
>
|
||||
<div class="w-full h-full bg-base/80"></div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex items-center gap-2 transition-opacity duration-300"
|
||||
:class="isStuck ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<button @click="$router.back()" class="text-content-2 hover:text-content transition text-sm">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="flex-1" />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const headerRef = ref<HTMLElement | null>(null)
|
||||
const isStuck = ref(false)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!headerRef.value) return
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isStuck.value = !entry.isIntersecting
|
||||
},
|
||||
{ threshold: 0 }
|
||||
)
|
||||
observer.observe(headerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
})
|
||||
</script>
|
||||
@ -1,167 +1,262 @@
|
||||
<template>
|
||||
<div v-if="player.currentSong"
|
||||
class="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-white/10 z-50 select-none">
|
||||
<!-- 歌词精简条(仅在非漫游全屏时显示) -->
|
||||
<div v-if="currentLyricText && !player.showRoamDrawer" @click="showFullLyric = !showFullLyric"
|
||||
class="px-6 py-1 text-center text-xs text-green-200/80 cursor-pointer hover:bg-white/5 transition truncate">
|
||||
{{ currentLyricText }}
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur z-50 select-none"
|
||||
>
|
||||
<div v-if="player.dominantColor"
|
||||
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
|
||||
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
|
||||
:style="{ backgroundColor: player.dominantColor }"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div ref="progressBar" class="w-full h-1.5 bg-white/10 rounded-full relative group cursor-pointer overflow-visible"
|
||||
<div ref="progressBar" class="w-full h-1.5 rounded-full relative group cursor-pointer overflow-visible"
|
||||
:class="drawerActive ? 'bg-white/10' : 'bg-muted'"
|
||||
@mousedown.prevent="startSeek">
|
||||
<!-- 缓存进度(灰白) -->
|
||||
<div class="absolute left-0 top-0 h-full bg-white/20 rounded-full" :style="{ width: cacheProgress + '%' }"></div>
|
||||
<!-- 播放进度(绿色渐变) -->
|
||||
<div class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-emerald-500 rounded-full"
|
||||
<div class="absolute left-0 top-0 h-full rounded-full" :class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" :style="{ width: cacheProgress + '%' }"></div>
|
||||
<div class="absolute left-0 top-0 h-full bg-accent rounded-full"
|
||||
:style="{ width: displayProgress + '%' }"></div>
|
||||
<!-- 拖动圆点(基于容器定位,left 百分比) -->
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg border border-line opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
|
||||
</div>
|
||||
|
||||
<!-- 主控制区 -->
|
||||
<div class="flex items-center px-6 h-16">
|
||||
<!-- 左侧:歌曲信息 -->
|
||||
<div class="flex items-center px-6 h-16 relative z-10">
|
||||
<div class="flex items-center gap-3 w-56 min-w-0">
|
||||
<img :src="player.currentSong.al?.picUrl"
|
||||
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
|
||||
@click="player.openRoamDrawer()" title="全屏展示" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ player.currentSong.name }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{{player.currentSong.ar?.map((a: any) => a.name).join('/')}}
|
||||
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
|
||||
<img :src="getCoverUrl(player.currentSong)" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示"
|
||||
:class="drawerActive ? 'bg-white/10' : 'bg-muted'">
|
||||
<IconMusic class="w-[18px] h-[18px]" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate" :class="drawerActive ? 'text-white' : ''">{{ player.currentSong?.name }}</p>
|
||||
<p class="text-xs truncate" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
|
||||
<template v-for="(a, i) in player.currentSong?.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" :class="drawerActive ? 'text-white/40' : 'text-content-3'">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="player.currentSong?.al?.name">
|
||||
<span :class="drawerActive ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="player.currentSong!.al.id && router.push({ name: 'album', params: { id: player.currentSong!.al.id } })">{{ player.currentSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : (drawerActive ? 'text-white/50 hover:text-danger' : 'text-content-3 hover:text-danger')">
|
||||
<IconHeart v-if="player.currentSong && player.isLiked(player.currentSong.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
|
||||
<IconHeart v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<button v-if="player.currentSong" @click="player.openRoamDrawer('comment')" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="评论">
|
||||
<IconMessageSquare class="w-4 h-4" />
|
||||
</button>
|
||||
<button v-if="player.currentSong && !download.isDownloaded(player.currentSong!.id) && !download.isDownloading(player.currentSong!.id)" @click="download.downloadSong(player.currentSong)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="下载">
|
||||
<IconDownload class="w-4 h-4" />
|
||||
</button>
|
||||
<IconLoader2 v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" class="w-4 h-4 flex-shrink-0 animate-spin" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
|
||||
</div>
|
||||
|
||||
<!-- 中间:控制按钮 + 时间 -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-1">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-5 relative">
|
||||
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
|
||||
'text-xl transition',
|
||||
player.isFmMode ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 hover:text-white',
|
||||
'transition',
|
||||
player.isFmMode ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
|
||||
]">
|
||||
⏮
|
||||
<IconSkipBack class="w-5 h-5" />
|
||||
</button>
|
||||
<button @click="player.toggle()"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20">
|
||||
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
|
||||
class="text-white">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full transition"
|
||||
:class="drawerActive ? 'bg-white/15 hover:bg-white/25 border border-white/20' : 'bg-muted hover:bg-emphasis border border-emphasis'">
|
||||
<IconPause v-if="player.playing" class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
<IconPlay v-else class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
</button>
|
||||
<button @click="player.next()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
|
||||
<IconSkipForward class="w-5 h-5" />
|
||||
</button>
|
||||
<button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" class="absolute left-full ml-5" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐">
|
||||
<IconHeartOff class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<button @click="player.next()" class="text-xl text-gray-400 hover:text-white transition">⏭</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<div class="flex items-center gap-2 text-xs" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
|
||||
<span>{{ formatTime(player.currentTime) }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ formatTime(player.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:音量、模式、播放列表 -->
|
||||
<div class="w-56 flex justify-end items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-sm text-gray-400">🔊</span>
|
||||
<div class="relative w-24 h-6 flex items-center">
|
||||
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
|
||||
<button @click="toggleMute" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
|
||||
<IconVolumeX v-if="player.volume === 0" class="w-[18px] h-[18px]" />
|
||||
<IconVolume2 v-else class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<div class="relative w-20 h-6 flex items-center">
|
||||
<input ref="volumeSlider" type="range" min="0" max="100" :value="player.volume"
|
||||
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
|
||||
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-white/20 outline-none" />
|
||||
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer outline-none"
|
||||
:class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="togglePlayMode" class="text-gray-400 hover:text-white transition text-lg" :title="modeTitle">
|
||||
{{ modeIcon }}
|
||||
<button @click="togglePlayMode" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" :title="modeTitle">
|
||||
<IconRepeat v-if="player.playMode === 'loop'" class="w-[18px] h-[18px]" />
|
||||
<IconShuffle v-else-if="player.playMode === 'shuffle'" class="w-[18px] h-[18px]" />
|
||||
<IconRepeat1 v-else class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<button @click="showQueuePanel = !showQueuePanel"
|
||||
class="text-gray-400 hover:text-white transition text-xl relative" title="播放列表">
|
||||
📋
|
||||
<span v-if="player.queue.length > 0"
|
||||
class="absolute -top-1 -right-1 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{{ player.queue.length }}
|
||||
</span>
|
||||
:class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" title="播放列表">
|
||||
<IconListMusic class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 队列面板 -->
|
||||
<Transition name="slide-up">
|
||||
<div v-if="showQueuePanel"
|
||||
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
|
||||
<button @click="player.clearQueue()" class="text-xs text-red-400 hover:text-red-300 transition">清空</button>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
|
||||
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
|
||||
idx === player.currentIndex ? 'bg-green-500/20 text-white' : 'hover:bg-white/5 text-gray-300',
|
||||
]">
|
||||
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">
|
||||
{{song.ar?.map((a: any) => a.name).join('/')}}
|
||||
</p>
|
||||
<Teleport to="body">
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showDislikeModal && player.currentSong" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDislikeModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-5 select-auto">
|
||||
<h2 class="text-base font-semibold text-content mb-1">减少推荐</h2>
|
||||
<p class="text-xs text-content-3 mb-4">选择要减少的推荐类型</p>
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<button @click="dislikeSong"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
|
||||
<IconMusic class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">不推荐这首歌曲</p>
|
||||
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ player.currentSong.name }}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button v-if="dislikeArtistName" @click="dislikeArtist"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
|
||||
<IconUserRound class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">不推荐这个歌手</p>
|
||||
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ dislikeArtistName }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
class="text-gray-500 hover:text-red-400 transition text-sm">
|
||||
✕
|
||||
<button @click="showDislikeModal = false"
|
||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
<Transition name="queue-fade">
|
||||
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
|
||||
</Transition>
|
||||
<Transition name="queue-slide">
|
||||
<div v-if="showQueuePanel"
|
||||
class="fixed right-0 top-0 bottom-0 z-[56] w-[340px] bg-base/95 backdrop-blur border-l border-line flex flex-col shadow-2xl shadow-black/40">
|
||||
|
||||
<!-- 全屏歌词浮层 -->
|
||||
<Transition name="slide-up">
|
||||
<div v-if="showFullLyric && lyrics.length > 0 && !player.showRoamDrawer"
|
||||
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-72 overflow-hidden flex flex-col"
|
||||
@click.self="showFullLyric = false">
|
||||
<div class="flex justify-between mb-2">
|
||||
<h3 class="text-xs font-semibold">歌词</h3>
|
||||
<button @click="showFullLyric = false" class="text-gray-400 hover:text-white">收起</button>
|
||||
<div class="px-5 pt-5 pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
|
||||
<p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="player.clearQueue()"
|
||||
class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
|
||||
清空
|
||||
</button>
|
||||
<button @click="showQueuePanel = false"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px mx-5 bg-line"></div>
|
||||
|
||||
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
|
||||
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
|
||||
<IconMusic class="w-10 h-10 opacity-40" />
|
||||
<p class="text-sm">播放列表为空</p>
|
||||
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
|
||||
</div>
|
||||
|
||||
<SongListItem
|
||||
v-for="(song, idx) in player.queue" :key="song.id + '-' + idx"
|
||||
:id="'queue-item-' + idx"
|
||||
:song="song"
|
||||
:index="idx"
|
||||
:is-current="idx === player.currentIndex"
|
||||
show-playing-overlay
|
||||
cover-size="w-9 h-9"
|
||||
cover-size-param="?param=80y80"
|
||||
:container-class="idx === player.currentIndex ? 'bg-muted hover:bg-muted gap-3 px-3 py-2 rounded-lg' : 'hover:bg-subtle gap-3 px-3 py-2 rounded-lg'"
|
||||
@click="playFromQueue(idx)"
|
||||
>
|
||||
<template #actions>
|
||||
<button @click.stop="player.removeFromQueue(idx)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
|
||||
<IconX class="w-3 h-3" />
|
||||
</button>
|
||||
</template>
|
||||
</SongListItem>
|
||||
|
||||
<div class="h-2"></div>
|
||||
|
||||
<button v-if="player.currentIndex >= 0 && player.queue.length > 0" v-show="!currentSongVisible"
|
||||
@click="scrollToCurrent"
|
||||
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
|
||||
title="定位到正在播放">
|
||||
<IconCrosshair class="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="lyricContainer"
|
||||
class="flex-1 overflow-y-auto overflow-x-hidden whitespace-normal break-words space-y-1 text-sm text-center">
|
||||
<p v-for="(line, idx) in lyrics" :key="idx" :class="idx === currentLyricIdx
|
||||
? 'text-green-400 font-medium scale-105 transition'
|
||||
: 'text-gray-400'
|
||||
" class="px-4 py-0.5">
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onBeforeUnmount, watch, onMounted } from 'vue';
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
|
||||
import { usePlayerStore, PlayMode } from '../stores/player';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useLyric } from '../composables/UserLyric';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
import { AudioApi } from '../api';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useRouter } from 'vue-router';
|
||||
import SongListItem from './SongListItem.vue';
|
||||
import IconSkipBack from '~icons/lucide/skip-back';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconPause from '~icons/lucide/pause';
|
||||
import IconSkipForward from '~icons/lucide/skip-forward';
|
||||
import IconHeartOff from '~icons/lucide/heart-off';
|
||||
import IconVolumeX from '~icons/lucide/volume-x';
|
||||
import IconVolume2 from '~icons/lucide/volume-2';
|
||||
import IconRepeat from '~icons/lucide/repeat';
|
||||
import IconShuffle from '~icons/lucide/shuffle';
|
||||
import IconRepeat1 from '~icons/lucide/repeat-1';
|
||||
import IconListMusic from '~icons/lucide/list-music';
|
||||
import IconMessageSquare from '~icons/lucide/message-square';
|
||||
import IconDownload from '~icons/lucide/download';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconCrosshair from '~icons/lucide/crosshair';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const drawerActive = computed(() => player.showRoamDrawer && !!player.dominantColor);
|
||||
const showQueuePanel = ref(false);
|
||||
const { lyrics, currentLyricIdx, currentLyricText } = useLyric();
|
||||
const showFullLyric = ref(false);
|
||||
const lyricContainer = ref<HTMLElement | null>(null);
|
||||
const showDislikeModal = ref(false);
|
||||
const queueListEl = ref<HTMLElement | null>(null);
|
||||
const currentSongVisible = ref(true);
|
||||
const progressBar = ref<HTMLElement | null>(null);
|
||||
const isSeeking = ref(false);
|
||||
const previewTime = ref(0);
|
||||
const cacheProgress = ref(0);
|
||||
const volume = ref(100);
|
||||
const prevVolume = ref(100);
|
||||
|
||||
let unlistenCache: (() => void) | null = null;
|
||||
|
||||
// 缓存进度监听
|
||||
onMounted(async () => {
|
||||
const fn = await listen<number>('cache-progress', (event) => {
|
||||
cacheProgress.value = event.payload;
|
||||
@ -172,10 +267,17 @@ onBeforeUnmount(() => {
|
||||
if (unlistenCache) unlistenCache();
|
||||
});
|
||||
|
||||
// 播放模式
|
||||
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||
const modeIcons = { loop: '🔁', shuffle: '🔀', 'repeat-one': '🔂' };
|
||||
const modeIcon = computed(() => modeIcons[player.playMode] || '🔁');
|
||||
watch(() => player.currentSong, (song) => {
|
||||
if (!song) {
|
||||
cacheProgress.value = 0;
|
||||
} else if (song.localPath) {
|
||||
cacheProgress.value = 100;
|
||||
} else {
|
||||
cacheProgress.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const modeTexts: Record<PlayMode, string> = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
|
||||
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
|
||||
function togglePlayMode() {
|
||||
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
|
||||
@ -183,7 +285,16 @@ function togglePlayMode() {
|
||||
player.setPlayMode(next);
|
||||
}
|
||||
|
||||
// 进度条拖拽逻辑
|
||||
function toggleMute() {
|
||||
if (player.volume > 0) {
|
||||
prevVolume.value = player.volume;
|
||||
player.volume = 0;
|
||||
} else {
|
||||
player.volume = prevVolume.value || 100;
|
||||
}
|
||||
AudioApi.setVolume(player.volume / 100);
|
||||
}
|
||||
|
||||
let onDocMove: ((e: MouseEvent) => void) | null = null;
|
||||
let onDocUp: (() => void) | null = null;
|
||||
|
||||
@ -234,57 +345,90 @@ const displayProgress = computed(() => {
|
||||
return isSeeking.value ? previewPercent.value : progressPercent.value;
|
||||
});
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!sec || isNaN(sec)) return '0:00';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function playFromQueue(index: number) {
|
||||
player.currentIndex = index;
|
||||
player.playCurrent();
|
||||
}
|
||||
|
||||
const dislikeArtistName = computed(() => {
|
||||
const song = player.currentSong;
|
||||
if (!song?.ar?.length) return '';
|
||||
return song.ar.map(a => a.name).join(' / ');
|
||||
});
|
||||
|
||||
async function dislikeSong() {
|
||||
if (!player.currentSong) return;
|
||||
showDislikeModal.value = false;
|
||||
await player.fmTrash(player.currentSong.id);
|
||||
showToast('已减少该歌曲推荐', 'success');
|
||||
}
|
||||
|
||||
async function dislikeArtist() {
|
||||
if (!player.currentSong) return;
|
||||
showDislikeModal.value = false;
|
||||
await player.fmTrash(player.currentSong.id);
|
||||
showToast('已减少该歌手推荐', 'success');
|
||||
}
|
||||
|
||||
function scrollToCurrent() {
|
||||
const el = document.getElementById('queue-item-' + player.currentIndex);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
let currentSongObserver: IntersectionObserver | null = null;
|
||||
|
||||
function setupCurrentSongObserver() {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = document.getElementById('queue-item-' + player.currentIndex);
|
||||
if (!el || !queueListEl.value) return;
|
||||
currentSongObserver = new IntersectionObserver(
|
||||
([entry]) => { currentSongVisible.value = entry.isIntersecting; },
|
||||
{ root: queueListEl.value, threshold: 0.5 }
|
||||
);
|
||||
currentSongObserver.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => [showQueuePanel.value, player.currentIndex, player.queue.length], () => {
|
||||
if (showQueuePanel.value) {
|
||||
setupCurrentSongObserver();
|
||||
} else {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
currentSongVisible.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (currentSongObserver) {
|
||||
currentSongObserver.disconnect();
|
||||
currentSongObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = parseInt(target.value, 10);
|
||||
volume.value = val;
|
||||
await invoke('set_volume', { vol: val / 100 });
|
||||
player.volume = val;
|
||||
await AudioApi.setVolume(val / 100);
|
||||
}
|
||||
|
||||
const volumeBarBg = computed(() => {
|
||||
const pct = volume.value;
|
||||
return `linear-gradient(to right, #34d399 0%, #10b981 ${pct}%, rgba(255,255,255,0.15) ${pct}%)`;
|
||||
const pct = player.volume;
|
||||
const track = drawerActive.value ? 'rgba(255,255,255,0.2)' : 'var(--c-muted)';
|
||||
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, ${track} ${pct}%)`;
|
||||
});
|
||||
|
||||
// 歌词浮层自动滚动
|
||||
watch(
|
||||
() => currentLyricIdx.value,
|
||||
() => {
|
||||
if (showFullLyric.value && lyricContainer.value) {
|
||||
nextTick(() => {
|
||||
const active = lyricContainer.value?.querySelector('.text-green-400');
|
||||
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 样式保持不变(原有歌词浮层过渡、滑块样式等) */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.vol-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@ -300,9 +444,9 @@ watch(
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right,
|
||||
#34d399 0%,
|
||||
#10b981 var(--vol-fill),
|
||||
rgba(255, 255, 255, 0.15) var(--vol-fill));
|
||||
var(--c-accent) 0%,
|
||||
var(--c-accent) var(--vol-fill),
|
||||
var(--c-muted) var(--vol-fill));
|
||||
}
|
||||
|
||||
.vol-slider::-webkit-slider-thumb {
|
||||
@ -321,4 +465,23 @@ watch(
|
||||
.vol-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
.queue-fade-enter-active,
|
||||
.queue-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.queue-fade-enter-from,
|
||||
.queue-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.queue-slide-enter-active,
|
||||
.queue-slide-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.queue-slide-enter-from,
|
||||
.queue-slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
243
src/components/RoamDrawer.vue
Normal file
243
src/components/RoamDrawer.vue
Normal file
@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl"
|
||||
:class="!player.dominantColor && 'bg-surface/95'"
|
||||
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
|
||||
>
|
||||
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
||||
<TitleBar :dark-mode="!!player.dominantColor" @close="player.closeRoamDrawer()">
|
||||
<template #left>
|
||||
<button @click="player.closeRoamDrawer()" :class="player.dominantColor ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
||||
<IconChevronDown class="w-5 h-5" />
|
||||
</button>
|
||||
</template>
|
||||
</TitleBar>
|
||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0 relative z-10">
|
||||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
v-if="roamCoverUrl && !roamCoverError"
|
||||
:src="roamCoverUrl"
|
||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||
@error="roamCoverError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
||||
:class="player.dominantColor ? 'bg-white/10' : 'bg-muted'"
|
||||
>
|
||||
<IconMusic class="w-16 h-16" :class="player.dominantColor ? 'text-white/30' : 'text-content-4'" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-center" :class="player.dominantColor ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
|
||||
<p class="mt-2 text-center" :class="player.dominantColor ? 'text-white/70' : 'text-content-2'">
|
||||
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
|
||||
<span v-if="i > 0" :class="player.dominantColor ? 'text-white/40' : 'text-content-3'">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
<template v-if="roamSong?.al?.name">
|
||||
<span :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||
<div class="flex items-center gap-1 mb-3 px-4">
|
||||
<button @click="player.roamTab = 'lyric'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="player.dominantColor
|
||||
? (player.roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||
: (player.roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||
歌词
|
||||
</button>
|
||||
<button @click="player.roamTab = 'comment'"
|
||||
class="px-3 py-1 rounded-full text-sm transition"
|
||||
:class="player.dominantColor
|
||||
? (player.roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||
: (player.roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||
评论
|
||||
</button>
|
||||
<button v-if="hasTranslation" @click="toggleTranslation"
|
||||
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
|
||||
:class="player.dominantColor
|
||||
? (showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70')
|
||||
: (showTranslation ? 'bg-muted text-content font-medium' : 'text-content-4 hover:text-content-2')">
|
||||
<IconLanguages class="w-3 h-3" />
|
||||
译
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="player.roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||
<p
|
||||
v-for="(line, idx) in lyrics"
|
||||
:key="idx"
|
||||
:class="getRoamLyricClass(idx)"
|
||||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer whitespace-nowrap transition-[font-size] duration-300 ease-out"
|
||||
@click="seekToRoamLyric(line.time)"
|
||||
@mouseenter="roamLyricHovering = true"
|
||||
@mouseleave="roamLyricHovering = false"
|
||||
>
|
||||
{{ line.text }}
|
||||
<span v-if="showTranslation && line.translation" class="block text-sm mt-1" :class="getTranslationClass(idx)">{{ line.translation }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||||
</div>
|
||||
<div v-show="player.roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
||||
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" :dark-mode="!!player.dominantColor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { getCoverUrl, extractDominantColor } from '../utils/song';
|
||||
import { useLyric } from '../composables/UserLyric';
|
||||
import TitleBar from './TitleBar.vue';
|
||||
import CommentSection from './CommentSection.vue';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconLanguages from '~icons/lucide/languages';
|
||||
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||
const roamLyricHovering = ref(false);
|
||||
const roamLyricPadPx = ref(0);
|
||||
const roamSong = computed(() => player.currentSong);
|
||||
const roamCoverError = ref(false);
|
||||
const roamCoverUrl = computed(() => {
|
||||
if (!roamSong.value) return '';
|
||||
return getCoverUrl(roamSong.value) || '';
|
||||
});
|
||||
|
||||
watch(roamCoverUrl, async (url) => {
|
||||
roamCoverError.value = false;
|
||||
if (url) {
|
||||
const color = await extractDominantColor(url);
|
||||
player.dominantColor = color;
|
||||
} else {
|
||||
player.dominantColor = '';
|
||||
}
|
||||
});
|
||||
|
||||
let roamResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function updateRoamLyricPad() {
|
||||
if (lyricScrollContainer.value) {
|
||||
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => player.showRoamDrawer, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
updateRoamLyricPad();
|
||||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||
if (lyricScrollContainer.value) {
|
||||
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
|
||||
roamResizeObserver.observe(lyricScrollContainer.value);
|
||||
}
|
||||
scrollToRoamActiveLyric();
|
||||
});
|
||||
} else {
|
||||
if (roamResizeObserver) {
|
||||
roamResizeObserver.disconnect();
|
||||
roamResizeObserver = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (roamResizeObserver) {
|
||||
roamResizeObserver.disconnect();
|
||||
roamResizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentLyricIdx, () => {
|
||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||
nextTick(() => scrollToRoamActiveLyric());
|
||||
}
|
||||
});
|
||||
|
||||
watch(showTranslation, () => {
|
||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||
nextTick(() => scrollToRoamActiveLyric());
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToRoamActiveLyric() {
|
||||
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
||||
const container = lyricScrollContainer.value;
|
||||
const active = container.querySelector('.roam-lyric-active') as HTMLElement | null;
|
||||
if (!active) return;
|
||||
const target = active.offsetTop - container.clientHeight / 2 + active.clientHeight / 2;
|
||||
const start = container.scrollTop;
|
||||
const distance = target - start;
|
||||
if (Math.abs(distance) < 1) return;
|
||||
const duration = 400;
|
||||
const startTime = performance.now();
|
||||
function animate(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
container.scrollTop = start + distance * ease;
|
||||
if (progress < 1) requestAnimationFrame(animate);
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function getTranslationClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
const hasColor = !!player.dominantColor;
|
||||
if (idx === currentLyricIdx.value) return hasColor ? 'text-[var(--c-accent)]' : 'text-accent-text';
|
||||
if (diff === 1) return hasColor ? 'text-white/70' : 'text-content/70';
|
||||
if (diff === 2) return hasColor ? 'text-white/50' : 'text-content-2/50';
|
||||
return hasColor ? 'text-white/35' : 'text-content-3/35';
|
||||
}
|
||||
|
||||
function getRoamLyricClass(idx: number): string {
|
||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||
const hasColor = !!player.dominantColor;
|
||||
if (idx === currentLyricIdx.value) {
|
||||
return hasColor
|
||||
? 'roam-lyric-active font-bold text-xl text-[var(--c-accent)]'
|
||||
: 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||
}
|
||||
if (diff === 1) return hasColor ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
|
||||
if (diff === 2) return hasColor ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
|
||||
return hasColor ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
|
||||
}
|
||||
|
||||
function seekToRoamLyric(time: number) {
|
||||
if (time != null && player.duration > 0) {
|
||||
player.seek(time);
|
||||
}
|
||||
}
|
||||
|
||||
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||
player.closeRoamDrawer();
|
||||
router.push(routeLocation);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active { transition: transform 0.3s ease; }
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to { transform: translateY(100%); }
|
||||
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||||
</style>
|
||||
198
src/components/Sidebar.vue
Normal file
198
src/components/Sidebar.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
|
||||
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<div class="space-y-0.5">
|
||||
<router-link to="/"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconHome class="w-[18px] h-[18px]" />
|
||||
推荐
|
||||
</router-link>
|
||||
<router-link to="/discover"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconSearch class="w-[18px] h-[18px]" />
|
||||
发现
|
||||
</router-link>
|
||||
<button
|
||||
@click="openRoamFromSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
|
||||
>
|
||||
<IconRadio class="w-[18px] h-[18px]" />
|
||||
漫游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2">
|
||||
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
|
||||
<div class="space-y-0.5">
|
||||
<router-link to="/favorites"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconHeart class="w-[18px] h-[18px]" />
|
||||
我喜欢的音乐
|
||||
</router-link>
|
||||
<router-link to="/recent"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconClock class="w-[18px] h-[18px]" />
|
||||
最近播放
|
||||
</router-link>
|
||||
<router-link to="/local-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconMusic class="w-[18px] h-[18px]" />
|
||||
本地音乐
|
||||
</router-link>
|
||||
<router-link to="/downloaded-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconDownload class="w-[18px] h-[18px]" />
|
||||
下载音乐
|
||||
</router-link>
|
||||
<router-link v-if="userStore.isLoggedIn" to="/cloud-music"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconCloud class="w-[18px] h-[18px]" />
|
||||
音乐云盘
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||
<p class="text-xs text-content-3">我的歌单</p>
|
||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
|
||||
</div>
|
||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<span class="text-sm truncate"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||
@click="showSubPlaylists = !showSubPlaylists">
|
||||
<p class="text-xs text-content-3">收藏的歌单</p>
|
||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
|
||||
</div>
|
||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<span class="text-sm truncate"
|
||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
|
||||
<div class="px-1">
|
||||
<router-link to="/settings"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||
active-class="!text-content !bg-muted">
|
||||
<IconSettings class="w-[18px] h-[18px]" />
|
||||
设置
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
|
||||
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
||||
<router-link to="/login"
|
||||
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
||||
<IconLogIn class="w-4 h-4" />
|
||||
立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3 px-2 mt-3">
|
||||
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
||||
<button @click="userStore.logout(); player.stop()"
|
||||
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { MusicApi } from '../api';
|
||||
import IconHome from '~icons/lucide/home';
|
||||
import IconSearch from '~icons/lucide/search';
|
||||
import IconRadio from '~icons/lucide/radio';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconSettings from '~icons/lucide/settings';
|
||||
import IconLogIn from '~icons/lucide/log-in';
|
||||
import IconChevronRight from '~icons/lucide/chevron-right';
|
||||
import IconClock from '~icons/lucide/clock';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconCloud from '~icons/lucide/cloud';
|
||||
import IconDownload from '~icons/lucide/download';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const player = usePlayerStore();
|
||||
|
||||
const createdPlaylists = ref<any[]>([]);
|
||||
const subPlaylists = ref<any[]>([]);
|
||||
const showCreatedPlaylists = ref(true);
|
||||
const showSubPlaylists = ref(true);
|
||||
|
||||
async function loadPlaylists() {
|
||||
if (!userStore.isLoggedIn || !userStore.user) return;
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.userPlaylist(userStore.user.userId);
|
||||
const data = JSON.parse(jsonStr);
|
||||
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
||||
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function goPlaylist(id: number) {
|
||||
router.push({ name: 'playlist', params: { id } });
|
||||
}
|
||||
|
||||
function isPlaylistActive(id: number): boolean {
|
||||
return route.name === 'playlist' && Number(route.params.id) === id;
|
||||
}
|
||||
|
||||
async function openRoamFromSidebar() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
if (player.isFmMode) {
|
||||
player.openRoamDrawer();
|
||||
} else {
|
||||
await player.loadFm();
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => userStore.isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
loadPlaylists();
|
||||
player.loadLikedIds();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.isLoggedIn) {
|
||||
loadPlaylists();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
44
src/components/SongItemMenu.vue
Normal file
44
src/components/SongItemMenu.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="relative flex-shrink-0" ref="menuRef">
|
||||
<button @click.stop="toggle" class="text-content-3 hover:text-content transition p-1 rounded-md hover:bg-subtle">
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<div v-if="open"
|
||||
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[120px]">
|
||||
<button @click.stop="handleComment" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition">
|
||||
<IconMessageSquare style="font-size: 14px" />
|
||||
评论
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconMessageSquare from '~icons/lucide/message-square';
|
||||
|
||||
const player = usePlayerStore();
|
||||
const props = defineProps<{ songId: number }>();
|
||||
const open = ref(false);
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
function handleComment() {
|
||||
open.value = false;
|
||||
player.openCommentForSong(props.songId);
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClickOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
|
||||
</script>
|
||||
115
src/components/SongListItem.vue
Normal file
115
src/components/SongListItem.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', containerClass]">
|
||||
<slot name="index" :index="index" :is-current="isCurrent">
|
||||
<div v-if="showIndex" class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
||||
<div v-if="isCurrent && showPlayingOverlay" class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-[3px] h-4">
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
||||
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
||||
<IconPlay class="hidden group-hover:block text-content" style="font-size: 14px" />
|
||||
</template>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div :class="['rounded-md overflow-hidden flex-shrink-0 relative', coverClass]">
|
||||
<img v-if="coverSrc" :src="coverSrc" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
||||
<IconMusic style="font-size: 14px" class="text-content-4" />
|
||||
</div>
|
||||
<div v-if="isCurrent && showPlayingOverlay"
|
||||
class="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||
<div class="flex items-end gap-[2px] h-3">
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span>
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span>
|
||||
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" :class="nameClass">{{ song.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-if="song.ar?.length">
|
||||
<template v-for="(a, i) in song.ar" :key="a.id || i">
|
||||
<span v-if="i > 0" class="text-content-3">/</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="song.al?.name">
|
||||
<span class="text-content-3 mx-1">·</span>
|
||||
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot name="actions">
|
||||
<button v-if="showLike" @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
||||
<IconHeart v-if="player.isLiked(song.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
|
||||
<IconHeart v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<button v-if="showDownload" @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
||||
<IconLoader2 v-if="download.isDownloading(song.id)" class="w-4 h-4 animate-spin" />
|
||||
<IconCheck v-else-if="download.isDownloaded(song.id)" class="w-4 h-4 text-accent-text" />
|
||||
<IconDownload v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<SongItemMenu v-if="showMenu" :song-id="song.id" />
|
||||
</slot>
|
||||
|
||||
<span v-if="showDuration && song.dt" class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { getCoverUrl, type Song } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import SongItemMenu from './SongItemMenu.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconHeart from '~icons/lucide/heart';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconCheck from '~icons/lucide/check';
|
||||
import IconDownload from '~icons/lucide/download';
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
song: Song;
|
||||
index: number;
|
||||
isCurrent?: boolean;
|
||||
showIndex?: boolean;
|
||||
showLike?: boolean;
|
||||
showDownload?: boolean;
|
||||
showMenu?: boolean;
|
||||
showDuration?: boolean;
|
||||
showPlayingOverlay?: boolean;
|
||||
coverSize?: string;
|
||||
coverSizeParam?: string;
|
||||
containerClass?: string;
|
||||
}>(), {
|
||||
isCurrent: false,
|
||||
showIndex: false,
|
||||
showLike: false,
|
||||
showDownload: false,
|
||||
showMenu: false,
|
||||
showDuration: false,
|
||||
showPlayingOverlay: false,
|
||||
coverSize: 'w-10 h-10',
|
||||
coverSizeParam: '',
|
||||
containerClass: 'hover:bg-subtle',
|
||||
});
|
||||
|
||||
const coverClass = computed(() => props.coverSize);
|
||||
const coverSrc = computed(() => getCoverUrl(props.song, props.coverSizeParam));
|
||||
const nameClass = computed(() => props.isCurrent ? 'text-accent-text' : '');
|
||||
</script>
|
||||
43
src/components/TitleBar.vue
Normal file
43
src/components/TitleBar.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="h-10 flex items-center justify-between px-4 flex-shrink-0 select-none relative z-10"
|
||||
:class="darkMode ? '' : 'bg-surface/90 backdrop-blur'"
|
||||
>
|
||||
<slot name="left">
|
||||
<span v-if="!darkMode" class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
|
||||
</slot>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
|
||||
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
|
||||
<button @click="$emit('close')" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
defineProps<{
|
||||
darkMode?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
|
||||
function minimizeWindow() {
|
||||
currentWindow.minimize();
|
||||
}
|
||||
|
||||
async function toggleMaximize() {
|
||||
const isMaximized = await currentWindow.isMaximized();
|
||||
if (isMaximized) {
|
||||
currentWindow.unmaximize();
|
||||
} else {
|
||||
currentWindow.maximize();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
60
src/components/ToastContainer.vue
Normal file
60
src/components/ToastContainer.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<TransitionGroup name="toast" tag="div" class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
@click="dismiss(toast.id)"
|
||||
class="pointer-events-auto min-w-[280px] max-w-[400px] px-4 py-3 rounded-lg shadow-lg bg-surface/95 backdrop-blur cursor-pointer border-l-4"
|
||||
:class="borderClass(toast.type)"
|
||||
>
|
||||
<p class="text-sm font-medium" :class="textClass(toast.type)">{{ toast.message }}</p>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast, type Toast } from '../composables/useToast';
|
||||
|
||||
const { toasts } = useToast();
|
||||
|
||||
function borderClass(type: Toast['type']) {
|
||||
return {
|
||||
success: 'border-accent',
|
||||
error: 'border-danger',
|
||||
info: 'border-info',
|
||||
}[type];
|
||||
}
|
||||
|
||||
function textClass(type: Toast['type']) {
|
||||
return {
|
||||
success: 'text-accent-text',
|
||||
error: 'text-danger',
|
||||
info: 'text-info',
|
||||
}[type];
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
const idx = toasts.value.findIndex(t => t.id === id);
|
||||
if (idx !== -1) toasts.value.splice(idx, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
.toast-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
88
src/components/UpdateDialog.vue
Normal file
88
src/components/UpdateDialog.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="!downloading && handleIgnore()">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[440px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<IconDownload class="w-5 h-5 text-accent-text" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-content">发现新版本</h2>
|
||||
<p class="text-xs text-content-3 mt-0.5">
|
||||
v{{ info.currentVersion }} → <span class="text-accent-text font-medium">v{{ info.version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="info.date" class="text-xs text-content-3 mt-2 ml-[52px]">{{ formatDate(info.date) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="info.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60 ml-[52px]">
|
||||
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ info.body }}</div>
|
||||
</div>
|
||||
<div v-else class="px-6 pb-4 ml-[52px]">
|
||||
<p class="text-sm text-content-3">暂无更新日志</p>
|
||||
</div>
|
||||
|
||||
<div v-if="downloading" class="px-6 pb-2">
|
||||
<div class="w-full bg-subtle rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: downloadProgress + '%' }"></div>
|
||||
</div>
|
||||
<p class="text-xs text-content-3 mt-1 text-center">正在下载更新 {{ downloadProgress }}%</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button
|
||||
@click="handleIgnore"
|
||||
:disabled="downloading"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition disabled:opacity-50"
|
||||
>
|
||||
忽略此版本
|
||||
</button>
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
:disabled="downloading"
|
||||
class="flex-1 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{{ downloading ? `下载中 ${downloadProgress}%` : '立即更新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UpdateInfo } from '../composables/useUpdater'
|
||||
import IconDownload from '~icons/lucide/download'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
info: UpdateInfo & { currentVersion: string }
|
||||
downloading: boolean
|
||||
downloadProgress: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: []
|
||||
ignore: []
|
||||
}>()
|
||||
|
||||
function handleUpdate() {
|
||||
emit('update')
|
||||
}
|
||||
|
||||
function handleIgnore() {
|
||||
if (props.downloading) return
|
||||
emit('ignore')
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1,34 +1,40 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
||||
import { ref, watch } from 'vue';
|
||||
import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { MusicApi } from '../api';
|
||||
|
||||
export function useLyric() {
|
||||
const player = usePlayerStore();
|
||||
|
||||
const lyrics = ref<LyricLine[]>([]);
|
||||
const currentLyricIdx = ref(-1);
|
||||
|
||||
const currentLyricText = computed(() => {
|
||||
if (lyrics.value.length === 0) return '';
|
||||
const idx = currentLyricIdx.value;
|
||||
return idx >= 0 && idx < lyrics.value.length ? lyrics.value[idx].text : '';
|
||||
});
|
||||
const showTranslation = ref(true);
|
||||
const hasTranslation = ref(false);
|
||||
|
||||
watch(() => player.currentSong, async (song) => {
|
||||
if (!song) {
|
||||
lyrics.value = [];
|
||||
currentLyricIdx.value = -1;
|
||||
hasTranslation.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_lyric', { id: song.id });
|
||||
const jsonStr: string = await MusicApi.getLyric(song.id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const lrc = data?.lrc?.lyric || '';
|
||||
lyrics.value = lrc ? parseLrc(lrc) : [];
|
||||
const tLrc = data?.tlyric?.lyric || '';
|
||||
let parsed = lrc ? parseLrc(lrc) : [];
|
||||
if (tLrc && parsed.length > 0) {
|
||||
parsed = mergeTranslation(parsed, tLrc);
|
||||
hasTranslation.value = parsed.some(l => l.translation);
|
||||
} else {
|
||||
hasTranslation.value = false;
|
||||
}
|
||||
lyrics.value = parsed;
|
||||
currentLyricIdx.value = -1;
|
||||
} catch {
|
||||
lyrics.value = [];
|
||||
hasTranslation.value = false;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@ -40,9 +46,15 @@ export function useLyric() {
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTranslation() {
|
||||
showTranslation.value = !showTranslation.value;
|
||||
}
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
currentLyricIdx,
|
||||
currentLyricText,
|
||||
hasTranslation,
|
||||
showTranslation,
|
||||
toggleTranslation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
134
src/composables/useDownload.ts
Normal file
134
src/composables/useDownload.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { reactive, watch } from 'vue';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { getCoverUrl, type Song } from '../utils/song';
|
||||
import { DownloadApi } from '../api';
|
||||
|
||||
interface DownloadTask {
|
||||
id: number;
|
||||
name: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const downloadingIds = reactive<Set<number>>(new Set());
|
||||
const tasks = reactive<DownloadTask[]>([]);
|
||||
const localSongIds = reactive<Set<number>>(new Set());
|
||||
|
||||
let listenerSetup = false;
|
||||
let storeSetup = false;
|
||||
|
||||
async function setupDownloadListener() {
|
||||
if (listenerSetup) return;
|
||||
listenerSetup = true;
|
||||
await listen<{ id: number; progress: number; name: string }>('download-progress', (event) => {
|
||||
const { id, progress, name } = event.payload;
|
||||
if (progress >= 100) {
|
||||
const idx = tasks.findIndex(t => t.id === id);
|
||||
if (idx >= 0) {
|
||||
tasks.splice(idx, 1);
|
||||
downloadingIds.delete(id);
|
||||
showToast(`${name} 下载完成`, 'success');
|
||||
}
|
||||
} else {
|
||||
const task = tasks.find(t => t.id === id);
|
||||
if (task) {
|
||||
task.progress = progress;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshLocalIds() {
|
||||
try {
|
||||
const settings = useSettingsStore();
|
||||
const list: { id: number }[] = await DownloadApi.listLocalSongs(settings.downloadPath || null);
|
||||
localSongIds.clear();
|
||||
for (const s of list) {
|
||||
localSongIds.add(s.id);
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function ensureStoreSetup() {
|
||||
if (storeSetup) return;
|
||||
storeSetup = true;
|
||||
const settings = useSettingsStore();
|
||||
refreshLocalIds();
|
||||
watch(() => settings.downloadPath, () => {
|
||||
refreshLocalIds();
|
||||
});
|
||||
}
|
||||
|
||||
function isDownloaded(songId: number): boolean {
|
||||
return localSongIds.has(songId);
|
||||
}
|
||||
|
||||
function isDownloading(songId: number): boolean {
|
||||
return downloadingIds.has(songId);
|
||||
}
|
||||
|
||||
function getDownloadProgress(songId: number): number {
|
||||
const task = tasks.find(t => t.id === songId);
|
||||
return task?.progress ?? 0;
|
||||
}
|
||||
|
||||
async function downloadSong(song: Song) {
|
||||
if (downloadingIds.has(song.id)) return;
|
||||
if (localSongIds.has(song.id)) {
|
||||
showToast(`${song.name} 已下载`, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const artist = song.ar?.map(a => a.name).join(' / ') || '未知';
|
||||
const albumName = song.al?.name || null;
|
||||
const durationVal = song.dt || null;
|
||||
const coverUrl = getCoverUrl(song) || null;
|
||||
|
||||
downloadingIds.add(song.id);
|
||||
tasks.push({ id: song.id, name: song.name, progress: 0 });
|
||||
|
||||
try {
|
||||
await DownloadApi.downloadSong({
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist,
|
||||
album: albumName,
|
||||
duration: durationVal,
|
||||
coverUrl,
|
||||
level: settings.audioQuality,
|
||||
downloadPath: settings.downloadPath || null,
|
||||
});
|
||||
localSongIds.add(song.id);
|
||||
} catch (e: any) {
|
||||
downloadingIds.delete(song.id);
|
||||
const idx = tasks.findIndex(t => t.id === song.id);
|
||||
if (idx >= 0) tasks.splice(idx, 1);
|
||||
if (e === '文件已存在') {
|
||||
localSongIds.add(song.id);
|
||||
showToast(`${song.name} 已下载`, 'info');
|
||||
} else if (e === 'VIP歌曲无法下载') {
|
||||
showToast(`${song.name} 为 VIP 歌曲,无法下载`, 'error');
|
||||
} else if (typeof e === 'string' && e.includes('VIP')) {
|
||||
showToast(`${song.name} 需要 VIP 权限才能下载`, 'error');
|
||||
} else {
|
||||
showToast(`下载失败: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
setupDownloadListener();
|
||||
ensureStoreSetup();
|
||||
return {
|
||||
downloadingIds,
|
||||
tasks,
|
||||
localSongIds,
|
||||
isDownloaded,
|
||||
isDownloading,
|
||||
getDownloadProgress,
|
||||
downloadSong,
|
||||
refreshLocalIds,
|
||||
};
|
||||
}
|
||||
52
src/composables/useLocalMusic.ts
Normal file
52
src/composables/useLocalMusic.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { MusicApi } from '../api';
|
||||
import type { Song } from '../utils/song';
|
||||
|
||||
export interface LocalSong {
|
||||
id: number;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
cover: string | null;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
path: string;
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
export function localSongToSong(local: LocalSong): Song {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
ar: local.artist ? [{ name: local.artist }] : [],
|
||||
al: { picUrl: local.cover || '', name: local.album || undefined },
|
||||
dt: local.duration || undefined,
|
||||
localPath: local.path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMissingCovers(songs: LocalSong[]): Promise<void> {
|
||||
const missing = songs.filter(s => !s.cover && s.id > 0 && s.id < 1e12);
|
||||
if (missing.length === 0) return;
|
||||
const ids = [...new Set(missing.map(s => s.id))];
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.getSongDetail(JSON.stringify(ids));
|
||||
const data = JSON.parse(jsonStr);
|
||||
const detailMap = new Map<number, string>();
|
||||
for (const s of data.songs || []) {
|
||||
const url = s.al?.picUrl;
|
||||
if (url && s.id) detailMap.set(s.id, url + '?param=100y100');
|
||||
}
|
||||
for (const song of missing) {
|
||||
const url = detailMap.get(song.id);
|
||||
if (url) song.cover = url;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
30
src/composables/useOnlineStatus.ts
Normal file
30
src/composables/useOnlineStatus.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const isOnline = ref(navigator.onLine);
|
||||
|
||||
function update() {
|
||||
isOnline.value = navigator.onLine;
|
||||
}
|
||||
|
||||
let refCount = 0;
|
||||
|
||||
export function useOnlineStatus() {
|
||||
onMounted(() => {
|
||||
refCount++;
|
||||
if (refCount === 1) {
|
||||
window.addEventListener('online', update);
|
||||
window.addEventListener('offline', update);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
refCount--;
|
||||
if (refCount <= 0) {
|
||||
refCount = 0;
|
||||
window.removeEventListener('online', update);
|
||||
window.removeEventListener('offline', update);
|
||||
}
|
||||
});
|
||||
|
||||
return { isOnline };
|
||||
}
|
||||
30
src/composables/usePageCache.ts
Normal file
30
src/composables/usePageCache.ts
Normal file
@ -0,0 +1,30 @@
|
||||
const cache = new Map<string, { data: any; ts: number }>();
|
||||
const TTL = 5 * 60 * 1000;
|
||||
|
||||
export function pageCacheGet(key: string): any | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.ts > TTL) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function pageCacheSet(key: string, data: any) {
|
||||
cache.set(key, { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
export function pageCacheDelete(key: string) {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
export function pageCacheInvalidate(key: string) {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
export function pageCacheIsStale(key: string): boolean {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return true;
|
||||
return Date.now() - entry.ts > TTL;
|
||||
}
|
||||
37
src/composables/useToast.ts
Normal file
37
src/composables/useToast.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
const MAX_TOASTS = 3;
|
||||
const DEDUP_WINDOW = 3000;
|
||||
const recentMessages = new Map<string, number>();
|
||||
|
||||
export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
|
||||
const key = `${type}:${message}`;
|
||||
const now = Date.now();
|
||||
const lastShown = recentMessages.get(key);
|
||||
if (lastShown && now - lastShown < DEDUP_WINDOW) return;
|
||||
recentMessages.set(key, now);
|
||||
setTimeout(() => { recentMessages.delete(key); }, DEDUP_WINDOW);
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
toasts.value.shift();
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
toasts.value.push({ id, message, type });
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return { toasts, showToast };
|
||||
}
|
||||
142
src/composables/useUpdater.ts
Normal file
142
src/composables/useUpdater.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { ref } from 'vue'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string
|
||||
date: string | null
|
||||
body: string | null
|
||||
}
|
||||
|
||||
const IGNORED_VERSION_KEY = 'updater_ignored_version'
|
||||
|
||||
export function useUpdater() {
|
||||
const checking = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const updateAvailable = ref(false)
|
||||
const updateInfo = ref<UpdateInfo | null>(null)
|
||||
const currentVersion = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function getCurrentVersion() {
|
||||
try {
|
||||
currentVersion.value = await getVersion()
|
||||
} catch {
|
||||
currentVersion.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function getIgnoredVersion(): string {
|
||||
try {
|
||||
return localStorage.getItem(IGNORED_VERSION_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function setIgnoredVersion(version: string) {
|
||||
try {
|
||||
localStorage.setItem(IGNORED_VERSION_KEY, version)
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
async function checkForUpdate(silent = false): Promise<UpdateInfo | null> {
|
||||
if (checking.value) return null
|
||||
checking.value = true
|
||||
error.value = ''
|
||||
updateAvailable.value = false
|
||||
updateInfo.value = null
|
||||
|
||||
try {
|
||||
await getCurrentVersion()
|
||||
const result = await check()
|
||||
|
||||
if (!result) {
|
||||
if (!silent) error.value = '当前已是最新版本'
|
||||
return null
|
||||
}
|
||||
|
||||
const info: UpdateInfo = {
|
||||
version: result.version,
|
||||
date: result.date ?? null,
|
||||
body: result.body ?? null,
|
||||
}
|
||||
|
||||
const ignored = getIgnoredVersion()
|
||||
if (info.version === ignored && silent) {
|
||||
return null
|
||||
}
|
||||
|
||||
updateAvailable.value = true
|
||||
updateInfo.value = info
|
||||
return info
|
||||
} catch (e: any) {
|
||||
if (!silent) error.value = `检查更新失败: ${e}`
|
||||
return null
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndInstall() {
|
||||
if (downloading.value) return
|
||||
downloading.value = true
|
||||
downloadProgress.value = 0
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const result = await check()
|
||||
if (!result) {
|
||||
error.value = '未找到可用更新'
|
||||
return
|
||||
}
|
||||
|
||||
let downloaded = 0
|
||||
let contentLength = 0
|
||||
await result.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength ?? 0
|
||||
break
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength
|
||||
if (contentLength > 0) {
|
||||
downloadProgress.value = Math.round((downloaded / contentLength) * 100)
|
||||
}
|
||||
break
|
||||
case 'Finished':
|
||||
downloadProgress.value = 100
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
await relaunch()
|
||||
} catch (e: any) {
|
||||
error.value = `更新失败: ${e}`
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function ignoreVersion(version: string) {
|
||||
setIgnoredVersion(version)
|
||||
updateAvailable.value = false
|
||||
updateInfo.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
checking,
|
||||
downloading,
|
||||
downloadProgress,
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
currentVersion,
|
||||
error,
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
ignoreVersion,
|
||||
getCurrentVersion,
|
||||
}
|
||||
}
|
||||
@ -4,29 +4,22 @@ import './style.css';
|
||||
import router from './router';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
// ---------- 彻底阻止双指拖动和手势 ----------
|
||||
const preventGesture = (e: Event) => e.preventDefault();
|
||||
|
||||
// 阻止 iOS / macOS 手势缩放和页面拖动
|
||||
document.addEventListener('gesturestart', preventGesture, { passive: false });
|
||||
document.addEventListener('gesturechange', preventGesture, { passive: false });
|
||||
document.addEventListener('gestureend', preventGesture, { passive: false });
|
||||
|
||||
// 阻止触控板双指水平滑动(若仍存在)
|
||||
window.addEventListener('wheel', (e: WheelEvent) => {
|
||||
// 只阻止水平方向,保留垂直滚动(内部容器会处理)
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// 阻止移动端双指触摸移动(不影响单指滚动)
|
||||
window.addEventListener('touchmove', (e: TouchEvent) => {
|
||||
if (e.touches.length >= 2) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
// -------------------------------------------
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
|
||||
@ -1,26 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Home from '@/views/Home.vue';
|
||||
import Discover from '@/views/Discover.vue';
|
||||
import PlaylistDetail from '@/views/PlaylistDetail.vue';
|
||||
import Login from '@/views/Login.vue';
|
||||
import FavoriteSongs from '@/views/FavoriteSongs.vue';
|
||||
import RecentPlays from '@/views/RecentPlays.vue';
|
||||
import DailySongs from '@/views/DailySongs.vue';
|
||||
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/discover', name: 'discover', component: Discover },
|
||||
{ path: '/search', name: 'search', component: Discover }, // 同样指向Discover,保留兼容
|
||||
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') }, // 漫游页面
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
|
||||
{ path: '/recent', name: 'recent', component: RecentPlays },
|
||||
{ path: '/daily', name: 'daily', component: DailySongs }, // 每日推荐
|
||||
{ path: '/login', name: 'login', component: Login },
|
||||
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
|
||||
{ path: '/', name: 'home', component: () => import('@/views/Home.vue') },
|
||||
{ path: '/discover', name: 'discover', component: () => import('@/views/Discover.vue') },
|
||||
{ path: '/search', name: 'search', component: () => import('@/views/Discover.vue') },
|
||||
{ path: '/favorites', name: 'favorites', component: () => import('@/views/FavoriteSongs.vue') },
|
||||
{ path: '/recent', name: 'recent', component: () => import('@/views/RecentPlays.vue') },
|
||||
{ path: '/daily', name: 'daily', component: () => import('@/views/DailySongs.vue') },
|
||||
{ path: '/local-music', name: 'local-music', component: () => import('@/views/LocalMusic.vue') },
|
||||
{ path: '/downloaded-music', name: 'downloaded-music', component: () => import('@/views/DownloadedMusic.vue') },
|
||||
{ path: '/cloud-music', name: 'cloud-music', component: () => import('@/views/CloudMusic.vue') },
|
||||
{ path: '/login', name: 'login', component: () => import('@/views/Login.vue'), meta: { guest: true } },
|
||||
{ path: '/playlist/:id', name: 'playlist', component: () => import('@/views/PlaylistDetail.vue') },
|
||||
{ path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
|
||||
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') },
|
||||
{ path: '/settings', name: 'settings', component: () => import('@/views/Settings.vue') },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.guest) {
|
||||
const raw = localStorage.getItem('user_profile');
|
||||
if (raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data?.userId) return { name: 'home' };
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
5
src/shims-icons.d.ts
vendored
Normal file
5
src/shims-icons.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module '~icons/lucide/*' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@ -1,38 +1,25 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref , watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { normalizeSong } from '../utils/song';
|
||||
import { ref, watch } from 'vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { useSettingsStore } from './settings';
|
||||
import { useUserStore } from './user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { MusicApi, AudioApi } from '../api';
|
||||
|
||||
// 设置播放模式,目前只有顺序循环,后续可扩展
|
||||
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
|
||||
export type { Song };
|
||||
|
||||
export interface Song {
|
||||
id: number;
|
||||
name: string;
|
||||
ar: { name: string }[];
|
||||
al: { picUrl: string };
|
||||
dt?: number;
|
||||
import { listen, emit } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
// 兼容不同接口返回的可选字段
|
||||
album?: { picUrl?: string };
|
||||
artists?: { name: string }[];
|
||||
duration?: number; // 某些接口的时长字段(单位可能是秒)
|
||||
function loadRecentLocal(): Song[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('recent_local');
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* 忽略 */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
const cacheProgress = ref(0);
|
||||
|
||||
// 监听 Tauri 事件(需要在适当位置初始化一次)
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
export function setupCacheProgressListener() {
|
||||
listen<number>('cache-progress', (event) => {
|
||||
cacheProgress.value = event.payload;
|
||||
});
|
||||
}
|
||||
|
||||
// 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用
|
||||
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
const currentSong = ref<Song | null>(null);
|
||||
const playing = ref(false);
|
||||
@ -42,11 +29,112 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const queue = ref<Song[]>([]);
|
||||
const currentIndex = ref(-1);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const volume = ref(settings.volume);
|
||||
|
||||
watch(volume, (val) => { settings.volume = val; });
|
||||
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
function clearTick() {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
}
|
||||
function setTick(v: ReturnType<typeof setInterval>) {
|
||||
tickInterval = v;
|
||||
}
|
||||
|
||||
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||
const MAX_RECENT = 200;
|
||||
|
||||
const likedIds = ref<Set<number>>(new Set());
|
||||
|
||||
function emitPlaybackState() {
|
||||
const song = currentSong.value;
|
||||
const status = playing.value ? 'playing' : (song ? 'paused' : 'stopped');
|
||||
emit('playback-state', {
|
||||
status,
|
||||
title: song?.name || '',
|
||||
album: song?.al?.name || '',
|
||||
artists: song?.ar?.map(a => a.name) || [],
|
||||
coverUrl: song?.al?.picUrl || '',
|
||||
durationUs: (song?.dt || 0) * 1000,
|
||||
positionUs: Math.round(currentTime.value * 1_000_000),
|
||||
volume: volume.value / 100,
|
||||
});
|
||||
}
|
||||
|
||||
function isLiked(songId: number): boolean {
|
||||
return likedIds.value.has(songId);
|
||||
}
|
||||
|
||||
async function loadLikedIds() {
|
||||
const userStore = useUserStore();
|
||||
if (!userStore.isLoggedIn) return;
|
||||
try {
|
||||
const json = await MusicApi.likelist(userStore.user!.userId);
|
||||
const data = JSON.parse(json);
|
||||
const ids: number[] = data.ids || data.data?.ids || [];
|
||||
likedIds.value = new Set(ids);
|
||||
} catch (e) {
|
||||
console.error('加载喜欢列表失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLike(songId: number) {
|
||||
const wasLiked = likedIds.value.has(songId);
|
||||
const newLike = !wasLiked;
|
||||
try {
|
||||
await MusicApi.likeSong(songId, newLike);
|
||||
if (newLike) {
|
||||
likedIds.value.add(songId);
|
||||
} else {
|
||||
likedIds.value.delete(songId);
|
||||
}
|
||||
likedIds.value = new Set(likedIds.value);
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function addRecent(song: Song) {
|
||||
recentLocal.value = recentLocal.value.filter(s => s.id !== song.id);
|
||||
recentLocal.value.unshift(song);
|
||||
if (recentLocal.value.length > MAX_RECENT) {
|
||||
recentLocal.value = recentLocal.value.slice(0, MAX_RECENT);
|
||||
}
|
||||
}
|
||||
|
||||
watch(recentLocal, (val) => {
|
||||
localStorage.setItem('recent_local', JSON.stringify(val));
|
||||
}, { deep: true });
|
||||
|
||||
const isFmMode = ref(false);
|
||||
const fmQueue: Song[] = [];
|
||||
let fmNextCallback: (() => void) | null = null;
|
||||
|
||||
const fmMode = ref<string>('DEFAULT');
|
||||
const fmSubMode = ref<string>('');
|
||||
|
||||
let lastScrobbleId: number | null = null;
|
||||
let lastScrobbleStartTime: number = 0;
|
||||
|
||||
function reportScrobble() {
|
||||
const song = currentSong.value;
|
||||
if (!song || song.localPath || song.id == null) {
|
||||
lastScrobbleId = null;
|
||||
return;
|
||||
}
|
||||
if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) {
|
||||
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
|
||||
if (playedSec > 5 && navigator.onLine) {
|
||||
MusicApi.scrobble({
|
||||
id: song.id,
|
||||
sourceid: isFmMode.value ? String(song.id) : '',
|
||||
time: playedSec,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
lastScrobbleId = song.id;
|
||||
lastScrobbleStartTime = Date.now();
|
||||
}
|
||||
|
||||
function enableFmMode(onNext: () => void) {
|
||||
isFmMode.value = true;
|
||||
fmNextCallback = onNext;
|
||||
@ -55,14 +143,53 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
function disableFmMode() {
|
||||
isFmMode.value = false;
|
||||
fmNextCallback = null;
|
||||
fmQueue.length = 0;
|
||||
fmMode.value = 'DEFAULT';
|
||||
fmSubMode.value = '';
|
||||
fmSong.value = null;
|
||||
fmPlaying.value = false;
|
||||
}
|
||||
|
||||
// 播放私人漫游歌曲(清空队列,只播放这一首)
|
||||
async function playFmSong(song: any) {
|
||||
// 如果缺少时长,尝试从详情接口获取
|
||||
function clearFmQueue() {
|
||||
fmQueue.length = 0;
|
||||
}
|
||||
|
||||
async function fmTrash(songId: number) {
|
||||
try {
|
||||
await MusicApi.fmTrash(songId, 25);
|
||||
} catch (e) {
|
||||
console.error('fm_trash 失败', e);
|
||||
showToast('减少推荐失败', 'error');
|
||||
}
|
||||
await nextFm();
|
||||
}
|
||||
|
||||
async function fetchFmBatch(): Promise<Song[]> {
|
||||
const isDefault = fmMode.value === 'DEFAULT' && !fmSubMode.value;
|
||||
const jsonStr: string = isDefault
|
||||
? await MusicApi.personalFm()
|
||||
: await MusicApi.personalFmMode({
|
||||
mode: fmMode.value,
|
||||
subMode: fmSubMode.value,
|
||||
limit: 3,
|
||||
});
|
||||
const data = JSON.parse(jsonStr);
|
||||
const raw = data.data || data;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||||
return raw.map((s: any) => normalizeSong(s));
|
||||
}
|
||||
|
||||
let fmVipSkipCount = 0;
|
||||
const MAX_FM_VIP_SKIP = 10;
|
||||
|
||||
async function playFmSong(song: Song) {
|
||||
const seq = ++_playSeq;
|
||||
_switchingSong = true;
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
if (!song.dt || song.dt === 0) {
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_song_detail', { id: Number(song.id) });
|
||||
const jsonStr = await MusicApi.getSongDetail(String(song.id));
|
||||
const data = JSON.parse(jsonStr);
|
||||
const full = data.songs?.[0];
|
||||
if (full) {
|
||||
@ -70,36 +197,90 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
song.al = full.al || song.al;
|
||||
song.ar = full.ar || song.ar;
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
await invoke('stop_audio');
|
||||
await AudioApi.stopAudio();
|
||||
queue.value = [];
|
||||
currentIndex.value = -1;
|
||||
playing.value = false;
|
||||
|
||||
fmSong.value = song;
|
||||
currentSong.value = song;
|
||||
try {
|
||||
const url: string = await invoke('get_song_url', { id: Number(song.id) });
|
||||
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality, fm_mode: true });
|
||||
if (seq !== _playSeq) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
if (!url) throw new Error('无播放源');
|
||||
await invoke('play_audio', { url });
|
||||
playing.value = true;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
currentTime.value = 0;
|
||||
startTick();
|
||||
|
||||
if (data.freeTrialInfo) {
|
||||
console.warn('FM VIP 试听歌曲,自动跳过', song.name);
|
||||
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
|
||||
fmVipSkipCount++;
|
||||
if (fmVipSkipCount >= MAX_FM_VIP_SKIP) {
|
||||
console.warn('FM 连续跳过 VIP 歌曲过多,停止');
|
||||
fmVipSkipCount = 0;
|
||||
disableFmMode();
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
disableFmMode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fmVipSkipCount = 0;
|
||||
await AudioApi.playAudio(url);
|
||||
if (seq !== _playSeq) return;
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
currentTime.value = 0;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
} else {
|
||||
playing.value = false;
|
||||
showToast('FM 播放启动超时,仍在尝试加载…', 'info');
|
||||
watchForLatePlayback(seq, song);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== _playSeq) return;
|
||||
console.error('FM播放失败', e);
|
||||
playing.value = false;
|
||||
showToast('FM 播放失败', 'error');
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
disableFmMode();
|
||||
}
|
||||
} finally {
|
||||
if (seq === _playSeq) {
|
||||
_switchingSong = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 播放指定歌曲(如果不在队列中则加入并切换)
|
||||
async function play(song: Song) {
|
||||
disableFmMode();
|
||||
|
||||
const idx = queue.value.findIndex(s => s.id === song.id);
|
||||
if (idx !== -1 && idx === currentIndex.value && currentSong.value?.id === song.id) {
|
||||
if (!playing.value) {
|
||||
await AudioApi.resumeAudio();
|
||||
playing.value = true;
|
||||
startTick();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx === -1) {
|
||||
// 未在队列中,添加到队列并播放该位置
|
||||
queue.value.push(song);
|
||||
currentIndex.value = queue.value.length - 1;
|
||||
} else {
|
||||
@ -108,66 +289,245 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
await playCurrent();
|
||||
}
|
||||
|
||||
async function playFromList(songs: Song[], startIndex: number) {
|
||||
disableFmMode();
|
||||
if (songs.length === 0) return;
|
||||
|
||||
const targetSong = songs[startIndex];
|
||||
if (targetSong && currentSong.value?.id === targetSong.id && currentIndex.value >= 0) {
|
||||
const sameQueue = queue.value.length === songs.length
|
||||
&& queue.value.every((s, i) => s.id === songs[i].id);
|
||||
if (sameQueue) {
|
||||
if (!playing.value) {
|
||||
await AudioApi.resumeAudio();
|
||||
playing.value = true;
|
||||
startTick();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
queue.value = [...songs];
|
||||
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
|
||||
await playCurrent();
|
||||
}
|
||||
|
||||
let vipSkipCount = 0;
|
||||
const MAX_VIP_SKIP = 10;
|
||||
|
||||
let _playSeq = 0;
|
||||
let _switchingSong = false;
|
||||
|
||||
async function waitForPlaybackStart(timeoutMs: number = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
try {
|
||||
if (await AudioApi.isAudioPlaying()) return true;
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
try {
|
||||
return await AudioApi.isAudioPlaying();
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function playCurrent() {
|
||||
const seq = ++_playSeq;
|
||||
_switchingSong = true;
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
const song = queue.value[currentIndex.value];
|
||||
if (!song?.id) {
|
||||
console.error('无效的歌曲数据', song);
|
||||
_switchingSong = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 重置状态
|
||||
currentSong.value = song;
|
||||
playing.value = false;
|
||||
currentTime.value = 0;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
|
||||
// 获取 URL 并播放
|
||||
const url: string = await invoke('get_song_url', { id: Number(song.id) });
|
||||
if (song.localPath) {
|
||||
await AudioApi.playLocalAudio(song.localPath);
|
||||
if (seq !== _playSeq) return;
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
} else {
|
||||
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||
watchForLatePlayback(seq, song);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality });
|
||||
if (seq !== _playSeq) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
|
||||
if (!url) {
|
||||
console.error('未获取到有效播放地址', song);
|
||||
return;
|
||||
}
|
||||
|
||||
await invoke('play_audio', { url });
|
||||
playing.value = true;
|
||||
startTick();
|
||||
if (data.freeTrialInfo) {
|
||||
console.warn('VIP 试听歌曲,自动跳过', song.name);
|
||||
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
|
||||
vipSkipCount++;
|
||||
if (vipSkipCount >= MAX_VIP_SKIP) {
|
||||
console.warn('连续跳过 VIP 歌曲过多,停止跳过');
|
||||
vipSkipCount = 0;
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
await AudioApi.playAudio(url);
|
||||
if (seq !== _playSeq) return;
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
emitPlaybackState();
|
||||
} else {
|
||||
playing.value = false;
|
||||
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||
watchForLatePlayback(seq, song);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== _playSeq) return;
|
||||
console.error('播放失败', e);
|
||||
playing.value = false;
|
||||
showToast('播放失败,请稍后重试', 'error');
|
||||
} finally {
|
||||
if (seq === _playSeq) {
|
||||
_switchingSong = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 超时后继续监听后端播放状态,如果后端实际开始播放则恢复状态
|
||||
function watchForLatePlayback(seq: number, song: Song) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 15;
|
||||
const check = async () => {
|
||||
if (seq !== _playSeq) return;
|
||||
if (playing.value) return;
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) return;
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (seq !== _playSeq) return;
|
||||
if (backendPlaying) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
emitPlaybackState();
|
||||
return;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
if (seq === _playSeq && !playing.value) {
|
||||
setTimeout(check, 1000);
|
||||
}
|
||||
};
|
||||
setTimeout(check, 1000);
|
||||
}
|
||||
|
||||
let onSeekStart: (() => void) | null = null;
|
||||
|
||||
function startTick() {
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
tickInterval = setInterval(() => {
|
||||
clearTick();
|
||||
let seekGuard = false;
|
||||
onSeekStart = () => { seekGuard = true; };
|
||||
let syncCounter = 1;
|
||||
let lastSyncPos = -1;
|
||||
let backendFrozen = false;
|
||||
let stateSyncCounter = 0;
|
||||
setTick(setInterval(async () => {
|
||||
if (playing.value && duration.value > 0) {
|
||||
currentTime.value += 0.25;
|
||||
if (currentTime.value >= duration.value) {
|
||||
if (seekGuard) return;
|
||||
syncCounter++;
|
||||
stateSyncCounter++;
|
||||
if (syncCounter >= 2) {
|
||||
syncCounter = 0;
|
||||
try {
|
||||
const pos = await AudioApi.getAudioPosition();
|
||||
if (pos >= currentTime.value - 0.5) {
|
||||
currentTime.value = pos;
|
||||
}
|
||||
if (lastSyncPos < 0) {
|
||||
lastSyncPos = pos;
|
||||
} else if (pos <= lastSyncPos + 0.05) {
|
||||
backendFrozen = true;
|
||||
lastSyncPos = pos;
|
||||
} else {
|
||||
backendFrozen = false;
|
||||
lastSyncPos = pos;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
if (stateSyncCounter >= 8) {
|
||||
stateSyncCounter = 0;
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (backendPlaying !== playing.value) {
|
||||
playing.value = backendPlaying;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
} else {
|
||||
if (!backendFrozen) {
|
||||
const next = currentTime.value + 0.25;
|
||||
if (next <= duration.value) {
|
||||
currentTime.value = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentTime.value > duration.value) {
|
||||
currentTime.value = duration.value;
|
||||
next(); // 自动下一首
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
}, 250));
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (backendPlaying !== playing.value) {
|
||||
playing.value = backendPlaying;
|
||||
}
|
||||
} catch { /* 忽略查询失败 */ }
|
||||
|
||||
if (playing.value) {
|
||||
await invoke('pause_audio');
|
||||
await AudioApi.pauseAudio();
|
||||
playing.value = false;
|
||||
} else {
|
||||
await invoke('resume_audio');
|
||||
await AudioApi.resumeAudio();
|
||||
playing.value = true;
|
||||
}
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
await invoke('stop_audio');
|
||||
await AudioApi.stopAudio();
|
||||
playing.value = false;
|
||||
currentSong.value = null;
|
||||
currentTime.value = 0;
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
disableFmMode(); // 停止时退出漫游
|
||||
clearTick();
|
||||
disableFmMode();
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
|
||||
@ -178,7 +538,6 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
playCurrent();
|
||||
}
|
||||
|
||||
// 批量添加歌曲到队列并播放第一首(用于“播放全部”)
|
||||
async function playAll(songs: Song[]) {
|
||||
if (songs.length === 0) return;
|
||||
queue.value = [...songs];
|
||||
@ -190,22 +549,17 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
if (index < 0 || index >= queue.value.length) return;
|
||||
const isCurrent = index === currentIndex.value;
|
||||
if (isCurrent) {
|
||||
// 如果移除的是当前正在播放的歌曲,先停止,然后调整索引
|
||||
stop();
|
||||
queue.value.splice(index, 1);
|
||||
// 如果队列变空,则重置
|
||||
if (queue.value.length === 0) {
|
||||
currentIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
// 保持索引不变,但如果删的是最后一个,索引需要退一位
|
||||
if (currentIndex.value >= queue.value.length) {
|
||||
currentIndex.value = queue.value.length - 1;
|
||||
}
|
||||
// 不自动播放,等用户手动选择
|
||||
} else {
|
||||
queue.value.splice(index, 1);
|
||||
// 调整当前索引
|
||||
if (index < currentIndex.value) {
|
||||
currentIndex.value -= 1;
|
||||
}
|
||||
@ -220,22 +574,30 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
async function seek(time: number) {
|
||||
try {
|
||||
await invoke('seek_audio', { time });
|
||||
currentTime.value = time;
|
||||
if (onSeekStart) onSeekStart();
|
||||
await AudioApi.seekAudio(time);
|
||||
startTick();
|
||||
emitPlaybackState();
|
||||
} catch (e) {
|
||||
console.error('seek 失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function adjustVolume(delta: number) {
|
||||
const newVol = Math.max(0, Math.min(100, volume.value + delta));
|
||||
volume.value = newVol;
|
||||
await AudioApi.setVolume(newVol / 100);
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
|
||||
// 在 defineStore 内部添加
|
||||
const playMode = ref<PlayMode>('loop');
|
||||
|
||||
function setPlayMode(mode: PlayMode) {
|
||||
playMode.value = mode;
|
||||
}
|
||||
|
||||
// 重写 next() 以根据模式选择下一首
|
||||
function next() {
|
||||
if (isFmMode.value && fmNextCallback) {
|
||||
fmNextCallback();
|
||||
@ -246,11 +608,9 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
let nextIndex: number;
|
||||
switch (playMode.value) {
|
||||
case 'repeat-one':
|
||||
// 单曲循环,不改变索引,只重新播放当前
|
||||
playCurrent();
|
||||
return;
|
||||
case 'shuffle':
|
||||
// 随机下一首,且不与当前重复(除非只剩一首)
|
||||
if (queue.value.length === 1) {
|
||||
nextIndex = 0;
|
||||
} else {
|
||||
@ -261,7 +621,6 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
break;
|
||||
case 'loop':
|
||||
default:
|
||||
// 顺序循环
|
||||
nextIndex = (currentIndex.value + 1) % queue.value.length;
|
||||
break;
|
||||
}
|
||||
@ -270,69 +629,80 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
const showRoamDrawer = ref(false);
|
||||
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||
const commentSongId = ref<number | null>(null);
|
||||
const dominantColor = ref('');
|
||||
|
||||
function openRoamDrawer() {
|
||||
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
|
||||
roamTab.value = tab;
|
||||
showRoamDrawer.value = true;
|
||||
}
|
||||
|
||||
function openCommentForSong(songId: number) {
|
||||
commentSongId.value = songId;
|
||||
openRoamDrawer('comment');
|
||||
}
|
||||
|
||||
function closeRoamDrawer() {
|
||||
showRoamDrawer.value = false;
|
||||
roamTab.value = 'lyric';
|
||||
}
|
||||
|
||||
function toggleRoamDrawer() {
|
||||
showRoamDrawer.value = !showRoamDrawer.value;
|
||||
}
|
||||
|
||||
async function loadFirstFmSong() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
enableFmMode(() => loadFirstFmSong()); // 下一首回调
|
||||
const batch = await fetchFmBatch();
|
||||
if (batch.length > 0) {
|
||||
fmQueue.push(...batch);
|
||||
const song = fmQueue.shift()!;
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(song);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('FM 加载失败', 'error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// -------- FM 专属状态 --------
|
||||
const fmSong = ref<any>(null);
|
||||
const fmSong = ref<Song | null>(null);
|
||||
const fmPlaying = ref(false);
|
||||
|
||||
async function loadFm() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
fmSong.value = song;
|
||||
enableFmMode(nextFm); // 设置下一首回调为 store 内的 nextFm
|
||||
await playFmSong(song); // 使用 FM 专用播放方法
|
||||
fmPlaying.value = true;
|
||||
// showRoamDrawer.value = true; // 自动打开全屏抽屉
|
||||
if (fmQueue.length === 0) {
|
||||
const batch = await fetchFmBatch();
|
||||
if (batch.length === 0) return;
|
||||
fmQueue.push(...batch);
|
||||
}
|
||||
const song = fmQueue.shift()!;
|
||||
fmSong.value = song;
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(song);
|
||||
fmPlaying.value = true;
|
||||
if (fmQueue.length <= 1) {
|
||||
fetchFmBatch().then(batch => { fmQueue.push(...batch); }).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('FM加载失败', e);
|
||||
showToast('FM 加载失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFm() {
|
||||
if (!fmSong.value) return;
|
||||
if (fmPlaying.value) {
|
||||
// 当前 FM 正在播放,切换暂停/恢复
|
||||
await toggle(); // 全局暂停/播放
|
||||
await toggle();
|
||||
fmPlaying.value = playing.value;
|
||||
} else {
|
||||
// FM 处于暂停状态,或者当前被其他歌曲打断
|
||||
if (currentSong.value?.id === fmSong.value.id) {
|
||||
// FM 歌曲还是当前歌曲,直接恢复
|
||||
await toggle();
|
||||
fmPlaying.value = playing.value;
|
||||
} else {
|
||||
// 当前播放的是其他歌曲,重新以 FM 模式播放 FM 歌曲
|
||||
enableFmMode(nextFm);
|
||||
await playFmSong(fmSong.value);
|
||||
fmPlaying.value = true;
|
||||
@ -341,20 +711,66 @@ async function toggleFm() {
|
||||
}
|
||||
|
||||
async function nextFm() {
|
||||
await loadFm(); // 加载下一首 FM 歌曲
|
||||
await loadFm();
|
||||
}
|
||||
|
||||
// 监听全局播放变化,若用户选择了非 FM 歌曲,自动退出 FM 状态
|
||||
listen('audio-ended', () => {
|
||||
if (_switchingSong) return;
|
||||
const player = usePlayerStore();
|
||||
player.clearTick();
|
||||
player.reportScrobble();
|
||||
player.next();
|
||||
});
|
||||
|
||||
listen<string>('mpris-command', (event) => {
|
||||
const cmd = event.payload;
|
||||
const player = usePlayerStore();
|
||||
if (cmd === 'Next') {
|
||||
player.next();
|
||||
} else if (cmd === 'Previous') {
|
||||
player.prev();
|
||||
} else if (cmd === 'PlayPause') {
|
||||
player.toggle();
|
||||
} else if (cmd === 'Play') {
|
||||
if (!player.playing) player.toggle();
|
||||
} else if (cmd === 'Pause') {
|
||||
if (player.playing) player.toggle();
|
||||
} else if (cmd === 'Stop') {
|
||||
player.stop();
|
||||
} else if (cmd.startsWith('SetVolume:')) {
|
||||
const vol = parseFloat(cmd.slice(10));
|
||||
if (!isNaN(vol)) {
|
||||
player.volume = Math.round(vol * 100);
|
||||
AudioApi.setVolume(vol).catch(() => {});
|
||||
}
|
||||
} else if (cmd.startsWith('Seek:')) {
|
||||
const offsetUs = parseInt(cmd.slice(5), 10);
|
||||
const offsetSec = offsetUs / 1_000_000;
|
||||
const newPos = Math.max(0, Math.min(player.currentTime + offsetSec, player.duration));
|
||||
player.seek(newPos);
|
||||
} else if (cmd.startsWith('SetPosition:')) {
|
||||
const posUs = parseInt(cmd.slice(13), 10);
|
||||
const posSec = posUs / 1_000_000;
|
||||
if (posSec < 1 && player.currentTime > 5) {
|
||||
return;
|
||||
}
|
||||
player.seek(posSec);
|
||||
} else if (cmd === 'Raise') {
|
||||
getCurrentWindow().show().catch(() => {});
|
||||
getCurrentWindow().setFocus().catch(() => {});
|
||||
} else if (cmd === 'Quit') {
|
||||
getCurrentWindow().close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentSong, (newSong) => {
|
||||
if (isFmMode.value && newSong?.id !== fmSong.value?.id) {
|
||||
fmPlaying.value = false;
|
||||
// 注意:不调用 disableFmMode,因为可能只是临时切歌,但卡片需要知道 FM 已停止
|
||||
disableFmMode(); // 退出 FM 模式,让上一首按钮恢复
|
||||
disableFmMode();
|
||||
}
|
||||
});
|
||||
|
||||
watch(playing, (val) => {
|
||||
// 只有当前正在播放的是 FM 歌曲时,才同步 fmPlaying
|
||||
if (currentSong.value?.id === fmSong.value?.id) {
|
||||
fmPlaying.value = val;
|
||||
} else {
|
||||
@ -377,6 +793,7 @@ watch(playing, (val) => {
|
||||
playFmSong,
|
||||
setPlayMode,
|
||||
play,
|
||||
playFromList,
|
||||
playAll,
|
||||
toggle,
|
||||
stop,
|
||||
@ -384,19 +801,41 @@ watch(playing, (val) => {
|
||||
next,
|
||||
seek,
|
||||
playCurrent,
|
||||
volume,
|
||||
adjustVolume,
|
||||
|
||||
removeFromQueue,
|
||||
clearQueue,
|
||||
|
||||
recentLocal,
|
||||
|
||||
likedIds,
|
||||
isLiked,
|
||||
loadLikedIds,
|
||||
toggleLike,
|
||||
|
||||
showRoamDrawer,
|
||||
roamTab,
|
||||
commentSongId,
|
||||
dominantColor,
|
||||
openCommentForSong,
|
||||
openRoamDrawer,
|
||||
closeRoamDrawer,
|
||||
toggleRoamDrawer,
|
||||
loadFirstFmSong,
|
||||
|
||||
fetchFmBatch,
|
||||
clearFmQueue,
|
||||
fmTrash,
|
||||
reportScrobble,
|
||||
clearTick,
|
||||
|
||||
fmSong,
|
||||
fmPlaying,
|
||||
fmMode,
|
||||
fmSubMode,
|
||||
loadFm,
|
||||
toggleFm,
|
||||
nextFm,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
235
src/stores/settings.ts
Normal file
235
src/stores/settings.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
|
||||
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
|
||||
export type ThemeColor = 'blue' | 'green' | 'rose' | 'violet' | 'orange' | 'cyan' | 'pink';
|
||||
export type Appearance = 'dark' | 'light';
|
||||
export type CloseAction = 'ask' | 'minimize' | 'exit';
|
||||
|
||||
export const themeLabels: Record<ThemeColor, string> = {
|
||||
blue: '天蓝',
|
||||
green: '翠绿',
|
||||
rose: '玫红',
|
||||
violet: '紫罗兰',
|
||||
orange: '橙色',
|
||||
cyan: '青色',
|
||||
pink: '粉色',
|
||||
};
|
||||
|
||||
export const themeColors: Record<ThemeColor, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
rose: '#f43f5e',
|
||||
violet: '#8b5cf6',
|
||||
orange: '#f97316',
|
||||
cyan: '#06b6d4',
|
||||
pink: '#ec4899',
|
||||
};
|
||||
|
||||
export const appearanceLabels: Record<Appearance, string> = {
|
||||
dark: '深色',
|
||||
light: '浅色',
|
||||
};
|
||||
|
||||
export const qualityLabels: Record<AudioQuality, string> = {
|
||||
standard: '标准',
|
||||
higher: '较高',
|
||||
exhigh: '极高 (HQ)',
|
||||
lossless: '无损 (SQ)',
|
||||
hires: 'Hi-Res',
|
||||
};
|
||||
|
||||
export const closeActionLabels: Record<CloseAction, string> = {
|
||||
ask: '每次询问',
|
||||
minimize: '最小化到托盘',
|
||||
exit: '直接退出',
|
||||
};
|
||||
|
||||
export interface ShortcutBinding {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
playPause: { key: 'Control+KeyP', label: '播放/暂停' },
|
||||
prev: { key: 'Control+ArrowLeft', label: '上一首' },
|
||||
next: { key: 'Control+ArrowRight', label: '下一首' },
|
||||
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
|
||||
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
|
||||
globalPlayPause: { key: 'Control+Alt+KeyP', label: '播放/暂停(全局)' },
|
||||
globalPrev: { key: 'Control+Alt+ArrowLeft', label: '上一首(全局)' },
|
||||
globalNext: { key: 'Control+Alt+ArrowRight', label: '下一首(全局)' },
|
||||
globalVolUp: { key: 'Control+Alt+ArrowUp', label: '音量增加(全局)' },
|
||||
globalVolDown: { key: 'Control+Alt+ArrowDown', label: '音量减小(全局)' },
|
||||
};
|
||||
|
||||
interface SettingsData {
|
||||
audioQuality: AudioQuality;
|
||||
downloadPath: string;
|
||||
localMusicPaths: string[];
|
||||
theme: ThemeColor;
|
||||
appearance: Appearance;
|
||||
closeAction: CloseAction;
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
function loadSettings(): SettingsData {
|
||||
try {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const theme = parsed.theme || parsed.accentColor || 'blue';
|
||||
const validThemes: ThemeColor[] = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
|
||||
const validAppearances: Appearance[] = ['dark', 'light'];
|
||||
const appearance = validAppearances.includes(parsed.appearance) ? parsed.appearance : 'dark';
|
||||
if (parsed.theme && parsed.theme.startsWith('light-')) {
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
localMusicPaths: parsed.localMusicPaths || [],
|
||||
theme: validThemes.includes(parsed.theme.slice(6)) ? parsed.theme.slice(6) : 'blue',
|
||||
appearance: 'light',
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
|
||||
};
|
||||
}
|
||||
return {
|
||||
audioQuality: parsed.audioQuality || 'standard',
|
||||
downloadPath: parsed.downloadPath || '',
|
||||
localMusicPaths: parsed.localMusicPaths || [],
|
||||
theme: validThemes.includes(theme) ? theme : 'blue',
|
||||
appearance,
|
||||
closeAction: parsed.closeAction || 'ask',
|
||||
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
|
||||
outputDevice: parsed.outputDevice || null,
|
||||
volume: typeof parsed.volume === 'number' ? parsed.volume : 100,
|
||||
};
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
localMusicPaths: [],
|
||||
theme: 'blue',
|
||||
appearance: 'dark',
|
||||
closeAction: 'ask',
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
volume: 100,
|
||||
};
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const saved = loadSettings();
|
||||
|
||||
const audioQuality = ref<AudioQuality>(saved.audioQuality);
|
||||
const downloadPath = ref<string>(saved.downloadPath);
|
||||
const localMusicPaths = ref<string[]>(saved.localMusicPaths);
|
||||
const theme = ref<ThemeColor>(saved.theme);
|
||||
const appearance = ref<Appearance>(saved.appearance);
|
||||
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
|
||||
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
|
||||
const outputDevice = ref<string | null>(saved.outputDevice);
|
||||
const volume = ref<number>(saved.volume);
|
||||
|
||||
const dataTheme = computed(() =>
|
||||
appearance.value === 'light' ? `light-${theme.value}` : theme.value
|
||||
);
|
||||
|
||||
function setAudioQuality(q: AudioQuality) {
|
||||
audioQuality.value = q;
|
||||
}
|
||||
|
||||
function setDownloadPath(p: string) {
|
||||
downloadPath.value = p;
|
||||
}
|
||||
|
||||
function addLocalMusicPath(p: string) {
|
||||
if (!localMusicPaths.value.includes(p)) {
|
||||
localMusicPaths.value = [...localMusicPaths.value, p];
|
||||
}
|
||||
}
|
||||
|
||||
function removeLocalMusicPath(p: string) {
|
||||
localMusicPaths.value = localMusicPaths.value.filter(v => v !== p);
|
||||
}
|
||||
|
||||
function setTheme(t: ThemeColor) {
|
||||
theme.value = t;
|
||||
}
|
||||
|
||||
function setAppearance(a: Appearance) {
|
||||
appearance.value = a;
|
||||
}
|
||||
|
||||
function setCloseAction(a: CloseAction) {
|
||||
closeAction.value = a;
|
||||
}
|
||||
|
||||
function setShortcut(id: string, key: string) {
|
||||
shortcuts.value = { ...shortcuts.value, [id]: { ...shortcuts.value[id], key } };
|
||||
}
|
||||
|
||||
function resetShortcuts() {
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
}
|
||||
|
||||
function setOutputDevice(device: string | null) {
|
||||
outputDevice.value = device;
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
localMusicPaths.value = [];
|
||||
theme.value = 'blue';
|
||||
appearance.value = 'dark';
|
||||
closeAction.value = 'ask';
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
volume.value = 100;
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, localMusicPaths, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
localMusicPaths: localMusicPaths.value,
|
||||
theme: theme.value,
|
||||
appearance: appearance.value,
|
||||
closeAction: closeAction.value,
|
||||
shortcuts: shortcuts.value,
|
||||
outputDevice: outputDevice.value,
|
||||
volume: volume.value,
|
||||
};
|
||||
localStorage.setItem('app_settings', JSON.stringify(data));
|
||||
}, { deep: true });
|
||||
|
||||
return {
|
||||
audioQuality,
|
||||
downloadPath,
|
||||
localMusicPaths,
|
||||
theme,
|
||||
appearance,
|
||||
dataTheme,
|
||||
closeAction,
|
||||
shortcuts,
|
||||
outputDevice,
|
||||
volume,
|
||||
setAudioQuality,
|
||||
setDownloadPath,
|
||||
addLocalMusicPath,
|
||||
removeLocalMusicPath,
|
||||
setTheme,
|
||||
setAppearance,
|
||||
setCloseAction,
|
||||
setOutputDevice,
|
||||
setShortcut,
|
||||
resetShortcuts,
|
||||
resetAll,
|
||||
};
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
|
||||
export interface UserProfile {
|
||||
userId: number;
|
||||
@ -21,7 +21,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await invoke('logout'); } catch {}
|
||||
try { await MusicApi.logout(); } catch { /* 忽略 */ }
|
||||
user.value = null;
|
||||
isLoggedIn.value = false;
|
||||
localStorage.removeItem('user_profile');
|
||||
|
||||
408
src/style.css
408
src/style.css
@ -1,14 +1,339 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-base: var(--c-bg);
|
||||
--color-surface: var(--c-surface);
|
||||
--color-subtle: var(--c-subtle);
|
||||
--color-muted: var(--c-muted);
|
||||
--color-emphasis: var(--c-emphasis);
|
||||
--color-content: var(--c-content);
|
||||
--color-content-2: var(--c-content-2);
|
||||
--color-content-3: var(--c-content-3);
|
||||
--color-content-4: var(--c-content-4);
|
||||
--color-line: var(--c-line);
|
||||
--color-line-2: var(--c-line-2);
|
||||
--color-accent: var(--c-accent);
|
||||
--color-accent-hover: var(--c-accent-hover);
|
||||
--color-accent-text: var(--c-accent-text);
|
||||
--color-accent-dim: var(--c-accent-dim);
|
||||
--color-danger: var(--c-danger);
|
||||
--color-danger-dim: var(--c-danger-dim);
|
||||
--color-warning: var(--c-warning);
|
||||
--color-info: var(--c-info);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-surface: 255 255 255;
|
||||
--color-primary: 34 197 94;
|
||||
:root,
|
||||
[data-theme="green"] {
|
||||
--c-bg: #020c06;
|
||||
--c-surface: #0a1a10;
|
||||
--c-subtle: rgba(34, 197, 94, 0.06);
|
||||
--c-muted: rgba(34, 197, 94, 0.10);
|
||||
--c-emphasis: rgba(34, 197, 94, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #22c55e;
|
||||
--c-accent-hover: #16a34a;
|
||||
--c-accent-text: #4ade80;
|
||||
--c-accent-dim: rgba(34, 197, 94, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="rose"] {
|
||||
--c-bg: #0c0206;
|
||||
--c-surface: #1a0a10;
|
||||
--c-subtle: rgba(244, 63, 94, 0.06);
|
||||
--c-muted: rgba(244, 63, 94, 0.10);
|
||||
--c-emphasis: rgba(244, 63, 94, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f43f5e;
|
||||
--c-accent-hover: #e11d48;
|
||||
--c-accent-text: #fb7185;
|
||||
--c-accent-dim: rgba(244, 63, 94, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="blue"] {
|
||||
--c-bg: #02060c;
|
||||
--c-surface: #0a101a;
|
||||
--c-subtle: rgba(59, 130, 246, 0.06);
|
||||
--c-muted: rgba(59, 130, 246, 0.10);
|
||||
--c-emphasis: rgba(59, 130, 246, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #3b82f6;
|
||||
--c-accent-hover: #2563eb;
|
||||
--c-accent-text: #60a5fa;
|
||||
--c-accent-dim: rgba(59, 130, 246, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #8b5cf6;
|
||||
}
|
||||
|
||||
[data-theme="violet"] {
|
||||
--c-bg: #06020c;
|
||||
--c-surface: #120a1a;
|
||||
--c-subtle: rgba(139, 92, 246, 0.06);
|
||||
--c-muted: rgba(139, 92, 246, 0.10);
|
||||
--c-emphasis: rgba(139, 92, 246, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #8b5cf6;
|
||||
--c-accent-hover: #7c3aed;
|
||||
--c-accent-text: #a78bfa;
|
||||
--c-accent-dim: rgba(139, 92, 246, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="orange"] {
|
||||
--c-bg: #0c0602;
|
||||
--c-surface: #1a120a;
|
||||
--c-subtle: rgba(249, 115, 22, 0.06);
|
||||
--c-muted: rgba(249, 115, 22, 0.10);
|
||||
--c-emphasis: rgba(249, 115, 22, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #f97316;
|
||||
--c-accent-hover: #ea580c;
|
||||
--c-accent-text: #fb923c;
|
||||
--c-accent-dim: rgba(249, 115, 22, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="cyan"] {
|
||||
--c-bg: #020c0c;
|
||||
--c-surface: #0a1a1a;
|
||||
--c-subtle: rgba(6, 182, 212, 0.06);
|
||||
--c-muted: rgba(6, 182, 212, 0.10);
|
||||
--c-emphasis: rgba(6, 182, 212, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #06b6d4;
|
||||
--c-accent-hover: #0891b2;
|
||||
--c-accent-text: #22d3ee;
|
||||
--c-accent-dim: rgba(6, 182, 212, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="pink"] {
|
||||
--c-bg: #0c020a;
|
||||
--c-surface: #1a0a16;
|
||||
--c-subtle: rgba(236, 72, 153, 0.06);
|
||||
--c-muted: rgba(236, 72, 153, 0.10);
|
||||
--c-emphasis: rgba(236, 72, 153, 0.18);
|
||||
--c-content: #ffffff;
|
||||
--c-content-2: #9ca3af;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #4b5563;
|
||||
--c-line: rgba(255, 255, 255, 0.08);
|
||||
--c-line-2: rgba(255, 255, 255, 0.04);
|
||||
--c-accent: #ec4899;
|
||||
--c-accent-hover: #db2777;
|
||||
--c-accent-text: #f472b6;
|
||||
--c-accent-dim: rgba(236, 72, 153, 0.20);
|
||||
--c-danger: #ef4444;
|
||||
--c-danger-dim: rgba(239, 68, 68, 0.20);
|
||||
--c-warning: #eab308;
|
||||
--c-info: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="light-green"] {
|
||||
--c-bg: #f8faf9;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(34, 197, 94, 0.06);
|
||||
--c-muted: rgba(34, 197, 94, 0.10);
|
||||
--c-emphasis: rgba(34, 197, 94, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #16a34a;
|
||||
--c-accent-hover: #15803d;
|
||||
--c-accent-text: #15803d;
|
||||
--c-accent-dim: rgba(34, 197, 94, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-rose"] {
|
||||
--c-bg: #faf8f9;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(244, 63, 94, 0.06);
|
||||
--c-muted: rgba(244, 63, 94, 0.10);
|
||||
--c-emphasis: rgba(244, 63, 94, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #e11d48;
|
||||
--c-accent-hover: #be123c;
|
||||
--c-accent-text: #be123c;
|
||||
--c-accent-dim: rgba(244, 63, 94, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-blue"] {
|
||||
--c-bg: #f8f9fb;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(59, 130, 246, 0.06);
|
||||
--c-muted: rgba(59, 130, 246, 0.10);
|
||||
--c-emphasis: rgba(59, 130, 246, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #2563eb;
|
||||
--c-accent-hover: #1d4ed8;
|
||||
--c-accent-text: #1d4ed8;
|
||||
--c-accent-dim: rgba(59, 130, 246, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #7c3aed;
|
||||
}
|
||||
|
||||
[data-theme="light-violet"] {
|
||||
--c-bg: #f9f8fb;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(139, 92, 246, 0.06);
|
||||
--c-muted: rgba(139, 92, 246, 0.10);
|
||||
--c-emphasis: rgba(139, 92, 246, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #7c3aed;
|
||||
--c-accent-hover: #6d28d9;
|
||||
--c-accent-text: #6d28d9;
|
||||
--c-accent-dim: rgba(139, 92, 246, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-orange"] {
|
||||
--c-bg: #faf9f8;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(249, 115, 22, 0.06);
|
||||
--c-muted: rgba(249, 115, 22, 0.10);
|
||||
--c-emphasis: rgba(249, 115, 22, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #ea580c;
|
||||
--c-accent-hover: #c2410c;
|
||||
--c-accent-text: #c2410c;
|
||||
--c-accent-dim: rgba(249, 115, 22, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-cyan"] {
|
||||
--c-bg: #f8fbfb;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(6, 182, 212, 0.06);
|
||||
--c-muted: rgba(6, 182, 212, 0.10);
|
||||
--c-emphasis: rgba(6, 182, 212, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #0891b2;
|
||||
--c-accent-hover: #0e7490;
|
||||
--c-accent-text: #0e7490;
|
||||
--c-accent-dim: rgba(6, 182, 212, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="light-pink"] {
|
||||
--c-bg: #faf8f9;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: rgba(236, 72, 153, 0.06);
|
||||
--c-muted: rgba(236, 72, 153, 0.10);
|
||||
--c-emphasis: rgba(236, 72, 153, 0.16);
|
||||
--c-content: #111827;
|
||||
--c-content-2: #4b5563;
|
||||
--c-content-3: #6b7280;
|
||||
--c-content-4: #9ca3af;
|
||||
--c-line: rgba(0, 0, 0, 0.08);
|
||||
--c-line-2: rgba(0, 0, 0, 0.04);
|
||||
--c-accent: #db2777;
|
||||
--c-accent-hover: #be185d;
|
||||
--c-accent-text: #be185d;
|
||||
--c-accent-dim: rgba(236, 72, 153, 0.15);
|
||||
--c-danger: #dc2626;
|
||||
--c-danger-dim: rgba(220, 38, 38, 0.12);
|
||||
--c-warning: #ca8a04;
|
||||
--c-info: #2563eb;
|
||||
}
|
||||
|
||||
/* 确保 html 也应用暗色背景,防止空白区域 */
|
||||
html {
|
||||
background: #0f172a;
|
||||
background: var(--c-bg);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
@ -17,17 +342,22 @@
|
||||
body {
|
||||
@apply antialiased;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
/* 关键:锁住 body,彻底消除整体拖动 */
|
||||
background: var(--c-bg);
|
||||
color: var(--c-content);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
/* 阻止触控板手势触发页面导航 */
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
input, textarea, [contenteditable="true"] {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* 自定义滚动条保持不变 */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@ -36,10 +366,64 @@
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
background-color: var(--c-muted);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
background-color: var(--c-emphasis);
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 0 0 2px var(--c-accent-dim);
|
||||
}
|
||||
|
||||
select option {
|
||||
background: var(--c-surface);
|
||||
color: var(--c-content);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--c-emphasis);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:hover {
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked {
|
||||
background: var(--c-accent);
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
29
src/utils/format.ts
Normal file
29
src/utils/format.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export function formatDuration(ms: number): string {
|
||||
const sec = Math.floor(ms / 1000);
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatTime(sec: number): string {
|
||||
if (!sec || isNaN(sec)) return '0:00';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const YI = 100_000_000;
|
||||
const WAN = 10_000;
|
||||
|
||||
export function formatPlayCount(count: number): string {
|
||||
if (!count) return '0';
|
||||
if (count >= YI) return (count / YI).toFixed(1) + '亿';
|
||||
if (count >= WAN) return (count / WAN).toFixed(1) + '万';
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
export function formatDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
export interface LyricLine {
|
||||
time: number; // 秒
|
||||
time: number;
|
||||
text: string;
|
||||
translation?: string;
|
||||
}
|
||||
|
||||
export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
@ -20,11 +21,37 @@ export function parseLrc(lrcStr: string): LyricLine[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按时长排序
|
||||
result.sort((a, b) => a.time - b.time);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeTranslation(lyrics: LyricLine[], tLrcStr: string): LyricLine[] {
|
||||
if (!tLrcStr) return lyrics;
|
||||
const tLines = parseLrc(tLrcStr);
|
||||
if (tLines.length === 0) return lyrics;
|
||||
|
||||
const tMap = new Map<number, string>();
|
||||
for (const t of tLines) {
|
||||
const key = Math.round(t.time * 100);
|
||||
tMap.set(key, t.text);
|
||||
}
|
||||
|
||||
return lyrics.map(line => {
|
||||
const key = Math.round(line.time * 100);
|
||||
const translation = tMap.get(key);
|
||||
if (translation) {
|
||||
return { ...line, translation };
|
||||
}
|
||||
for (let offset = -3; offset <= 3; offset++) {
|
||||
const t = tMap.get(key + offset);
|
||||
if (t) {
|
||||
return { ...line, translation: t };
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number {
|
||||
let index = -1;
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
@ -35,4 +62,4 @@ export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number):
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,79 @@
|
||||
/**
|
||||
* 统一规范化歌曲对象,确保 al.picUrl、ar、dt 字段存在且合理
|
||||
*/
|
||||
export function normalizeSong(song: any) {
|
||||
const normalized = { ...song };
|
||||
// 封面 / 艺术家兼容
|
||||
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
|
||||
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
|
||||
export interface Song {
|
||||
id: number;
|
||||
name: string;
|
||||
ar: { id?: number; name: string }[];
|
||||
al: { id?: number; picUrl: string; name?: string };
|
||||
dt?: number;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
export function normalizeSong(song: any): Song {
|
||||
const al = {
|
||||
id: song.al?.id || song.album?.id,
|
||||
picUrl: song.al?.picUrl || song.album?.picUrl || '',
|
||||
name: song.al?.name || song.album?.name,
|
||||
};
|
||||
const ar = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
|
||||
let dt = song.dt || song.duration || 0;
|
||||
if (dt < 100 || dt > 7200000) dt = 0;
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
ar,
|
||||
al,
|
||||
dt,
|
||||
localPath: song.localPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCoverUrl(song: Song | null, sizeParam = ''): string {
|
||||
if (!song) return '';
|
||||
const raw = song.al?.picUrl || '';
|
||||
if (!raw) return '';
|
||||
if (!sizeParam || raw.startsWith('data:')) return raw;
|
||||
return raw + sizeParam;
|
||||
}
|
||||
|
||||
const colorCache = new Map<string, string>();
|
||||
|
||||
export function extractDominantColor(imageUrl: string): Promise<string> {
|
||||
if (colorCache.has(imageUrl)) {
|
||||
return Promise.resolve(colorCache.get(imageUrl)!);
|
||||
}
|
||||
if (!normalized.ar || normalized.ar.length === 0) {
|
||||
normalized.ar = normalized.artists || [];
|
||||
}
|
||||
// 时长:只保留合理的 dt(100ms ~ 2小时),否则置 0
|
||||
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
|
||||
normalized.dt = 0;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = 8;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve(''); return; }
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const data = ctx.getImageData(0, 0, size, size).data;
|
||||
|
||||
let r = 0, g = 0, b = 0, count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r += data[i];
|
||||
g += data[i + 1];
|
||||
b += data[i + 2];
|
||||
count++;
|
||||
}
|
||||
r = Math.round(r / count);
|
||||
g = Math.round(g / count);
|
||||
b = Math.round(b / count);
|
||||
|
||||
const color = `rgb(${r}, ${g}, ${b})`;
|
||||
colorCache.set(imageUrl, color);
|
||||
resolve(color);
|
||||
} catch {
|
||||
resolve('');
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve('');
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
160
src/views/AlbumDetail.vue
Normal file
160
src/views/AlbumDetail.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader />
|
||||
|
||||
<!-- 头部骨架 -->
|
||||
<div v-if="!album && albumLoading" class="flex gap-6 mb-8">
|
||||
<div class="w-44 h-44 rounded-xl bg-muted animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
|
||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 头部信息 -->
|
||||
<div v-else-if="album" class="flex gap-6 mb-8">
|
||||
<img :src="album.picUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||
<div class="flex flex-col justify-between min-w-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ album.name }}</h1>
|
||||
<div v-if="album.artists?.length" class="flex flex-wrap items-center gap-x-1 gap-y-0.5 mt-2 text-sm text-content-2">
|
||||
<template v-for="(ar, idx) in album.artists" :key="ar.id">
|
||||
<span v-if="(idx as number) > 0" class="text-content-3">/</span>
|
||||
<span
|
||||
class="hover:text-accent-text cursor-pointer transition whitespace-nowrap"
|
||||
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
|
||||
>{{ ar.name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-content-3 mt-2">
|
||||
{{ formatDate(album.publishTime) }} · {{ songs.length }} 首歌曲
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="fetchAlbum(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表骨架 -->
|
||||
<div v-else-if="songsLoading" class="space-y-1">
|
||||
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else-if="songs.length" class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onActivated } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
defineOptions({ name: 'AlbumDetailView' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
const album = ref<any>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const albumLoading = ref(true);
|
||||
const songsLoading = ref(false);
|
||||
const loadError = ref(false);
|
||||
|
||||
async function fetchAlbum(id: number, force = false) {
|
||||
const cacheKey = `album_${id}`;
|
||||
if (!force) {
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
album.value = cached.album;
|
||||
songs.value = cached.songs;
|
||||
albumLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
albumLoading.value = true;
|
||||
songsLoading.value = true;
|
||||
loadError.value = false;
|
||||
album.value = null;
|
||||
songs.value = [];
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.albumDetail(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
album.value = data.album;
|
||||
albumLoading.value = false;
|
||||
songs.value = (data.songs || []).map(normalizeSong);
|
||||
songsLoading.value = false;
|
||||
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = true;
|
||||
albumLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbum(Number(route.params.id));
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId && route.name === 'album') fetchAlbum(Number(newId));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) fetchAlbum(Number(route.params.id), true);
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
}
|
||||
</script>
|
||||
345
src/views/ArtistDetail.vue
Normal file
345
src/views/ArtistDetail.vue
Normal file
@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader />
|
||||
|
||||
<!-- 头部骨架 -->
|
||||
<div v-if="!artist && !songs.length && !albums.length" class="flex gap-6 mb-4">
|
||||
<div class="w-44 h-44 rounded-full bg-muted animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-7 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
||||
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="fetchArtist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
|
||||
<template v-if="!loadError">
|
||||
<!-- 头部:头像 + 简介 -->
|
||||
<div v-if="artist || songs.length || albums.length" class="flex gap-6 mb-4">
|
||||
<img v-if="artistCover" :src="artistCover" class="w-44 h-44 rounded-full object-cover shadow-lg flex-shrink-0" />
|
||||
<div v-else class="w-44 h-44 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<IconMusic class="w-12 h-12 text-content-4" />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ artistName }}</h1>
|
||||
<p v-if="artistFollowers || artist?.musicSize" class="text-xs text-content-3 mt-1">
|
||||
<span v-if="artistFollowers">{{ formatPlayCount(artistFollowers) }} 粉丝</span>
|
||||
<span v-if="artistFollowers && artist?.musicSize"> · </span>
|
||||
<span v-if="artist?.musicSize">{{ artist.musicSize }} 首歌曲</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="briefDesc" class="mt-3">
|
||||
<p
|
||||
ref="descEl"
|
||||
class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap overflow-hidden"
|
||||
style="max-height: 4.5em"
|
||||
>{{ briefDesc }}</p>
|
||||
<button
|
||||
v-if="descOverflow"
|
||||
@click="showDescModal = true"
|
||||
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1.5 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
||||
>
|
||||
<IconChevronDown class="w-3 h-3" />
|
||||
查看完整介绍
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-auto pt-4">
|
||||
<button
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
<button
|
||||
@click="toggleFollow"
|
||||
:disabled="followLoading"
|
||||
class="px-5 py-2 rounded-full font-medium transition flex items-center gap-2"
|
||||
:class="isFollowed
|
||||
? 'bg-subtle text-content-2 hover:bg-muted'
|
||||
: 'bg-accent/15 text-accent-text hover:bg-accent/25'"
|
||||
>
|
||||
{{ isFollowed ? '已关注' : '关注' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简介弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
||||
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
||||
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||
<h2 class="text-lg font-semibold">{{ artistName }} 的介绍</h2>
|
||||
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 内容区:热门歌曲 / 专辑 -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="px-4 py-1.5 rounded-full text-sm transition"
|
||||
:class="activeTab === tab.key ? 'bg-accent text-white' : 'bg-subtle text-content-2 hover:bg-muted'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-if="activeTab === 'songs'">
|
||||
<div v-if="songsLoading" class="space-y-1">
|
||||
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="songs.length" class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 专辑列表 -->
|
||||
<div v-if="activeTab === 'albums'">
|
||||
<div v-if="albumsLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 8" :key="i" class="bg-muted rounded-xl animate-pulse">
|
||||
<div class="w-full aspect-square"></div>
|
||||
<div class="p-3 space-y-2">
|
||||
<div class="h-4 bg-subtle rounded w-3/4"></div>
|
||||
<div class="h-3 bg-subtle rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="albums.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
@click="router.push({ name: 'album', params: { id: album.id } })"
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer"
|
||||
>
|
||||
<img :src="album.picUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
||||
<p class="text-xs text-content-2 mt-1">{{ formatDate(album.publishTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, onActivated, nextTick } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
|
||||
defineOptions({ name: 'ArtistDetailView' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const player = usePlayerStore();
|
||||
|
||||
const artist = ref<any>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const albums = ref<any[]>([]);
|
||||
const briefDesc = ref('');
|
||||
const loadError = ref(false);
|
||||
const songsLoading = ref(false);
|
||||
const albumsLoading = ref(false);
|
||||
const activeTab = ref('songs');
|
||||
const showDescModal = ref(false);
|
||||
const descOverflow = ref(false);
|
||||
const descEl = ref<HTMLElement | null>(null);
|
||||
const isFollowed = ref(false);
|
||||
const followLoading = ref(false);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'songs', label: '热门歌曲' },
|
||||
{ key: 'albums', label: '专辑' },
|
||||
];
|
||||
|
||||
const artistName = computed(() => {
|
||||
if (artist.value?.name) return artist.value.name;
|
||||
if (songs.value.length > 0 && songs.value[0].ar?.length > 0) return songs.value[0].ar[0].name;
|
||||
if (albums.value.length > 0) return albums.value[0].artist?.name || albums.value[0].artists?.[0]?.name || '';
|
||||
return '未知歌手';
|
||||
});
|
||||
|
||||
const artistCover = computed(() => {
|
||||
if (artist.value?.cover) return artist.value.cover;
|
||||
if (artist.value?.picUrl) return artist.value.picUrl;
|
||||
if (artist.value?.img1v1Url) return artist.value.img1v1Url;
|
||||
return '';
|
||||
});
|
||||
|
||||
const artistFollowers = computed(() => {
|
||||
if (!artist.value) return 0;
|
||||
return artist.value.followeds || artist.value.followCount || artist.value.fans || 0;
|
||||
});
|
||||
|
||||
function checkDescOverflow() {
|
||||
nextTick(() => {
|
||||
if (descEl.value) {
|
||||
descOverflow.value = descEl.value.scrollHeight > descEl.value.clientHeight + 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchArtist(id: number, force = false) {
|
||||
const cacheKey = `artist_${id}`;
|
||||
if (!force) {
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
artist.value = cached.artist;
|
||||
songs.value = cached.songs;
|
||||
albums.value = cached.albums;
|
||||
briefDesc.value = cached.briefDesc;
|
||||
loadError.value = false;
|
||||
checkDescOverflow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loadError.value = false;
|
||||
artist.value = null;
|
||||
songs.value = [];
|
||||
albums.value = [];
|
||||
briefDesc.value = '';
|
||||
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistDetail(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const a = data.artist || data.data?.artist || data;
|
||||
artist.value = a;
|
||||
} catch { /* 忽略 */ }
|
||||
};
|
||||
|
||||
const loadFollowStatus = async () => {
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistSublist(100, 0);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const list = data.data || [];
|
||||
isFollowed.value = list.some((item: any) => item.id === id);
|
||||
} catch { /* 忽略 */ }
|
||||
};
|
||||
|
||||
const loadSongs = async () => {
|
||||
songsLoading.value = true;
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistSongs({ id, order: 'hot', limit: 50, offset: 0 });
|
||||
const data = JSON.parse(jsonStr);
|
||||
songs.value = (data.songs || []).map(normalizeSong);
|
||||
} catch { /* 忽略 */ }
|
||||
finally { songsLoading.value = false; }
|
||||
};
|
||||
|
||||
const loadAlbums = async () => {
|
||||
albumsLoading.value = true;
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistAlbum(id, 30, 0);
|
||||
const data = JSON.parse(jsonStr);
|
||||
albums.value = data.hotAlbums || [];
|
||||
} catch { /* 忽略 */ }
|
||||
finally { albumsLoading.value = false; }
|
||||
};
|
||||
|
||||
const loadDesc = async () => {
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistDesc(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.briefDesc) {
|
||||
briefDesc.value = data.briefDesc;
|
||||
} else if (Array.isArray(data.introduction) && data.introduction.length > 0) {
|
||||
briefDesc.value = data.introduction.map((item: any) => item.txt || '').filter(Boolean).join('\n');
|
||||
}
|
||||
checkDescOverflow();
|
||||
} catch { /* 忽略 */ }
|
||||
};
|
||||
|
||||
await Promise.allSettled([loadDetail(), loadSongs(), loadAlbums(), loadDesc(), loadFollowStatus()]);
|
||||
|
||||
if (!artist.value && !songs.value.length && !albums.value.length && !briefDesc.value) {
|
||||
loadError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pageCacheSet(cacheKey, { artist: artist.value, songs: songs.value, albums: albums.value, briefDesc: briefDesc.value });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchArtist(Number(route.params.id));
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId && route.name === 'artist') fetchArtist(Number(newId));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) fetchArtist(Number(route.params.id), true);
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
const id = Number(route.params.id);
|
||||
if (!id || followLoading.value) return;
|
||||
followLoading.value = true;
|
||||
try {
|
||||
await MusicApi.artistSub(id, !isFollowed.value);
|
||||
isFollowed.value = !isFollowed.value;
|
||||
} catch (e) {
|
||||
console.error('关注操作失败', e);
|
||||
} finally {
|
||||
followLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
436
src/views/CloudMusic.vue
Normal file
436
src/views/CloudMusic.vue
Normal file
@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">音乐云盘</h1>
|
||||
<span v-if="totalCount" class="text-xs text-content-3">{{ totalCount }} 首</span>
|
||||
<template #actions>
|
||||
<button
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
@click="pickAndUpload"
|
||||
:disabled="uploading"
|
||||
class="px-3 py-1 bg-accent/15 text-accent-text hover:bg-accent/25 rounded-full text-xs transition disabled:opacity-50"
|
||||
>
|
||||
{{ uploading ? '上传中...' : '上传歌曲' }}
|
||||
</button>
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="uploading && uploadProgress < 100" class="flex items-center gap-2 text-xs text-content-3">
|
||||
<div class="w-24 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: uploadProgress + '%' }"></div>
|
||||
</div>
|
||||
<span>{{ uploadProgress.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- 存储空间 -->
|
||||
<div v-if="cloudSize > 0" class="mb-6 p-4 bg-subtle rounded-xl">
|
||||
<div class="flex items-center justify-between text-xs mb-2">
|
||||
<span class="text-content-2">已使用 {{ formatFileSize(cloudSize) }} / {{ formatFileSize(cloudMaxSize) }}</span>
|
||||
<span class="text-content-3">{{ cloudUsagePercent }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: cloudUsagePercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!userStore.isLoggedIn" class="text-content-3 py-8">
|
||||
请先登录后查看云盘音乐
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading && !songs.length" class="space-y-1">
|
||||
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="refresh" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!songs.length" class="text-content-3 py-8">云盘中暂无音乐</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(cloudData[index]?.fileSize || 0) }}</span>
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
@click.stop="toggleMenu(song.id)"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="更多"
|
||||
>
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<Transition name="fade">
|
||||
<div v-if="openMenuId === song.id" class="absolute right-0 top-full mt-1 w-40 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
|
||||
<button @click="showDetail(index)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-content-2 hover:bg-subtle transition">
|
||||
<IconInfo style="font-size: 14px" />
|
||||
查看详情
|
||||
</button>
|
||||
<button @click="confirmDelete(song)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
|
||||
<IconTrash2 style="font-size: 14px" />
|
||||
从云盘删除
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore && songs.length" class="flex justify-center py-6">
|
||||
<button
|
||||
@click="loadMore"
|
||||
:disabled="loadingMore"
|
||||
class="px-6 py-2 bg-subtle hover:bg-muted rounded-full text-sm transition disabled:opacity-50"
|
||||
>
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showDetailModal && detailData" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDetailModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[380px] p-6 select-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold truncate pr-4">{{ detailData.songName }}</h2>
|
||||
<button @click="showDetailModal = false" class="text-content-3 hover:text-content transition flex-shrink-0">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">文件名</span>
|
||||
<span class="text-content-2 text-right max-w-[220px] truncate" :title="detailData.fileName">{{ detailData.fileName }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">歌手</span>
|
||||
<span class="text-content-2">{{ detailData.artist }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">专辑</span>
|
||||
<span class="text-content-2">{{ detailData.album }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">文件大小</span>
|
||||
<span class="text-content-2">{{ formatFileSize(detailData.fileSize) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">比特率</span>
|
||||
<span class="text-content-2">{{ detailData.bitrate ? (detailData.bitrate / 1000) + ' kbps' : '未知' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-content-3">上传时间</span>
|
||||
<span class="text-content-2">{{ detailData.addTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
|
||||
<p class="text-sm text-content-2 mb-5">确定要从云盘删除「{{ deleteTarget?.name }}」吗?</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showDeleteConfirm = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
<button @click="doDelete"
|
||||
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, onBeforeUnmount } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { formatFileSize } from '../composables/useLocalMusic';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconInfo from '~icons/lucide/info';
|
||||
import IconTrash2 from '~icons/lucide/trash-2';
|
||||
import IconX from '~icons/lucide/x';
|
||||
|
||||
defineOptions({ name: 'CloudMusicView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
interface CloudItem {
|
||||
songId: number;
|
||||
fileSize: number;
|
||||
fileName: string;
|
||||
bitrate: number;
|
||||
addTime: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
songName: string;
|
||||
}
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const cloudData = ref<CloudItem[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const loadError = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const totalCount = ref(0);
|
||||
const openMenuId = ref<number | null>(null);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const showDetailModal = ref(false);
|
||||
const detailData = ref<CloudItem | null>(null);
|
||||
const deleteTarget = ref<Song | null>(null);
|
||||
const cloudSize = ref(0);
|
||||
const cloudMaxSize = ref(0);
|
||||
const uploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
|
||||
const cloudUsagePercent = computed(() => {
|
||||
if (cloudMaxSize.value === 0) return 0;
|
||||
return Math.min(100, Math.round(cloudSize.value / cloudMaxSize.value * 100));
|
||||
});
|
||||
|
||||
const LIMIT = 30;
|
||||
let currentOffset = 0;
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
if (!ts) return '未知';
|
||||
return new Date(ts).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeMenu);
|
||||
// 监听上传进度事件
|
||||
listen<{ filename: string; progress: number; uploaded: number; total: number }>('cloud-upload-progress', (e) => {
|
||||
uploadProgress.value = e.payload.progress;
|
||||
}).then(fn => { unlistenProgress = fn; });
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeMenu);
|
||||
unlistenProgress?.();
|
||||
});
|
||||
|
||||
async function fetchCloud(offset = 0, append = false) {
|
||||
if (!userStore.isLoggedIn) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!append) {
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStr = await MusicApi.userCloud(LIMIT, offset);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const items = data.data || [];
|
||||
|
||||
const newSongs = items.map((item: any) => {
|
||||
const s = item.simpleSong || {};
|
||||
return normalizeSong({
|
||||
...s,
|
||||
id: s.id || item.songId,
|
||||
name: s.name || item.fileName,
|
||||
ar: s.ar || (item.artist ? [{ name: item.artist }] : []),
|
||||
al: s.al || { name: item.album || '未知专辑' },
|
||||
dt: s.dt || item.duration,
|
||||
});
|
||||
});
|
||||
|
||||
const newCloudData: CloudItem[] = items.map((item: any) => ({
|
||||
songId: item.songId,
|
||||
fileSize: item.fileSize || 0,
|
||||
fileName: item.fileName || '',
|
||||
bitrate: item.bitrate || 0,
|
||||
addTime: formatTimestamp(item.addTime),
|
||||
artist: item.artist || (item.simpleSong?.ar || []).map((a: any) => a.name).join(' / ') || '未知歌手',
|
||||
album: item.album || item.simpleSong?.al?.name || '未知专辑',
|
||||
songName: item.simpleSong?.name || item.fileName?.replace(/\.\w+$/, '') || '未知歌曲',
|
||||
}));
|
||||
|
||||
if (append) {
|
||||
songs.value = [...songs.value, ...newSongs];
|
||||
cloudData.value = [...cloudData.value, ...newCloudData];
|
||||
} else {
|
||||
songs.value = newSongs;
|
||||
cloudData.value = newCloudData;
|
||||
}
|
||||
|
||||
totalCount.value = data.count || songs.value.length;
|
||||
currentOffset = offset + items.length;
|
||||
hasMore.value = songs.value.length < totalCount.value;
|
||||
cloudSize.value = data.size || 0;
|
||||
cloudMaxSize.value = data.maxSize || 0;
|
||||
|
||||
if (!append) {
|
||||
pageCacheSet('cloudMusic', {
|
||||
songs: songs.value, cloudData: cloudData.value, totalCount: totalCount.value,
|
||||
hasMore: hasMore.value, offset: currentOffset,
|
||||
cloudSize: cloudSize.value, cloudMaxSize: cloudMaxSize.value,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!append) loadError.value = true;
|
||||
else showToast('加载更多失败', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
pageCacheInvalidate('cloudMusic');
|
||||
currentOffset = 0;
|
||||
fetchCloud(0, false);
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
fetchCloud(currentOffset, true);
|
||||
}
|
||||
|
||||
async function pickAndUpload() {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
filters: [{ name: '音频文件', extensions: ['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a'] }],
|
||||
title: '选择要上传的歌曲',
|
||||
});
|
||||
if (!selected) return;
|
||||
|
||||
const paths = Array.isArray(selected) ? selected : [selected];
|
||||
uploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
for (const filePath of paths) {
|
||||
uploadProgress.value = 0;
|
||||
try {
|
||||
await MusicApi.cloudUpload(filePath);
|
||||
showToast('上传成功', 'success');
|
||||
} catch (e: any) {
|
||||
showToast(`上传失败: ${e || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
// 等待服务端完全提交后再刷新列表
|
||||
setTimeout(() => refresh(), 1000);
|
||||
}
|
||||
|
||||
function showDetail(index: number) {
|
||||
openMenuId.value = null;
|
||||
detailData.value = cloudData.value[index] || null;
|
||||
showDetailModal.value = true;
|
||||
}
|
||||
|
||||
function confirmDelete(song: Song) {
|
||||
openMenuId.value = null;
|
||||
deleteTarget.value = song;
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
try {
|
||||
await MusicApi.userCloudDel(deleteTarget.value.id);
|
||||
const targetId = deleteTarget.value.id;
|
||||
const idx = songs.value.findIndex(s => s.id === targetId);
|
||||
songs.value = songs.value.filter(s => s.id !== targetId);
|
||||
if (idx !== -1) cloudData.value.splice(idx, 1);
|
||||
totalCount.value = Math.max(0, totalCount.value - 1);
|
||||
pageCacheInvalidate('cloudMusic');
|
||||
showToast('已从云盘删除', 'success');
|
||||
} catch {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
showDeleteConfirm.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
const cached = pageCacheGet('cloudMusic');
|
||||
if (cached) {
|
||||
songs.value = cached.songs;
|
||||
cloudData.value = cached.cloudData;
|
||||
totalCount.value = cached.totalCount;
|
||||
hasMore.value = cached.hasMore;
|
||||
currentOffset = cached.offset;
|
||||
cloudSize.value = cached.cloudSize || 0;
|
||||
cloudMaxSize.value = cached.cloudMaxSize || 0;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
fetchCloud();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,56 +1,107 @@
|
||||
<template>
|
||||
<div class="p-8 text-white">
|
||||
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
|
||||
← 返回
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold mb-6">每日推荐</h1>
|
||||
<div v-if="loading" class="text-gray-400">加载中...</div>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">每日推荐</h1>
|
||||
<button
|
||||
v-if="songs.length > 0"
|
||||
@click="player.playAll(songs)"
|
||||
class="px-4 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition text-sm"
|
||||
>
|
||||
播放全部
|
||||
</button>
|
||||
</PageHeader>
|
||||
<div v-if="loading" class="space-y-1">
|
||||
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="player.play(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
|
||||
>
|
||||
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
|
||||
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
|
||||
</div>
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="isCurrentSong(song.id)"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="isCurrentSong(song.id) ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
|
||||
defineOptions({ name: 'DailySongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const songs = ref<any[]>([]);
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function loadData(force = false) {
|
||||
if (!force) {
|
||||
const cached = pageCacheGet('dailySongs');
|
||||
if (cached) {
|
||||
songs.value = cached;
|
||||
loading.value = false;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const jsonStr: string = await invoke('recommend_songs');
|
||||
loadError.value = false;
|
||||
const jsonStr: string = await MusicApi.recommendSongs();
|
||||
const data = JSON.parse(jsonStr);
|
||||
songs.value = data.data?.dailySongs || [];
|
||||
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
|
||||
pageCacheSet('dailySongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) {
|
||||
loadData(true);
|
||||
} else if (pageCacheIsStale('dailySongs')) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const sec = Math.floor(ms / 1000);
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
watch(() => navigator.onLine, (val, old) => {
|
||||
if (val && !old && songs.value.length === 0) {
|
||||
pageCacheInvalidate('dailySongs');
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,16 +1,56 @@
|
||||
<template>
|
||||
<div class="p-8 text-white">
|
||||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||||
<div class="p-8 text-content" @click="showSuggestions = false">
|
||||
<div class="relative mb-6" @click.stop>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<IconSearch class="absolute left-3.5 top-1/2 -translate-y-1/2 text-content-3 w-[18px] h-[18px]" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="keyword"
|
||||
@input="onInputChange"
|
||||
@keydown.enter="handleSearch"
|
||||
@focus="onInputFocus"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="w-full rounded-xl bg-muted pl-10 pr-10 py-3 text-content placeholder-content-3 outline-none focus:bg-subtle focus:ring-1 focus:ring-accent/30 transition"
|
||||
/>
|
||||
<button v-if="keyword" @click="clearSearch" class="absolute right-3 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<input
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="mb-4 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
|
||||
/>
|
||||
<div v-if="showSuggestions && !hasSearched"
|
||||
class="absolute z-30 left-0 right-0 top-full mt-2 bg-surface border border-line-2 rounded-xl shadow-xl overflow-hidden max-h-[60vh] overflow-y-auto">
|
||||
<div v-if="suggestions.length" class="p-2">
|
||||
<p class="text-xs text-content-3 px-3 py-1.5">搜索建议</p>
|
||||
<button v-for="s in suggestions" :key="s" @click="searchTag(s)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconSearch style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ s }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchHistory.length && !suggestions.length" class="p-2">
|
||||
<div class="flex items-center justify-between px-3 py-1.5">
|
||||
<p class="text-xs text-content-3">搜索历史</p>
|
||||
<button @click.stop="clearHistory" class="text-xs text-content-3 hover:text-danger transition">清空</button>
|
||||
</div>
|
||||
<button v-for="h in searchHistory" :key="h" @click="searchTag(h)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconHistory style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ h }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="hotTags.length && !suggestions.length && !searchHistory.length" class="p-2">
|
||||
<p class="text-xs text-content-3 px-3 py-1.5">热门搜索</p>
|
||||
<button v-for="tag in hotTags" :key="tag.searchWord" @click="searchTag(tag.searchWord)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconClock style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ tag.searchWord }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门搜索标签(仅在没有搜索且未显示结果时出现) -->
|
||||
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@ -18,40 +58,84 @@
|
||||
v-for="tag in hotTags"
|
||||
:key="tag.searchWord"
|
||||
@click="searchTag(tag.searchWord)"
|
||||
class="px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 cursor-pointer transition text-sm"
|
||||
class="px-3 py-1.5 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
|
||||
>
|
||||
{{ tag.searchWord }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出设备选择 -->
|
||||
<!-- <div class="mb-4">
|
||||
<label class="mr-2 text-sm text-gray-400">输出设备:</label>
|
||||
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1 text-sm">
|
||||
<option :value="null">跟随系统默认</option>
|
||||
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div> -->
|
||||
<div v-if="hasSearched">
|
||||
<div class="flex items-center gap-1 mb-4 bg-muted rounded-lg p-1 w-fit">
|
||||
<button v-for="tab in tabs" :key="tab.type" @click="switchTab(tab.type)"
|
||||
:class="['px-4 py-1.5 rounded-md text-sm font-medium transition', activeTab === tab.type ? 'bg-surface text-content shadow-sm' : 'text-content-2 hover:text-content']">
|
||||
{{ tab.label }}
|
||||
<span v-if="resultCache.has(tab.type) && resultCache.get(tab.type)!.count > 0" class="text-xs text-content-3 ml-1">{{ resultCache.get(tab.type)!.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="loading" class="text-gray-400">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="song in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition"
|
||||
>
|
||||
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
||||
<div>
|
||||
<p class="font-medium">{{ song.name }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
</p>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-end gap-1 h-6">
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0s"></span>
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.12s"></span>
|
||||
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.24s"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 1">
|
||||
<div v-if="currentResults.length" class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in currentResults"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
show-download
|
||||
show-menu
|
||||
cover-size="w-12 h-12"
|
||||
container-class="bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(currentResults, index)"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌曲' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 100">
|
||||
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="artist in currentResults" :key="artist.id" @click="router.push({ name: 'artist', params: { id: artist.id } })"
|
||||
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl p-4 cursor-pointer transition flex items-center gap-3">
|
||||
<img v-if="artist.picUrl" :src="artist.picUrl + '?param=100y100'" class="w-14 h-14 rounded-full object-cover flex-shrink-0" />
|
||||
<div v-else class="w-14 h-14 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<IconUserRound class="w-5 h-5 text-content-3" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ artist.name }}</p>
|
||||
<p v-if="artist.alias?.length" class="text-xs text-content-3 truncate">{{ artist.alias[0] }}</p>
|
||||
<p v-if="artist.musicSize" class="text-xs text-content-3">{{ artist.musicSize }} 首歌曲</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌手' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 10">
|
||||
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div v-for="album in currentResults" :key="album.id" @click="router.push({ name: 'album', params: { id: album.id } })"
|
||||
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl overflow-hidden cursor-pointer transition">
|
||||
<img v-if="album.picUrl" :src="album.picUrl + '?param=200y200'" class="w-full aspect-square object-cover" />
|
||||
<div v-else class="w-full aspect-square bg-muted flex items-center justify-center">
|
||||
<IconDisc class="w-8 h-8 text-content-3" />
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ album.name }}</p>
|
||||
<p class="text-xs text-content-2 truncate mt-0.5">{{ album.artist?.name || '' }}</p>
|
||||
<p v-if="album.publishTime" class="text-xs text-content-3 mt-0.5">{{ formatDate(album.publishTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关专辑' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -59,35 +143,127 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DiscoverView' });
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconSearch from '~icons/lucide/search';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconHistory from '~icons/lucide/history';
|
||||
import IconClock from '~icons/lucide/clock';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
import IconDisc from '~icons/lucide/disc';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const keyword = ref('');
|
||||
const results = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const hotTags = ref<any[]>([]);
|
||||
const suggestions = ref<string[]>([]);
|
||||
const showSuggestions = ref(false);
|
||||
const activeTab = ref(1);
|
||||
const cacheError = ref(false);
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
interface CacheEntry {
|
||||
data: Song[] | any[];
|
||||
count: number;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const resultCache = ref<Map<number, CacheEntry>>(new Map());
|
||||
const lastSearchKeyword = ref('');
|
||||
|
||||
const currentResults = computed(() => {
|
||||
const entry = resultCache.value.get(activeTab.value);
|
||||
return entry ? entry.data : [];
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ type: 1, label: '歌曲' },
|
||||
{ type: 100, label: '歌手' },
|
||||
{ type: 10, label: '专辑' },
|
||||
];
|
||||
|
||||
const HISTORY_KEY = 'search_history';
|
||||
const MAX_HISTORY = 15;
|
||||
|
||||
function loadSearchHistory(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* 忽略 */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveSearchHistory(q: string) {
|
||||
let history = loadSearchHistory();
|
||||
history = history.filter(h => h !== q);
|
||||
history.unshift(q);
|
||||
if (history.length > MAX_HISTORY) history = history.slice(0, MAX_HISTORY);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
const searchHistory = ref<string[]>(loadSearchHistory());
|
||||
|
||||
function clearHistory() {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onInputChange() {
|
||||
if (suggestTimer) clearTimeout(suggestTimer);
|
||||
if (!keyword.value.trim()) {
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = true;
|
||||
return;
|
||||
}
|
||||
suggestTimer = setTimeout(async () => {
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.searchSuggest(keyword.value.trim());
|
||||
const data = JSON.parse(jsonStr);
|
||||
const all = data.result?.allMatch || [];
|
||||
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
|
||||
showSuggestions.value = true;
|
||||
} catch {
|
||||
suggestions.value = [];
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onInputFocus() {
|
||||
if (!hasSearched.value) {
|
||||
showSuggestions.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotTags() {
|
||||
const cached = pageCacheGet('discover_hotTags');
|
||||
if (cached) {
|
||||
hotTags.value = cached;
|
||||
} else {
|
||||
try {
|
||||
const json = await MusicApi.getHotSearch();
|
||||
const data = JSON.parse(json as string);
|
||||
hotTags.value = (data.data || []).slice(0, 12);
|
||||
pageCacheSet('discover_hotTags', hotTags.value);
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 获取输出设备列表
|
||||
try { devices.value = await invoke('get_output_devices'); } catch {}
|
||||
|
||||
// 获取热门搜索
|
||||
try {
|
||||
const json = await invoke('get_hot_search');
|
||||
const data = JSON.parse(json as string);
|
||||
hotTags.value = (data.data || []).slice(0, 12);
|
||||
} catch {}
|
||||
|
||||
// 检查路由是否有查询关键词,自动搜索
|
||||
await loadHotTags();
|
||||
const q = route.query.q as string;
|
||||
if (q) {
|
||||
keyword.value = q;
|
||||
@ -96,28 +272,99 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
|
||||
const q = route.query.q as string;
|
||||
if (q && q !== lastSearchKeyword.value) {
|
||||
keyword.value = q;
|
||||
await handleSearch();
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && hotTags.value.length === 0) {
|
||||
pageCacheInvalidate('discover_hotTags');
|
||||
loadHotTags();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
const q = keyword.value.trim();
|
||||
if (!q) return;
|
||||
showSuggestions.value = false;
|
||||
hasSearched.value = true;
|
||||
cacheError.value = false;
|
||||
saveSearchHistory(q);
|
||||
searchHistory.value = loadSearchHistory();
|
||||
|
||||
if (q === lastSearchKeyword.value && resultCache.value.size > 0) return;
|
||||
|
||||
lastSearchKeyword.value = q;
|
||||
resultCache.value.clear();
|
||||
|
||||
await Promise.all([
|
||||
fetchTabResults(1),
|
||||
fetchTabResults(100),
|
||||
fetchTabResults(10),
|
||||
]);
|
||||
}
|
||||
|
||||
async function fetchTabResults(type: number) {
|
||||
const entry = resultCache.value.get(type);
|
||||
if (entry && !entry.dirty) return;
|
||||
|
||||
loading.value = true;
|
||||
cacheError.value = false;
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||||
const jsonStr: string = await MusicApi.cloudsearch({
|
||||
keyword: lastSearchKeyword.value, searchType: type, limit: 30
|
||||
});
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = data.result?.songs || [];
|
||||
const result = data.result || {};
|
||||
|
||||
let items: any[] = [];
|
||||
if (type === 1) {
|
||||
items = (result.songs || []).map(normalizeSong);
|
||||
} else if (type === 100) {
|
||||
items = result.artists || [];
|
||||
} else if (type === 10) {
|
||||
items = result.albums || [];
|
||||
}
|
||||
|
||||
resultCache.value.set(type, { data: items, count: items.length, dirty: false });
|
||||
} catch (e) {
|
||||
console.error('搜索出错:', e);
|
||||
resultCache.value.set(type, { data: [], count: 0, dirty: true });
|
||||
cacheError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTab(type: number) {
|
||||
if (type === activeTab.value) return;
|
||||
activeTab.value = type;
|
||||
|
||||
const entry = resultCache.value.get(type);
|
||||
if (!entry || entry.dirty) {
|
||||
await fetchTabResults(type);
|
||||
}
|
||||
}
|
||||
|
||||
function searchTag(tag: string) {
|
||||
keyword.value = tag;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
async function playSong(song: any) {
|
||||
player.play(song);
|
||||
function clearSearch() {
|
||||
keyword.value = '';
|
||||
hasSearched.value = false;
|
||||
resultCache.value.clear();
|
||||
lastSearchKeyword.value = '';
|
||||
cacheError.value = false;
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = true;
|
||||
nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
177
src/views/DownloadedMusic.vue
Normal file
177
src/views/DownloadedMusic.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">下载音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<template #actions>
|
||||
<button
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" class="space-y-1">
|
||||
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-3">
|
||||
暂无下载音乐
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in normalizedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(normalizedSongs, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
@click.stop="toggleMenu(songs[index].id)"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="更多"
|
||||
>
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<Transition name="fade">
|
||||
<div v-if="openMenuId === songs[index].id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
|
||||
<button @click="confirmDelete(songs[index])" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
|
||||
<IconTrash2 style="font-size: 14px" />
|
||||
从磁盘中删除
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
|
||||
<p class="text-sm text-content-2 mb-5">确定要删除「{{ deleteTarget?.name }}」吗?此操作不可撤销。</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showDeleteConfirm = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
<button @click="doDelete"
|
||||
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
|
||||
import { DownloadApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconTrash2 from '~icons/lucide/trash-2';
|
||||
|
||||
defineOptions({ name: 'DownloadedMusicView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const songs = ref<LocalSong[]>([]);
|
||||
const loading = ref(true);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const deleteTarget = ref<LocalSong | null>(null);
|
||||
const openMenuId = ref<number | null>(null);
|
||||
|
||||
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => { document.addEventListener('click', closeMenu); });
|
||||
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await DownloadApi.listLocalSongs(settings.downloadPath || null);
|
||||
songs.value = list;
|
||||
pageCacheSet('downloadedMusic', list);
|
||||
fetchMissingCovers(songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('downloadedMusic')) refresh();
|
||||
});
|
||||
|
||||
watch(() => settings.downloadPath, () => { refresh(); });
|
||||
|
||||
function confirmDelete(song: LocalSong) {
|
||||
openMenuId.value = null;
|
||||
deleteTarget.value = song;
|
||||
showDeleteConfirm.value = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
try {
|
||||
await DownloadApi.deleteLocalSong({ id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null });
|
||||
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
|
||||
download.localSongIds.delete(deleteTarget.value.id);
|
||||
pageCacheInvalidate('downloadedMusic');
|
||||
showToast('已删除', 'success');
|
||||
} catch (e) {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
showDeleteConfirm.value = false;
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,123 @@
|
||||
<template>
|
||||
<div class="p-8 text-white">
|
||||
<h1 class="text-2xl font-bold mb-4">❤️ 我喜欢的音乐</h1>
|
||||
<p class="text-gray-400">正在施工...</p>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
|
||||
<button
|
||||
v-if="songs.length"
|
||||
@click="player.playAll(songs)"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
</PageHeader>
|
||||
<div v-if="!userStore.isLoggedIn" class="text-content-2">
|
||||
请先登录后查看喜欢的音乐
|
||||
</div>
|
||||
<div v-else-if="loading" class="space-y-1">
|
||||
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
|
||||
<div v-else class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
defineOptions({ name: 'FavoriteSongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
async function loadData(force = false) {
|
||||
if (!userStore.isLoggedIn) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
if (!force) {
|
||||
const cached = pageCacheGet('favoriteSongs');
|
||||
if (cached) {
|
||||
songs.value = cached;
|
||||
loading.value = false;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
loadError.value = false;
|
||||
const playlistJson: string = await MusicApi.userPlaylist(userStore.user!.userId);
|
||||
const playlistData = JSON.parse(playlistJson);
|
||||
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
|
||||
if (created.length === 0) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
const likePlaylistId = created[0].id;
|
||||
const trackJson: string = await MusicApi.playlistTrackAll(likePlaylistId);
|
||||
const trackData = JSON.parse(trackJson);
|
||||
songs.value = (trackData.songs || []).map(normalizeSong);
|
||||
pageCacheSet('favoriteSongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) {
|
||||
loadData(true);
|
||||
} else if (pageCacheIsStale('favoriteSongs')) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => navigator.onLine, (val, old) => {
|
||||
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
|
||||
pageCacheInvalidate('favoriteSongs');
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="p-8 text-white">
|
||||
<div class="p-8 text-content">
|
||||
<!-- 第一行:每日推荐 & 私人漫游 卡片 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
||||
<div class="grid grid-cols-2 gap-6 mb-10">
|
||||
<!-- 每日推荐 -->
|
||||
<div
|
||||
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
|
||||
@ -11,97 +11,97 @@
|
||||
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
|
||||
<h2 class="text-2xl font-bold">每日推荐</h2>
|
||||
<h2 class="text-2xl font-bold text-white">每日推荐</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">根据你的口味生成,每天 6:00 更新</p>
|
||||
<p class="text-xs text-white/60">根据你的口味生成,每天凌晨更新</p>
|
||||
</div>
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
|
||||
</div>
|
||||
|
||||
<!-- 私人漫游 卡片 -->
|
||||
<!-- 私人漫游 卡片 -->
|
||||
<div
|
||||
class="h-48 bg-gradient-to-br from-blue-600 to-cyan-500 rounded-3xl overflow-hidden relative group select-none"
|
||||
@click="!userStore.isLoggedIn ? goLogin() : null"
|
||||
>
|
||||
<!-- 模糊封面层(仅在有歌曲且有封面时显示,低透明度模糊) -->
|
||||
<div
|
||||
v-if="player.fmSong && fmCoverUrl"
|
||||
class="absolute inset-0 bg-cover bg-center opacity-30 blur-md scale-110"
|
||||
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
|
||||
></div>
|
||||
<!-- 遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition"></div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative z-10 h-full">
|
||||
<!-- 未登录 -->
|
||||
<div v-if="!userStore.isLoggedIn" class="flex flex-col items-center justify-center h-full">
|
||||
<p class="text-xs text-white/60 mb-1">🌀 一键探索</p>
|
||||
<h2 class="text-2xl font-bold">私人漫游</h2>
|
||||
<p class="text-xs text-white/60 mt-2">登录后即可开启沉浸式音乐探索</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录后:无歌曲 → 垂直居中播放按钮 -->
|
||||
<div
|
||||
v-else-if="!player.fmSong"
|
||||
class="flex flex-col items-center justify-center h-full gap-3 cursor-pointer"
|
||||
@click.stop="startFmPlay"
|
||||
>
|
||||
<p class="text-xs text-white/60">🌀 一键探索</p>
|
||||
<h2 class="text-2xl font-bold">私人漫游</h2>
|
||||
<button
|
||||
class="w-12 h-12 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition mt-2"
|
||||
<div
|
||||
class="h-48 rounded-3xl overflow-hidden relative group select-none cursor-pointer"
|
||||
:class="player.fmSong && fmCoverUrl ? '' : 'bg-gradient-to-br from-indigo-600 via-blue-600 to-cyan-500'"
|
||||
@click="onFmCardClick"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="player.fmSong && fmCoverUrl"
|
||||
class="absolute inset-0 bg-cover bg-center scale-110"
|
||||
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
|
||||
></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 group-hover:from-black/60 transition"></div>
|
||||
|
||||
<!-- 有歌曲 → 横向布局:左侧信息,右侧按钮 -->
|
||||
<div v-else class="flex items-center justify-between h-full px-6 cursor-pointer" @click.stop="player.toggleFm">
|
||||
<!-- 左侧:封面 + 歌曲信息 -->
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<img :src="fmCoverUrl" class="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold truncate">{{ fmDisplayName }}</p>
|
||||
<p class="text-xs text-white/70 truncate">{{ fmDisplayArtists }}</p>
|
||||
<div class="relative z-10 h-full flex flex-col justify-between p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconRadio class="w-4 h-4 text-white/50" />
|
||||
<span class="text-xs text-white/50 font-medium">私人漫游</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold text-white" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
|
||||
<h2 class="text-xl font-bold text-white" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
|
||||
<h2 class="text-lg font-bold truncate text-white" v-else>{{ fmDisplayName }}</h2>
|
||||
<p v-if="!userStore.isLoggedIn" class="text-xs text-white/50 mt-1">登录后开启沉浸式音乐探索</p>
|
||||
<p v-else-if="!player.fmSong" class="text-xs text-white/50 mt-1">根据你的喜好,为你推荐意想不到的好歌</p>
|
||||
<p v-else class="text-xs text-white/60 truncate mt-1">{{ fmDisplayArtists }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button v-if="userStore.isLoggedIn && !player.fmSong"
|
||||
@click.stop="startFmPlay"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
|
||||
<IconPlay class="w-4 h-4 fill-current text-white" />
|
||||
</button>
|
||||
<template v-if="player.fmSong">
|
||||
<button @click.stop="player.toggleFm"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
|
||||
<IconPause v-if="player.fmPlaying" class="w-[18px] h-[18px] fill-current text-white" />
|
||||
<IconPlay v-else class="w-[18px] h-[18px] fill-current text-white" />
|
||||
</button>
|
||||
<button @click.stop="player.nextFm"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition">
|
||||
<IconSkipForward class="w-[14px] h-[14px] text-white" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧:控制按钮 -->
|
||||
<div class="flex items-center gap-3 ml-4">
|
||||
<button @click.stop="player.toggleFm"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition"
|
||||
>
|
||||
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click.stop="player.nextFm" class="text-xl text-white/80 hover:text-white transition">⏭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20 pointer-events-none">🌊</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 第二行:为你推荐(需登录) -->
|
||||
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
|
||||
<div v-if="userStore.isLoggedIn" class="mb-10">
|
||||
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
|
||||
<!-- 加载中:骨架屏 -->
|
||||
<div v-if="recLoading && !recPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="i in 6" :key="'skel-'+i" class="bg-subtle rounded-xl overflow-hidden max-w-[220px] justify-self-center w-full animate-pulse">
|
||||
<div class="w-full aspect-square bg-muted"></div>
|
||||
<div class="p-3 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-else-if="recError && !recPlaylists.length" class="flex flex-col items-center justify-center py-12 gap-3">
|
||||
<p class="text-content-2 text-sm">推荐加载失败</p>
|
||||
<button @click="fetchRecPlaylists"
|
||||
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 正常内容 -->
|
||||
<div v-else-if="recPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer">
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer max-w-[220px] justify-self-center w-full">
|
||||
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ pl.copywriter || '' }}</p>
|
||||
<p class="text-xs text-content-2 mt-1 truncate">{{ pl.copywriter || pl.description || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -110,12 +110,34 @@
|
||||
<!-- 第三行:热门歌单(排行榜) -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
|
||||
<!-- 加载中:骨架屏 -->
|
||||
<div v-if="rankLoading && !rankPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="i in 4" :key="'rskel-'+i" class="bg-subtle rounded-xl overflow-hidden max-w-[220px] justify-self-center w-full animate-pulse">
|
||||
<div class="w-full aspect-square bg-muted"></div>
|
||||
<div class="p-3 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-else-if="rankError && !rankPlaylists.length" class="flex flex-col items-center justify-center py-12 gap-3">
|
||||
<p class="text-content-2 text-sm">热门歌单加载失败</p>
|
||||
<button @click="fetchRankPlaylists"
|
||||
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 正常内容 -->
|
||||
<div v-else-if="rankPlaylists.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer backdrop-blur-sm">
|
||||
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm max-w-[220px] justify-self-center w-full">
|
||||
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
|
||||
<p v-if="pl.description || pl.copywriter" class="text-xs text-content-2 mt-1 truncate">{{ pl.description || pl.copywriter }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -124,69 +146,157 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { getCoverUrl } from '../utils/song';
|
||||
|
||||
defineOptions({ name: 'HomeView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const rankPlaylists = ref<any[]>([]);
|
||||
const rankLoading = ref(false);
|
||||
const rankError = ref(false);
|
||||
const recPlaylists = ref<any[]>([]);
|
||||
const recLoading = ref(false);
|
||||
const recError = ref(false);
|
||||
const todayStr = ref('');
|
||||
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
|
||||
|
||||
import { computed } from 'vue';
|
||||
import IconRadio from '~icons/lucide/radio';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconPause from '~icons/lucide/pause';
|
||||
import IconSkipForward from '~icons/lucide/skip-forward';
|
||||
|
||||
|
||||
const fmCoverUrl = computed(() => {
|
||||
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || '';
|
||||
return getCoverUrl(player.fmSong) || '';
|
||||
});
|
||||
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
|
||||
const fmDisplayArtists = computed(() => {
|
||||
if (!player.fmSong) return '';
|
||||
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') ||
|
||||
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
|
||||
return player.fmSong.ar?.map((a: { name: string }) => a.name).join(' / ') || '';
|
||||
});
|
||||
|
||||
|
||||
// 首次点击播放按钮:开始 FM 并播放
|
||||
async function startFmPlay() {
|
||||
// 如果还没加载过 FM,或者之前加载了但被停止了,重新加载
|
||||
if (!player.fmSong) {
|
||||
await player.loadFm(); // loadFm 内部会设置 fmSong 并播放
|
||||
await player.loadFm();
|
||||
} else {
|
||||
// 已有歌曲但未播放状态(比如之前暂停/停止了),直接播放
|
||||
await player.toggleFm();
|
||||
}
|
||||
}
|
||||
|
||||
function onFmCardClick() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
if (!player.fmSong) {
|
||||
startFmPlay();
|
||||
return;
|
||||
}
|
||||
player.openRoamDrawer();
|
||||
}
|
||||
|
||||
async function fetchRankPlaylists() {
|
||||
const cacheKey = 'home_rank';
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
rankPlaylists.value = cached;
|
||||
return;
|
||||
}
|
||||
rankLoading.value = true;
|
||||
rankError.value = false;
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
|
||||
);
|
||||
rankPlaylists.value = results
|
||||
.filter(r => r.status === 'fulfilled')
|
||||
.map((r: any) => {
|
||||
const data = JSON.parse(r.value);
|
||||
return data.playlist;
|
||||
})
|
||||
.filter(Boolean);
|
||||
pageCacheSet(cacheKey, rankPlaylists.value);
|
||||
} catch {
|
||||
rankError.value = true;
|
||||
} finally {
|
||||
rankLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecPlaylists() {
|
||||
if (!userStore.isLoggedIn) return;
|
||||
const cacheKey = 'home_rec';
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
recPlaylists.value = cached;
|
||||
return;
|
||||
}
|
||||
recLoading.value = true;
|
||||
recError.value = false;
|
||||
try {
|
||||
const json = await MusicApi.recommendResource();
|
||||
const data = JSON.parse(json as string);
|
||||
recPlaylists.value = data.recommend || [];
|
||||
pageCacheSet(cacheKey, recPlaylists.value);
|
||||
} catch {
|
||||
recError.value = true;
|
||||
} finally {
|
||||
recLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const cached = pageCacheGet('home');
|
||||
if (cached) {
|
||||
rankPlaylists.value = cached.rankPlaylists || [];
|
||||
recPlaylists.value = cached.recPlaylists || [];
|
||||
return;
|
||||
}
|
||||
|
||||
fetchRankPlaylists();
|
||||
fetchRecPlaylists();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const d = new Date();
|
||||
todayStr.value = `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
await loadData();
|
||||
});
|
||||
|
||||
// 排行榜
|
||||
const results = await Promise.allSettled(
|
||||
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
|
||||
);
|
||||
rankPlaylists.value = results
|
||||
.filter(r => r.status === 'fulfilled')
|
||||
.map((r: any) => {
|
||||
const data = JSON.parse(r.value);
|
||||
return data.playlist;
|
||||
})
|
||||
.filter(Boolean);
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('home')) loadData();
|
||||
});
|
||||
|
||||
// 推荐歌单(需登录)
|
||||
if (userStore.isLoggedIn) {
|
||||
try {
|
||||
const json = await invoke('recommend_resource');
|
||||
const data = JSON.parse(json as string);
|
||||
recPlaylists.value = data.recommend || [];
|
||||
} catch { }
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old) {
|
||||
if (rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
|
||||
pageCacheInvalidate('home');
|
||||
pageCacheInvalidate('home_rank');
|
||||
pageCacheInvalidate('home_rec');
|
||||
loadData();
|
||||
} else {
|
||||
if (rankError.value) {
|
||||
pageCacheInvalidate('home_rank');
|
||||
fetchRankPlaylists();
|
||||
}
|
||||
if (recError.value) {
|
||||
pageCacheInvalidate('home_rec');
|
||||
fetchRecPlaylists();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
172
src/views/LocalMusic.vue
Normal file
172
src/views/LocalMusic.vue
Normal file
@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">本地音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<template #actions>
|
||||
<button
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
@click="showFolderModal = true"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="文件夹管理"
|
||||
>
|
||||
<IconEllipsis class="w-5 h-5 fill-current" />
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" class="space-y-1">
|
||||
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="settings.localMusicPaths.length === 0" class="text-content-3 py-4">
|
||||
请先添加要扫描的文件夹
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-3">
|
||||
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in normalizedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(normalizedSongs, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹管理弹窗 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showFolderModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFolderModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">扫描文件夹</h2>
|
||||
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="settings.localMusicPaths.length === 0" class="text-sm text-content-3 py-4 text-center">
|
||||
未添加任何文件夹
|
||||
</div>
|
||||
<div v-else class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
|
||||
<div
|
||||
v-for="p in settings.localMusicPaths"
|
||||
:key="p"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group"
|
||||
>
|
||||
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
|
||||
<span class="text-sm text-content-2 truncate flex-1" :title="p">{{ p }}</span>
|
||||
<button
|
||||
@click="settings.removeLocalMusicPath(p)"
|
||||
class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100"
|
||||
title="移除"
|
||||
>
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="addFolder"
|
||||
class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition"
|
||||
>
|
||||
添加文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, watch } from 'vue';
|
||||
import { DownloadApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconFolder from '~icons/lucide/folder';
|
||||
import IconX from '~icons/lucide/x';
|
||||
|
||||
defineOptions({ name: 'LocalMusicView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const songs = ref<LocalSong[]>([]);
|
||||
const loading = ref(true);
|
||||
const showFolderModal = ref(false);
|
||||
|
||||
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
|
||||
|
||||
async function addFolder() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: '选择音乐文件夹',
|
||||
});
|
||||
if (selected) {
|
||||
settings.addLocalMusicPath(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (settings.localMusicPaths.length === 0) {
|
||||
songs.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await DownloadApi.scanLocalFolders(settings.localMusicPaths);
|
||||
songs.value = list;
|
||||
pageCacheSet('localMusic', list);
|
||||
fetchMissingCovers(songs.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
onActivated(() => {
|
||||
if (pageCacheIsStale('localMusic')) refresh();
|
||||
});
|
||||
|
||||
watch(() => settings.localMusicPaths, () => { refresh(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,28 +1,26 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-950 text-white">
|
||||
<div class="bg-white/5 backdrop-blur-md border border-white/10 p-8 rounded-2xl w-full max-w-sm text-center">
|
||||
<div class="min-h-screen flex items-center justify-center bg-base text-content">
|
||||
<div class="bg-subtle backdrop-blur-md border border-line p-8 rounded-2xl w-full max-w-sm text-center">
|
||||
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
|
||||
<p class="text-sm text-gray-400 mb-6">请使用网易云音乐 App 扫描二维码</p>
|
||||
|
||||
<!-- 二维码展示区 -->
|
||||
<p class="text-sm text-content-2 mb-6">请使用网易云音乐 App 扫描二维码</p>
|
||||
|
||||
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
|
||||
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
|
||||
</div>
|
||||
<div v-else class="w-48 h-48 bg-white/5 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<span v-if="qrLoading" class="text-gray-400">加载中...</span>
|
||||
<span v-else-if="qrError" class="text-red-400 text-sm">{{ qrError }}</span>
|
||||
<div v-else class="w-48 h-48 bg-subtle rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<span v-if="qrLoading" class="text-content-2">加载中...</span>
|
||||
<span v-else-if="qrError" class="text-danger text-sm">{{ qrError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
|
||||
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
|
||||
<button @click="refreshQr" class="mt-4 text-xs text-green-400 hover:underline">重新获取二维码</button>
|
||||
<button @click="refreshQr" class="mt-4 text-xs text-accent-text hover:underline">重新获取二维码</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import QRCode from 'qrcode';
|
||||
@ -33,7 +31,7 @@ const qrimg = ref('');
|
||||
const qrLoading = ref(true);
|
||||
const qrError = ref('');
|
||||
const statusText = ref('等待扫码...');
|
||||
const statusColor = ref('text-gray-400');
|
||||
const statusColor = ref('text-content-2');
|
||||
let qrKey = '';
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@ -54,24 +52,20 @@ async function refreshQr() {
|
||||
qrError.value = '';
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
try {
|
||||
// 1. 获取 unikey
|
||||
qrKey = await invoke('get_qr_key');
|
||||
qrKey = await MusicApi.getQrKey();
|
||||
if (!qrKey) {
|
||||
qrError.value = '未获取到登录密钥';
|
||||
qrLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 拼接网易云标准扫码链接(无需 create_qr)
|
||||
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
|
||||
|
||||
// 3. 用 qrcode 生成二维码图片
|
||||
const canvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
|
||||
qrimg.value = canvas.toDataURL('image/png');
|
||||
qrLoading.value = false;
|
||||
|
||||
// 4. 开始轮询状态
|
||||
startPolling();
|
||||
} catch (e: any) {
|
||||
qrError.value = '获取二维码失败';
|
||||
@ -79,45 +73,26 @@ async function refreshQr() {
|
||||
}
|
||||
}
|
||||
|
||||
// 新增函数:用 Canvas 生成二维码并赋值给 qrimg
|
||||
// async function drawQrCode(url: string) {
|
||||
// try {
|
||||
// // 等待 DOM 准备好 canvas 元素
|
||||
// const canvas = document.createElement('canvas');
|
||||
// await QRCode.toCanvas(canvas, url, { width: 201, margin: 1 });
|
||||
// // 转为 data URL 赋值给响应式的图片地址
|
||||
// qrimg.value = canvas.toDataURL('image/png');
|
||||
// } catch (e) {
|
||||
// console.error('生成二维码失败', e);
|
||||
// qrError.value = '生成二维码失败';
|
||||
// }
|
||||
// }
|
||||
|
||||
function startPolling() {
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const jsonStr: string = await invoke('check_qr_status', { query: { key: qrKey } });
|
||||
const jsonStr: string = await MusicApi.checkQrStatus(qrKey);
|
||||
const data = JSON.parse(jsonStr);
|
||||
const code = data.code;
|
||||
if (code === 800) {
|
||||
statusText.value = '二维码已过期,请刷新';
|
||||
statusColor.value = 'text-red-400';
|
||||
statusColor.value = 'text-danger';
|
||||
clearInterval(pollTimer!);
|
||||
} else if (code === 801) {
|
||||
statusText.value = '等待扫码...';
|
||||
statusColor.value = 'text-gray-400';
|
||||
statusColor.value = 'text-content-2';
|
||||
} else if (code === 802) {
|
||||
statusText.value = '请在手机上确认登录';
|
||||
statusColor.value = 'text-yellow-400';
|
||||
} else if (code === 803) {
|
||||
// 登录成功
|
||||
clearInterval(pollTimer!);
|
||||
statusText.value = '登录成功!';
|
||||
statusColor.value = 'text-green-400';
|
||||
// 存储 cookie 到 NcmApi(后台线程中自动保留,后续请求都带登录态)
|
||||
// 获取用户信息(简化,可从 /login/status 获取)
|
||||
// 这里需要额外调用获取用户详情的 API,但因为 NcmApi 已有 cookie,可以直接在后台线程中添加
|
||||
// 暂时用简易方式:调用 /user/account 获取用户简档
|
||||
statusColor.value = 'text-accent-text';
|
||||
await fetchUserProfile();
|
||||
setTimeout(() => router.push('/'), 500);
|
||||
}
|
||||
@ -129,10 +104,7 @@ function startPolling() {
|
||||
|
||||
async function fetchUserProfile() {
|
||||
try {
|
||||
// 添加一个快速获取用户信息的命令(可复用之前的 login 命令中获取 profile 的逻辑)
|
||||
// 这里简化,由于后台 NcmApi 已有 cookie,我们可以直接用 reqwest 调 /user/account
|
||||
// 但最好添加一个新命令,这里直接调用现有的 login 逻辑不适用,因此我们在 Rust 侧添加一个 get_login_status 命令
|
||||
const profileJson: string = await invoke('get_login_status');
|
||||
const profileJson: string = await MusicApi.getLoginStatus();
|
||||
const profile = JSON.parse(profileJson);
|
||||
if (profile.profile) {
|
||||
userStore.setUser({
|
||||
@ -145,4 +117,4 @@ async function fetchUserProfile() {
|
||||
console.error('获取用户信息失败', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@ -1,91 +1,244 @@
|
||||
<template>
|
||||
<div class="p-8 text-white">
|
||||
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
|
||||
← 返回
|
||||
</button>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader />
|
||||
|
||||
<!-- 歌单信息 -->
|
||||
<div v-if="playlist" class="flex gap-6 mb-8">
|
||||
<img :src="playlist.coverImgUrl" class="w-40 h-40 rounded-xl object-cover shadow-lg" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ playlist.name }}</h1>
|
||||
<p class="text-sm text-gray-400 mt-2">{{ playlist.description }}</p>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
{{ playlist.trackCount }} 首歌曲 · 播放 {{ playlist.playCount }} 次
|
||||
</p>
|
||||
<button
|
||||
@click="playAll"
|
||||
class="mt-4 px-4 py-2 bg-green-500 hover:bg-green-600 rounded-full text-white font-medium transition"
|
||||
>
|
||||
播放全部
|
||||
</button>
|
||||
<!-- 头部骨架 -->
|
||||
<div v-if="!playlist && playlistLoading" class="flex gap-6 mb-8">
|
||||
<div class="w-44 h-44 rounded-xl bg-muted animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
|
||||
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="text-gray-400">加载中...</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
@click="playSingle(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
|
||||
>
|
||||
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
<!-- 头部信息 -->
|
||||
<div v-else-if="playlist" class="flex gap-6 mb-8">
|
||||
<img :src="playlist.coverImgUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
|
||||
<div class="flex flex-col justify-between min-w-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ playlist.name }}</h1>
|
||||
<div v-if="playlist.creator" class="flex items-center gap-2 mt-2">
|
||||
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
|
||||
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
|
||||
</div>
|
||||
<div v-if="playlist.description" class="mt-2">
|
||||
<p
|
||||
ref="descEl"
|
||||
class="text-sm text-content-2 leading-relaxed overflow-hidden"
|
||||
style="max-height: 3em"
|
||||
>{{ playlist.description }}</p>
|
||||
<button
|
||||
v-if="descOverflow"
|
||||
@click="showDescModal = true"
|
||||
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
|
||||
>
|
||||
<IconChevronDown class="w-3 h-3" />
|
||||
查看完整介绍
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-content-3 mt-2">
|
||||
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }} 次
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
@click="playAll"
|
||||
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<IconPlay class="w-4 h-4 fill-current" />
|
||||
播放全部
|
||||
</button>
|
||||
<button
|
||||
v-if="!isOwnPlaylist"
|
||||
@click="toggleSubscribe"
|
||||
class="px-4 py-2 bg-muted hover:bg-emphasis rounded-full text-sm transition flex items-center gap-2"
|
||||
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
|
||||
>
|
||||
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
|
||||
{{ subscribed ? '已收藏' : '收藏歌单' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简介弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
|
||||
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
|
||||
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||
<h2 class="text-lg font-semibold">{{ playlist?.name }} 的介绍</h2>
|
||||
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ playlist?.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="fetchPlaylist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表骨架 -->
|
||||
<div v-else-if="songsLoading" class="space-y-1">
|
||||
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else-if="songs.length" class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!songsLoading && !loadError" class="text-content-2">暂无歌曲</div>
|
||||
|
||||
<div v-if="playlist" class="mt-8">
|
||||
<CommentSection :type="2" :id="Number(route.params.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, watch, onActivated, nextTick } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { formatPlayCount } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconBookmark from '~icons/lucide/bookmark';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
|
||||
defineOptions({ name: 'PlaylistDetailView' });
|
||||
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const playlist = ref<any>(null);
|
||||
const songs = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const songs = ref<Song[]>([]);
|
||||
const playlistLoading = ref(true);
|
||||
const songsLoading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const subscribed = ref(false);
|
||||
const showDescModal = ref(false);
|
||||
const descOverflow = ref(false);
|
||||
const descEl = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id);
|
||||
try {
|
||||
const jsonStr: string = await invoke('get_playlist_detail', { id });
|
||||
const data = JSON.parse(jsonStr);
|
||||
playlist.value = data.playlist;
|
||||
songs.value = data.playlist.tracks || [];
|
||||
} catch (e) {
|
||||
console.error('获取歌单详情失败', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
const isOwnPlaylist = computed(() => {
|
||||
if (!playlist.value || !userStore.user) return false;
|
||||
return playlist.value.creator?.userId === userStore.user.userId;
|
||||
});
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const sec = Math.floor(ms / 1000);
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
function checkDescOverflow() {
|
||||
nextTick(() => {
|
||||
if (descEl.value) {
|
||||
descOverflow.value = descEl.value.scrollHeight > descEl.value.clientHeight + 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function playSingle(song: any) {
|
||||
player.play(song);
|
||||
async function fetchPlaylist(id: number, force = false) {
|
||||
const cacheKey = `playlist_${id}`;
|
||||
if (!force) {
|
||||
const cached = pageCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
playlist.value = cached.playlist;
|
||||
songs.value = cached.songs;
|
||||
subscribed.value = cached.subscribed;
|
||||
playlistLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
loadError.value = false;
|
||||
checkDescOverflow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
playlistLoading.value = true;
|
||||
songsLoading.value = true;
|
||||
loadError.value = false;
|
||||
playlist.value = null;
|
||||
songs.value = [];
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.getPlaylistDetail(id);
|
||||
const data = JSON.parse(jsonStr);
|
||||
playlist.value = data.playlist;
|
||||
playlistLoading.value = false;
|
||||
songs.value = (data.playlist.tracks || []).map(normalizeSong);
|
||||
songsLoading.value = false;
|
||||
subscribed.value = data.playlist.subscribed || false;
|
||||
pageCacheSet(cacheKey, { playlist: playlist.value, songs: songs.value, subscribed: subscribed.value });
|
||||
checkDescOverflow();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = true;
|
||||
playlistLoading.value = false;
|
||||
songsLoading.value = false;
|
||||
showToast('获取歌单详情失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPlaylist(Number(route.params.id));
|
||||
});
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId && route.name === 'playlist') fetchPlaylist(Number(newId));
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (loadError.value) fetchPlaylist(Number(route.params.id), true);
|
||||
});
|
||||
|
||||
function playAll() {
|
||||
if (songs.value.length === 0) return;
|
||||
player.playAll(songs.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
async function toggleSubscribe() {
|
||||
if (!playlist.value) return;
|
||||
const newSubscribed = !subscribed.value;
|
||||
try {
|
||||
await MusicApi.playlistSubscribe(Number(playlist.value.id), newSubscribed);
|
||||
subscribed.value = newSubscribed;
|
||||
showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success');
|
||||
} catch {
|
||||
showToast('操作失败,请稍后重试', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,6 +1,33 @@
|
||||
<template>
|
||||
<div class="p-8 text-white">
|
||||
<h1 class="text-2xl font-bold mb-4">🕐 最近播放</h1>
|
||||
<p class="text-gray-400">正在施工...</p>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">最近播放</h1>
|
||||
</PageHeader>
|
||||
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录,去听首歌吧</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in player.recentLocal"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(player.recentLocal, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
|
||||
const player = usePlayerStore();
|
||||
</script>
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div class="p-8 text-white flex flex-col items-center justify-center min-h-full">
|
||||
<!-- 无歌曲时提示 -->
|
||||
<div v-if="!currentSong" class="text-center">
|
||||
<p class="text-gray-400 mb-4">私人漫游未启动</p>
|
||||
<button
|
||||
@click="startFm"
|
||||
class="px-6 py-2 bg-white/10 hover:bg-white/20 rounded-full transition"
|
||||
>
|
||||
开始漫游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息展示 -->
|
||||
<template v-else>
|
||||
<!-- 专辑封面 -->
|
||||
<img
|
||||
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
|
||||
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
|
||||
/>
|
||||
|
||||
<!-- 歌曲名和艺术家 -->
|
||||
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
|
||||
<p class="text-lg text-gray-400 mb-8">
|
||||
{{ artists }}
|
||||
</p>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="flex items-center gap-8">
|
||||
<button
|
||||
@click="togglePlay"
|
||||
class="w-16 h-16 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20"
|
||||
>
|
||||
<!-- 暂停图标 -->
|
||||
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="3" y="2" width="3" height="12" rx="0.5" />
|
||||
<rect x="10" y="2" width="3" height="12" rx="0.5" />
|
||||
</svg>
|
||||
<!-- 播放图标 -->
|
||||
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="nextSong"
|
||||
class="text-3xl text-gray-400 hover:text-white transition"
|
||||
>
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
const player = usePlayerStore();
|
||||
|
||||
// 当前正在播放的歌曲(如果处于FM模式,则显示当前歌曲)
|
||||
const currentSong = computed(() => {
|
||||
// FM 模式下直接显示正在播放的歌曲(可能是FM歌曲)
|
||||
if (player.isFmMode && player.currentSong) {
|
||||
return player.currentSong;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const artists = computed(() => {
|
||||
if (!currentSong.value) return '';
|
||||
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
|
||||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
|
||||
});
|
||||
|
||||
// 进入页面时,如果FM未启动,自动开始
|
||||
onMounted(async () => {
|
||||
if (!player.isFmMode || !player.currentSong) {
|
||||
await startFm();
|
||||
}
|
||||
});
|
||||
|
||||
async function startFm() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('personal_fm');
|
||||
const data = JSON.parse(jsonStr);
|
||||
const songs = data.data || data;
|
||||
if (songs && songs.length > 0) {
|
||||
const song = normalizeSong(songs[0]);
|
||||
player.enableFmMode(nextSong);
|
||||
await player.playFmSong(song);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('启动漫游失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSong(song: any) {
|
||||
const normalized = { ...song };
|
||||
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
|
||||
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
|
||||
}
|
||||
if (!normalized.ar || normalized.ar.length === 0) {
|
||||
normalized.ar = normalized.artists || [];
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function togglePlay() {
|
||||
if (player.playing) {
|
||||
await invoke('pause_audio');
|
||||
} else {
|
||||
if (player.currentSong) {
|
||||
// 恢复播放
|
||||
await invoke('resume_audio');
|
||||
} else {
|
||||
await startFm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function nextSong() {
|
||||
await startFm();
|
||||
}
|
||||
</script>
|
||||
@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div class="text-white">
|
||||
<h1 class="text-2xl font-bold mb-4">搜索</h1>
|
||||
|
||||
<!-- 输出设备选择-->
|
||||
<!-- <div class="mb-4">
|
||||
<label class="mr-2">输出设备:</label>
|
||||
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1">
|
||||
<option :value="null">跟随系统默认</option>
|
||||
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div> -->
|
||||
|
||||
<input
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
placeholder="搜索歌曲..."
|
||||
class="mb-6 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="text-gray-400">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="song in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition-all duration-200 hover:scale-[1.01] active:scale-95"
|
||||
>
|
||||
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
||||
<div>
|
||||
<p class="font-medium">{{ song.name }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SearchView' });
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
import { watch } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref('');
|
||||
const results = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const player = usePlayerStore();
|
||||
|
||||
const route = useRoute();
|
||||
// 监听从首页或其他地方传来的 query 参数,自动搜索
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQ) => {
|
||||
if (newQ) {
|
||||
keyword.value = newQ as string;
|
||||
handleSearch();
|
||||
// 清除 query,防止刷新后重复搜索
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
hasSearched.value = true;
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = data.result?.songs || [];
|
||||
} catch (e) {
|
||||
console.error('搜索出错:', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playSong(song: any) {
|
||||
try {
|
||||
await player.play(song);
|
||||
} catch (e) {
|
||||
alert('暂无播放源或需登录');
|
||||
}
|
||||
}
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
// const selectedDevice = ref<string | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
devices.value = await invoke('get_output_devices');
|
||||
});
|
||||
|
||||
// async function changeDevice() {
|
||||
// await invoke('set_output_device', { device: selectedDevice.value });
|
||||
// }
|
||||
</script>
|
||||
452
src/views/Settings.vue
Normal file
452
src/views/Settings.vue
Normal file
@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<div class="p-8 text-content">
|
||||
<PageHeader>
|
||||
<h1 class="text-2xl font-bold">设置</h1>
|
||||
</PageHeader>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">输出设备</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">选择音频播放设备</p>
|
||||
</div>
|
||||
<CustomSelect v-model="selectedDevice" :options="deviceOptions" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">音质选择</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">更高音质需要 VIP 权限</p>
|
||||
</div>
|
||||
<CustomSelect v-model="settings.audioQuality" :options="qualityLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">外观模式</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-for="(label, key) in appearanceLabels"
|
||||
:key="key"
|
||||
@click="settings.setAppearance(key)"
|
||||
class="flex items-center gap-2 px-4 py-2.5 rounded-xl transition-all border-2"
|
||||
:class="settings.appearance === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<IconSun v-if="key === 'light'" class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
|
||||
<IconMoon v-else class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="settings.appearance === key ? 'text-content font-medium' : 'text-content-3'">{{ label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">主题色</p>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="(color, key) in themeColors"
|
||||
:key="key"
|
||||
@click="settings.setTheme(key)"
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
|
||||
:class="settings.theme === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
|
||||
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">窗口</h2>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">关闭窗口时</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">点击关闭按钮的默认行为</p>
|
||||
</div>
|
||||
<CustomSelect v-model="closeActionValue" :options="closeActionLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">下载</h2>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium">下载路径</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content-2 truncate" :title="settings.downloadPath || defaultDownloadPath">
|
||||
{{ settings.downloadPath || defaultDownloadPath }}
|
||||
</div>
|
||||
<button
|
||||
@click="pickDownloadFolder"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-accent/15 text-accent-text hover:bg-accent/25 active:scale-95"
|
||||
>
|
||||
选择文件夹
|
||||
</button>
|
||||
<button
|
||||
v-if="settings.downloadPath"
|
||||
@click="clearDownloadPath"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-muted text-content-2 hover:bg-emphasis hover:text-content active:scale-95"
|
||||
title="重置为默认路径"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">快捷键</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(sc, id) in settings.shortcuts"
|
||||
:key="id"
|
||||
class="flex items-center justify-between p-3 bg-subtle rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ sc.label }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
v-if="sc.key !== defaultShortcuts[id]?.key"
|
||||
@click="settings.setShortcut(String(id), defaultShortcuts[id].key)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
|
||||
title="恢复默认"
|
||||
>
|
||||
<IconX style="font-size: 14px" />
|
||||
</button>
|
||||
<button
|
||||
@click="startRecording(String(id))"
|
||||
class="px-3 py-1.5 rounded-lg text-sm transition min-w-[120px] text-center"
|
||||
:class="recordingId === String(id) ? 'bg-accent text-white' : 'bg-muted hover:bg-emphasis text-content-2'"
|
||||
>
|
||||
{{ recordingId === String(id) ? '按下新快捷键...' : formatShortcut(sc.key) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="resetShortcuts"
|
||||
class="text-xs text-content-3 hover:text-danger transition"
|
||||
>
|
||||
恢复默认快捷键
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">其他</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-subtle rounded-xl">
|
||||
<div>
|
||||
<p class="text-sm font-medium">恢复默认设置</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">重置所有设置为初始状态</p>
|
||||
</div>
|
||||
<button
|
||||
@click="handleResetAll"
|
||||
class="px-3 py-1.5 rounded-lg text-sm bg-muted hover:bg-emphasis text-danger transition"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">关于</h2>
|
||||
<div class="space-y-4">
|
||||
<a @click.prevent="openUrl('https://github.com/atdunbg/Nekosonic-Music')"
|
||||
class="flex items-center gap-4 p-4 bg-subtle rounded-xl hover:bg-muted transition cursor-pointer">
|
||||
<img src="../assets/app-icon.png" class="w-12 h-12 rounded-xl flex-shrink-0" alt="Nekosonic" />
|
||||
<div>
|
||||
<p class="font-semibold">Nekosonic</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">版本 {{ appVersion }}</p>
|
||||
</div>
|
||||
</a>
|
||||
<p class="text-xs text-content-3 leading-relaxed">
|
||||
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端,基于 Tauri 2 + Vue 3 构建,提供轻量流畅的音乐播放体验。
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="handleCheckUpdate"
|
||||
:disabled="updater.checking.value"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||
>
|
||||
<IconFileText v-if="!updater.checking.value" class="w-4 h-4" />
|
||||
<IconLoader2 v-else class="w-4 h-4 animate-spin" />
|
||||
{{ updater.checking.value ? '检查中...' : '检查更新' }}
|
||||
</button>
|
||||
<button
|
||||
@click="fetchChangelog"
|
||||
:disabled="fetchingChangelog"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
|
||||
>
|
||||
<IconFileText class="w-4 h-4" />
|
||||
{{ fetchingChangelog ? '获取中...' : '更新日志' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="updater.error.value" class="text-xs text-content-3">{{ updater.error.value }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="fade">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认重置</h2>
|
||||
<p class="text-sm text-content-2 mb-5">所有设置将恢复为默认值,此操作不可撤销。</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showResetConfirm = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
取消
|
||||
</button>
|
||||
<button @click="confirmResetAll"
|
||||
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
|
||||
确认重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[480px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-lg font-semibold text-content">更新日志</h2>
|
||||
<span v-if="changelogRelease" class="text-xs font-medium px-2 py-0.5 rounded-full bg-accent/15 text-accent-text">v{{ changelogRelease.tag_name?.replace('v', '') }}</span>
|
||||
</div>
|
||||
<p v-if="changelogRelease?.published_at" class="text-xs text-content-3 mt-1">{{ formatDate(changelogRelease.published_at) }}</p>
|
||||
</div>
|
||||
<div v-if="changelogRelease?.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60">
|
||||
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ changelogRelease.body }}</div>
|
||||
</div>
|
||||
<div v-else class="px-6 pb-4">
|
||||
<p class="text-sm text-content-3">暂无更新日志</p>
|
||||
</div>
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button @click="showChangelogModal = false"
|
||||
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||
关闭
|
||||
</button>
|
||||
<button v-if="changelogRelease?.html_url" @click="openUrl(changelogRelease.html_url)"
|
||||
class="flex-1 py-2 rounded-lg bg-accent/20 hover:bg-accent/30 text-accent-text text-sm font-medium transition">
|
||||
在 GitHub 中查看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, appearanceLabels, type CloseAction } from '../stores/settings';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { useUpdater } from '../composables/useUpdater';
|
||||
import { DeviceApi, DownloadApi } from '../api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import CustomSelect from '../components/CustomSelect.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconFileText from '~icons/lucide/file-text';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconSun from '~icons/lucide/sun';
|
||||
import IconMoon from '~icons/lucide/moon';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
const updater = useUpdater();
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
const deviceOptions = computed(() => {
|
||||
const options: Record<string, string> = { '': '跟随系统默认' };
|
||||
for (const name of devices.value) {
|
||||
options[name] = name;
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const selectedDevice = computed({
|
||||
get: () => settings.outputDevice || '',
|
||||
set: (val: string) => {
|
||||
const device = val === '' ? null : val;
|
||||
settings.setOutputDevice(device);
|
||||
DeviceApi.setOutputDevice(device).then(() => {
|
||||
showToast(device ? `已切换到: ${device}` : '已切换到系统默认', 'success');
|
||||
}).catch((e) => {
|
||||
console.error('切换设备失败: ', e);
|
||||
showToast('切换设备失败', 'error');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
devices.value = await DeviceApi.getOutputDevices();
|
||||
} catch (e) {
|
||||
console.error('获取设备失败: ', e);
|
||||
}
|
||||
}
|
||||
|
||||
const appVersion = ref('');
|
||||
const defaultDownloadPath = ref('');
|
||||
onMounted(async () => {
|
||||
appVersion.value = await getVersion();
|
||||
try {
|
||||
defaultDownloadPath.value = await DownloadApi.getDefaultDownloadPath();
|
||||
} catch { /* 忽略 */ }
|
||||
loadDevices();
|
||||
});
|
||||
|
||||
const closeActionValue = computed({
|
||||
get: () => settings.closeAction,
|
||||
set: (val: CloseAction) => settings.setCloseAction(val),
|
||||
});
|
||||
|
||||
async function pickDownloadFolder() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: '选择下载路径',
|
||||
});
|
||||
if (selected) {
|
||||
settings.setDownloadPath(selected);
|
||||
showToast('下载路径已更新', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function clearDownloadPath() {
|
||||
settings.setDownloadPath('');
|
||||
showToast('已重置为默认路径', 'success');
|
||||
}
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
const result = await updater.checkForUpdate(false);
|
||||
if (!result) {
|
||||
showToast(updater.error.value || '当前已是最新版本', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
const fetchingChangelog = ref(false);
|
||||
const changelogRelease = ref<any>(null);
|
||||
const showChangelogModal = ref(false);
|
||||
|
||||
async function fetchChangelog() {
|
||||
fetchingChangelog.value = true;
|
||||
try {
|
||||
const resp = await fetch('https://api.github.com/repos/atdunbg/Nekosonic-Music/releases?per_page=1');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const releases = await resp.json();
|
||||
if (releases && releases.length > 0) {
|
||||
changelogRelease.value = releases[0];
|
||||
showChangelogModal.value = true;
|
||||
} else {
|
||||
showToast('暂无发布版本', 'info');
|
||||
}
|
||||
} catch (e: any) {
|
||||
showToast(`获取失败: ${e}`, 'error');
|
||||
} finally {
|
||||
fetchingChangelog.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
const recordingId = ref<string | null>(null);
|
||||
|
||||
function formatShortcut(key: string): string {
|
||||
return key
|
||||
.replace('Control', 'Ctrl')
|
||||
.replace('ArrowLeft', '←')
|
||||
.replace('ArrowRight', '→')
|
||||
.replace('ArrowUp', '↑')
|
||||
.replace('ArrowDown', '↓')
|
||||
.replace(/Key([A-Z])/g, '$1')
|
||||
.replace(/\+/g, ' + ');
|
||||
}
|
||||
|
||||
function startRecording(id: string) {
|
||||
recordingId.value = id;
|
||||
}
|
||||
|
||||
function resetShortcuts() {
|
||||
settings.resetShortcuts();
|
||||
showToast('快捷键已恢复默认', 'success');
|
||||
}
|
||||
|
||||
const showResetConfirm = ref(false);
|
||||
|
||||
function handleResetAll() {
|
||||
showResetConfirm.value = true;
|
||||
}
|
||||
|
||||
function confirmResetAll() {
|
||||
settings.resetAll();
|
||||
showResetConfirm.value = false;
|
||||
showToast('已恢复默认设置', 'success');
|
||||
}
|
||||
|
||||
function onRecordingKeydown(e: KeyboardEvent) {
|
||||
if (!recordingId.value) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
recordingId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Control');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
|
||||
const ignoredKeys = ['Control', 'Alt', 'Shift', 'Meta'];
|
||||
if (!ignoredKeys.includes(e.key)) {
|
||||
parts.push(e.code);
|
||||
}
|
||||
|
||||
if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
|
||||
const hasModifier = parts.includes('Control') || parts.includes('Alt') || parts.includes('Shift');
|
||||
if (!hasModifier) {
|
||||
showToast('快捷键必须包含 Ctrl、Alt 或 Shift', 'error');
|
||||
recordingId.value = null;
|
||||
return;
|
||||
}
|
||||
const combo = parts.join('+');
|
||||
settings.setShortcut(recordingId.value, combo);
|
||||
recordingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onRecordingKeydown, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onRecordingKeydown, true);
|
||||
});
|
||||
</script>
|
||||
@ -7,4 +7,4 @@
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,8 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import Icons from "unplugin-icons/vite";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
|
||||
// \@ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [
|
||||
vue(),
|
||||
@ -21,11 +18,19 @@ export default defineConfig(async () => ({
|
||||
},
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
build: {
|
||||
target: "esnext",
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ["vue", "vue-router", "pinia"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
@ -38,7 +43,6 @@ export default defineConfig(async () => ({
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user