24 Commits

Author SHA1 Message Date
e40f82cc51 refactor: 创建统一 API 封装层,前端不再直接调用 invoke
- 新增 src/api.ts,按职责分为 MusicApi/AudioApi/DeviceApi/DownloadApi/AppApi 五个命名空间
- 替换 15 个文件中所有 invoke 调用为 API 层方法
- 后端接口变更只需修改 api.ts 一处,便于后期迭代维护
2026-05-29 22:02:42 +08:00
68f29c8ea8 refactor: 拆分 App.vue 为独立组件,修复多项敷衍方案
- 提取 TitleBar、Sidebar、RoamDrawer、CloseModal 四个组件
- App.vue 从 676 行精简至 238 行,职责更清晰
- 修复评论点赞无限+1:改为基于服务端 liked 字段切换
- 修复评论点赞无防重复:添加 likingSet 锁
- 修复评论点赞用本地缓存:likedIds 改为从服务端 likelist API 同步
- 删除 likedIds 写 localStorage 的 watch
- 播放失败/FM加载失败/减少推荐失败添加 showToast 用户提示
- 抽屉遮罩下标题栏按钮保持原色(z-10 提升至遮罩之上)
2026-05-29 21:13:14 +08:00
57aa9dae61 fix: PlayerBar进度条边框线移除及按钮居中修复,release构建优化
- 移除PlayerBar顶部border线,进度条紧贴边缘无需分隔线
- 减少推荐按钮改为absolute定位,不参与播放控制按钮居中计算
- 删除未使用的npm依赖(howler/axios/@vueuse/motion/@vicons/ionicons5)
- @iconify-json/lucide移至devDependencies
- Cargo.toml添加[profile.release]优化(strip/lto/codegen-units/panic/opt-level)
- crate-type精简,移除不需要的staticlib
- Vite构建优化(esnext目标/CSS合并/vendor chunk拆分)
2026-05-29 17:22:29 +08:00
c275461015 feat: v0.6.0 - 亮色主题、封面主色、发现页重做、漫游页重做、减少推荐、列表风格统一
新功能:
- 亮色主题:新增浅色外观模式,7种主题色各有对应亮色变体
- 封面主色背景:漫游抽屉自动提取封面主色,PlayerBar跟随继承
- 发现页重做:多类型搜索(歌曲/歌手/专辑)+搜索建议+搜索历史
- 漫游页重做:进入即播放,布局改为封面+歌名+播放/下一首/减少推荐
- 减少推荐:FM模式下可标记不推荐歌曲或歌手
- 列表风格统一:播放指示器跳动动画+hover播放图标+图标统一使用Lucide

修复:
- 专辑页艺术家过多时窗口缩小竖排,改为自动换行
- FM播放时退出登录后首页仍可点击下一首
- 本地音乐播放时缓冲进度条未重置
- 亮色主题下多处文字不可见
- 退出FM模式时状态未正确清理
- 暗色模式下关闭抽屉时PlayerBar闪烁亮色(改用opacity过渡)
- player.ts tickInterval双变量状态不同步,统一为clearTick/setTick

变更:
- 移除播放列表按钮数字角标
- 主页卡片标题固定白色不随主题变化
- 全项目空catch块格式统一
- 清理冗余注释和代码
2026-05-28 23:14:25 +08:00
6da544cffb v0.5.1: 修复缓存/FM/翻译/Linux等问题 2026-05-25 19:38:48 +08:00
65ed71503e feat: 架构重构与跨平台媒体控制集成
## 后端

- 替换 rodio 为 symphonia + ringbuf,重构 audio.rs 播放引擎
- 重构 api.rs,使用 api_call! 宏统一 API 调用模式
- 新增 media_controls.rs,使用 souvlaki 实现跨平台系统媒体控制
  (Linux MPRIS / Windows SMTC / macOS Now Playing)
- 版本号升至 v0.5.0

## 前端 - 新增

- 新增 SongListItem 通用组件
- 新增 useOnlineStatus composable,检测网络状态
- 新增 usePageCache composable,页面数据缓存与失效
- 新增 getCoverUrl()、formatDate() 工具函数
- 新增 emitPlaybackState() 同步播放状态到系统媒体控制
- 新增 mpris-command 事件监听,响应系统媒体控制命令
- 新增 Toast 离线/恢复在线提示
- 各页面新增断网恢复后自动重试加载
- 新增路由守卫:已登录用户访问 /login 重定向至首页
- 新增音量持久化(settings store + localStorage)
- 新增禁用右键菜单与用户选择限制(输入框除外)

## 前端 - 变更

- Song 接口从 player.ts 迁移至 song.ts 并导出
- AlbumDetail/ArtistDetail/PlaylistDetail/RecentPlays/LocalMusic 迁移至 SongListItem
- PlayerBar 队列列表迁移至 SongListItem,封面使用 getCoverUrl()
- downloadSong 参数类型从内联对象改为 Song,使用 getCoverUrl()
- 默认主题从 green 改为 blue,ThemeName 及相关列表中 blue 移至首位
- 全局快捷键从 Alt+Control 改为 Control+Alt 顺序
- formatShortcut 新增 KeyP → P 显示
- keep-alive 从 max=3 固定 include 改为 max=5 动态列表,窗口隐藏时释放
- App.vue 封面使用 getCoverUrl() 替代手动 al/album 回退
- formatPlayCount 提取常量
- Login.vue text-warning 改为 text-yellow-400

## 前端 - 删除

- 删除 Search.vue(与 Discover.vue 重复)
- 删除 SongItemMenu.vue(被 SongListItem 替代)

## 修复

- 更新器跳过版本逻辑:仅静默检查时跳过已忽略版本,手动检查不再跳过
- 重复播放同一首歌时无法恢复播放
- settings.ts 重复的 ThemeName 定义
- PlayerBar.vue modeTexts 缺少类型注解
- Home.vue map 回调参数缺少类型
- Settings.vue v-for key 类型不匹配
2026-05-23 14:43:47 +08:00
970fb15f5a 重构播放列表为右侧弹出式
播放列表可以定位正在播放的歌曲位置
添加歌词翻译
新增快捷键 播放/暂停
重构主题设置,支持多种主题

修复评论playerbar查看点击后一直默认评论打开抽屉页面问题
2026-05-22 00:54:09 +08:00
cf21c96eaf 更新版本至 v0.4.1 2026-05-21 14:22:14 +08:00
987d34f58b 修改changelog 2026-05-18 15:58:56 +08:00
baa6235c56 添加设置音频输出选择 2026-05-18 15:52:51 +08:00
38c079ed5c 更新README 2026-05-16 15:29:58 +08:00
68e3b92a6a 设置页面自适应,添加查看最新版本日志按钮 2026-05-16 13:34:10 +08:00
d718ee5b42 修复错误workflow变量 2026-05-16 12:36:29 +08:00
966825c885 添加自动更新功能 2026-05-16 12:17:41 +08:00
e8efc7275a 更新workflow 2026-05-16 02:20:34 +08:00
0740d9be29 更新workflow 2026-05-16 02:02:36 +08:00
d2546ca93c Update Linux dependencies in release workflow 2026-05-16 01:35:48 +08:00
29df8ca491 添加意外删除的'vite.config.ts'文件 2026-05-16 01:06:40 +08:00
3158338d0b 添加意外删除的'tsconfig.node.json'文件 2026-05-16 00:54:01 +08:00
79fb001ae7 修改错误的workflow命名 2026-05-16 00:48:02 +08:00
fd4bbb4a0a 添加 CHANGELOG 2026-05-16 00:41:49 +08:00
3b800e451f - **歌手相关功能添加**: 添加歌曲的艺术家入口,
歌曲的艺术家现可点击查看其他歌曲,专辑和介绍
- **歌曲评论功能添加**: 添加歌曲的评论查看功能

- 修复私人漫游自动播放下一首调用多次问题

-
优化播放逻辑,歌曲列表在点击时候不在单首累加, 而是直接获取当前列表所有的歌曲作为播放内容
2026-05-16 00:11:37 +08:00
718d3ed641 feat: v0.3.0 - 流式播放、本地音乐、下载系统、漫游修复
### 新功能
- 流式播放:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载
- 本地音乐页面:支持浏览、播放本地歌曲,横向菜单含「从磁盘删除」
- 下载系统:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长)
- 封面补全:本地音乐缺少封面时自动从网易云 API 获取
- 更新信息:接入 Gitea Releases API,查看最新版更新日志

### 修复
- 修复私人漫游播完一首歌后跳三首的问题(双重触发:audio-ended + startTick)
- 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片
- 修复 PlayerBar 无封面歌曲显示破损图片
- 修复下载路径修改后不生效(Rust serde camelCase 映射)
- 修复本地音乐始终只显示默认路径歌曲
- 修复下载完成提示弹出 4 次
- 修复播放网络歌曲时进度条先走但无声音(audio-started 事件同步)

### 优化
- PlayerBar 下载状态:未下载显示下载按钮,下载中显示进度,已下载不显示
- audio.rs 新增 manual_stop 标志防止 stop_audio 触发虚假 audio-ended
- player.ts 新增 waitForAudioStart() 确保 playing 状态与实际播放同步
- 切歌/停止时立即清除 tickInterval 防止重复触发 next()
2026-05-15 02:24:48 +08:00
02f7df4201 设置应用为单例,防止多个实例同时存在 2026-05-12 06:31:37 +08:00
55 changed files with 8067 additions and 2321 deletions

81
.github/workflows/release.yml vendored Normal file
View 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

142
CHANGELOG.md Normal file
View File

@ -0,0 +1,142 @@
## 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 的跨平台桌面音乐播放器,音源主要来自网易云音乐,开箱即用。

View File

@ -1,25 +1,63 @@
# Nekosonic
一款轻量的跨平台音乐播放器支持Windows/Linux系统,音源主要源自网易云音乐。
一款轻量的跨平台音乐播放器,支持 Windows / Linux / macOS,音源源自网易云音乐。
## ✨ 特性
- 🔴 网易云账号登录(扫码)
- 🎵 多音质播放(标准 / 较高 / 极高 / 无损 / Hi-Res
- 📻 私人漫游,沉浸式全屏歌词体验
- ❤️ 一键喜欢 / 取消喜欢
- 📋 歌单管理,收藏 / 取消收藏歌单
### 播放
- 🎵 在线音乐播放,流式缓冲边下边播
- 🎵 多音质选择(标准 / 较高 / 极高 HQ / 无损 SQ / Hi-Res
- 🔄 播放模式切换(列表循环 / 随机播放 / 单曲循环)
- ⏯ 播放控制(播放 / 暂停 / 上一首 / 下一首 / 进度跳转 / 音量调节)
- 📋 播放队列管理(查看队列 / 移除歌曲 / 清空队列)
- 📻 私人漫游 FM个性化推荐VIP 试听自动跳过)
- 🎵 本地音乐播放(支持 mp3 / flac / wav / ogg / aac / m4a / wma / opus
- 🔊 音频输出设备选择
- 🎧 系统媒体控制(蓝牙耳机/键盘媒体键/系统面板,支持 Linux / Windows / macOS
### 发现与浏览
- 🔍 关键词搜索歌曲 + 热门搜索标签
- 📋 歌单浏览(推荐歌单 / 排行榜 / 用户歌单 / 收藏歌单)
- 📋 歌单详情(歌曲列表 + 收藏 / 取消收藏 + 歌单评论)
- 🎤 歌手详情(热门歌曲 / 专辑 / 简介)
- 💿 专辑详情(歌曲列表 + 播放全部)
- 📅 每日推荐歌曲
- 🕐 本地播放历史记录
- 🔍 关键词搜索歌曲
- 🎤 实时滚动歌词
- 🌚 Light / Dark Mode 主题切换
- 🛠 更多特性添加中
### 歌词与评论
- 🎤 实时滚动歌词(自动滚动 / 点击跳转 / 渐变透明度)
- 🎤 歌词翻译显示
- 🎤 全屏漫游模式(大封面 + 歌词 / 评论双标签页)
- 💬 歌曲评论查看(热门评论 + 无限滚动加载 + 点赞)
### 收藏与下载
- ❤️ 一键喜欢 / 取消喜欢(同步到网易云账号)
- ⬇️ 歌曲下载(带进度显示 / VIP 拦截 / 元数据保存)
- 🎵 本地音乐管理(列出 / 播放 / 删除 / 音频元数据与封面读取)
- 🕐 本地播放历史记录(最多 200 首)
### 账号
- 🔴 网易云账号登录(二维码扫码 / 手机号密码)
- 🔑 登录态持久化(重启后自动恢复)
### 系统与设置
- 📡 系统托盘(播放控制 / 显示窗口 / 退出)
- 🛡 单实例运行(防止重复启动)
- ⌨️ 自定义快捷键(应用内 + 系统全局)
- 🎨 多主题切换(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
- ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出)
- 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度)
- 📝 更新日志查看
- 📶 网络状态检测(断网/恢复 Toast 提示 + 自动重试加载)
## 📦️ 安装
访问本项目的 [Releases](https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
访问本项目的 [Releases](https://github.com/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
## 💻 配置开发环境
@ -49,19 +87,26 @@ npm run tauri build
| 样式 | Tailwind CSS v4 + CSS 变量主题系统 |
| 状态管理 | Pinia |
| 路由 | Vue Router 4 |
| 音频播放 | rodio (Rust) |
| 音频解码 | symphonia + ringbuf (Rust) |
| 媒体控制 | souvlaki (Linux MPRIS / Windows SMTC / macOS Now Playing) |
| 网易云 API | ncm-api-rs |
| 构建工具 | Vite 6 |
## ☑️ Todo
- [x] 评论系统
- [x] 歌曲下载
- [x] 本地音乐管理
- [x] 歌手详情页
- [x] 专辑详情页
- [x] 自定义全局快捷键
- [x] 自动更新
- [x] 歌词翻译
- [x] 更多主题
- [x] 系统媒体控制(蓝牙耳机/键盘媒体键)
- [ ] MV 播放
- [ ] 音乐云盘
- [ ] 评论系统
- [ ] 下载功能
- [ ] 自定义全局快捷键
- [ ] 歌词翻译
- [ ] 更多主题
- [ ] 桌面歌词
欢迎提 Issue 和 Pull request。
@ -71,11 +116,11 @@ npm run tauri build
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
## 致谢
- [ncm-api-rs](https://crates.io/crates/ncm-api-rs) — 网易云音乐 API 的 Rust 封装
- [Tauri](https://tauri.app/) — 跨平台桌面应用框架
- [Vue.js](https://vuejs.org/) — 渐进式 JavaScript 框架
- [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架
- [rodio](https://crates.io/crates/rodio) — Rust 音频播放
- [symphonia](https://crates.io/crates/symphonia) — Rust 音频解码
- [souvlaki](https://crates.io/crates/souvlaki) — 跨平台 OS 媒体控制库

773
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "nekosonic",
"private": true,
"version": "0.1.0",
"version": "0.6.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",

1513
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "Nekosonic"
version = "0.1.0"
version = "0.6.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,38 @@ 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"
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"

View File

@ -12,6 +12,15 @@
"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"
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,13 @@
use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
menu::{MenuBuilder, MenuItemBuilder},
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;
@ -17,7 +18,6 @@ pub fn run() {
tauri::Builder::default()
.setup(|app| {
// 注入控制器
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
let api_controller = ApiController::new(app_data_dir);
app.manage(api_controller);
@ -26,36 +26,59 @@ pub fn run() {
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", ());
@ -83,16 +106,19 @@ 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 {
if ALLOW_EXIT.load(Ordering::SeqCst) {
@ -100,6 +126,7 @@ pub fn run() {
}
close_api.prevent_close();
let _ = window_clone.hide();
let _ = app_handle.emit("window-hidden", ());
}
});
@ -110,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,
@ -118,6 +147,9 @@ 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,
@ -132,15 +164,45 @@ pub fn run() {
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,
api::download_song,
api::list_local_songs,
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::album_detail,
api::comment_new,
api::comment_hot,
api::comment_floor,
api::comment_like,
])
.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");
}
}

View 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);
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Nekosonic",
"version": "0.2.0",
"version": "0.6.0",
"identifier": "com.atdunbg.Nekosonic",
"build": {
"beforeDevCommand": "npm run dev",
@ -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"
}
}
}
}

View File

@ -1,371 +1,95 @@
<template>
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
<div
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 bg-surface/90 backdrop-blur select-none flex-shrink-0"
>
<span class="text-xs text-content-3 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>
<TitleBar @close="closeWindow" />
<div class="flex flex-1 overflow-hidden">
<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="relative mb-4">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-content-3" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input v-model="searchQuery" @keydown.enter="doSearch" type="text" placeholder="搜索音乐..."
class="w-full rounded-lg bg-subtle pl-9 pr-3 py-2 text-sm text-content placeholder-content-3 outline-none focus:bg-muted transition" />
</div>
<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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l9-9 9 9"/><path d="M5 10v10a1 1 0 001 1h3v-6h6v6h3a1 1 0 001-1V10"/></svg>
推荐
</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"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
漫游
</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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
我喜欢的音乐
</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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
最近播放
</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"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-content-3">我的歌单</p>
<span class="text-xs text-content-3 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 cursor-pointer truncate transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
{{ 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-content-3">收藏的歌单</p>
<span class="text-xs text-content-3 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 cursor-pointer truncate transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
{{ pl.name }}
</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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
设置
</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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
立即登录
</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()"
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
<Sidebar />
<main class="flex-1 overflow-y-auto pb-24">
<router-view v-slot="{ Component }">
<keep-alive :max="3" include="HomeView,DiscoverView">
<keep-alive :max="5" :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-10 flex items-center justify-between px-4 flex-shrink-0" data-tauri-drag-region>
<button @click="player.closeRoamDrawer()" class="text-content-2 hover:text-content transition">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<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-1 min-h-0 flex px-8 pb-8 gap-0">
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
<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 text-center">{{ roamSong?.name }}</h1>
<p class="text-content-2 mt-2 text-center">{{ roamArtists }}</p>
</div>
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
<div ref="lyricScrollContainer" class="h-full 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 transition-all duration-300"
@click="seekToRoamLyric(line.time)"
@mouseenter="roamLyricHovering = true"
@mouseleave="roamLyricHovering = false"
>
{{ line.text }}
</p>
</div>
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
</div>
</div>
</div>
</div>
</Transition>
<RoamDrawer :visible="windowVisible && player.showRoamDrawer" />
<PlayerBar v-if="player.currentSong" />
<ToastContainer />
<Transition name="fade">
<div v-if="showCloseModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCloseModal = 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="space-y-2.5 mb-4">
<button @click="handleCloseAction('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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
</div>
<div>
<p class="text-sm font-medium text-content">最小化到托盘</p>
<p class="text-xs text-content-3">程序继续在后台运行</p>
</div>
</button>
<button @click="handleCloseAction('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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="M18 6L6 18M6 6l12 12"/></svg>
</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="closeDontAskAgain" />
<span class="text-xs text-content-2">不再询问记住我的选择</span>
</label>
<button @click="showCloseModal = false"
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
</div>
</div>
</Transition>
<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, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useUserStore } from './stores/user';
import { useSettingsStore, type CloseAction } from './stores/settings';
import { usePlayerStore } from './stores/player';
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 { usePlayerStore } from './stores/player';
import { useLyric } from './composables/UserLyric';
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 route = useRoute();
const userStore = useUserStore();
const player = usePlayerStore();
const settings = useSettingsStore();
const updater = useUpdater();
const { isOnline } = useOnlineStatus();
watch(isOnline, (val, old) => {
if (val && !old) showToast('网络已恢复', 'success');
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
});
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
const searchQuery = ref('');
const showCloseModal = ref(false);
const closeDontAskAgain = ref(false);
const windowVisible = ref(true);
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
watch(() => settings.theme, (val) => {
watch(() => settings.dataTheme, (val) => {
document.documentElement.setAttribute('data-theme', val);
}, { immediate: true });
function doSearch() {
const q = searchQuery.value.trim();
if (q) router.push({ path: '/discover', query: { q } });
}
const { lyrics, currentLyricIdx } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamLyricHovering = ref(false);
const roamLyricPadPx = ref(0);
const roamSong = computed(() => player.currentSong);
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;
}
});
const roamArtists = computed(() => {
if (!roamSong.value) return '';
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
});
watch(currentLyricIdx, () => {
if (player.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
function scrollToRoamActiveLyric() {
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;
if (active) {
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function getRoamLyricClass(idx: number): string {
const diff = Math.abs(idx - currentLyricIdx.value);
if (idx === currentLyricIdx.value) {
return 'roam-lyric-active text-accent-text font-semibold text-xl';
}
if (diff === 1) return 'text-content/70 text-lg';
if (diff === 2) return 'text-content-2/50 text-base';
return 'text-content-3/35 text-base';
}
function seekToRoamLyric(time: number) {
if (time != null && player.duration > 0) {
player.seek(time);
}
}
async function openRoamFromSidebar() {
if (player.isFmMode) {
player.openRoamDrawer();
} else {
await player.loadFm();
}
}
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).slice(1);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch (e) { /* 忽略 */ }
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function isPlaylistActive(id: number): boolean {
return route.name === 'playlist' && Number(route.params.id) === id;
}
watch(() => userStore.isLoggedIn, (val) => {
if (val) {
loadPlaylists();
player.loadLikedIds();
}
});
onMounted(async () => {
document.addEventListener('contextmenu', (e) => e.preventDefault());
if (userStore.isLoggedIn) {
loadPlaylists();
player.loadLikedIds();
}
try { await invoke('stop_audio'); } catch {}
try { await AudioApi.stopAudio(); } catch { /* 忽略 */ }
try {
const jsonStr: string = await invoke('get_login_status');
const jsonStr: string = await MusicApi.getLoginStatus();
const data = JSON.parse(jsonStr);
if (data.account || data.profile) {
const profile = data.profile || data.account;
@ -375,34 +99,38 @@ onMounted(async () => {
avatarUrl: profile.avatarUrl,
});
}
} catch {}
} catch { /* 忽略 */ }
updater.checkForUpdate(true);
if (settings.outputDevice) {
try {
await 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') {
closeDontAskAgain.value = false;
showCloseModal.value = true;
} else if (settings.closeAction === 'minimize') {
currentWindow.hide();
} else {
invoke('exit_app');
AppApi.exitApp();
}
}
function handleCloseAction(action: CloseAction) {
if (closeDontAskAgain.value) {
function handleCloseAction(action: CloseAction, remember: boolean) {
if (remember) {
settings.setCloseAction(action);
}
showCloseModal.value = false;
if (action === 'minimize') {
currentWindow.hide();
} else {
invoke('exit_app');
AppApi.exitApp();
}
}
@ -416,14 +144,62 @@ onMounted(() => {
const unlisten3 = listen('tray-prev', () => {
player.prev();
});
const unlisten4 = listen('window-hidden', () => {
windowVisible.value = false;
keepAliveInclude.value = [];
});
const unlisten5 = listen('window-shown', () => {
windowVisible.value = true;
keepAliveInclude.value = ['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView'];
});
onBeforeUnmount(() => {
unlisten1.then(fn => fn());
unlisten2.then(fn => fn());
unlisten3.then(fn => fn());
unlisten4.then(fn => fn());
unlisten5.then(fn => fn());
});
});
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 { /* 忽略 */ }
}
}
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;
@ -432,13 +208,26 @@ onMounted(() => {
e.preventDefault();
player.toggle();
}
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowRight') {
e.preventDefault();
player.next();
}
if ((e.ctrlKey || e.metaKey) && e.code === 'ArrowLeft') {
e.preventDefault();
player.prev();
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);
@ -447,23 +236,3 @@ onMounted(() => {
});
});
</script>
<style>
.drawer-enter-active,
.drawer-leave-active { transition: transform 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { transform: translateY(100%); }
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
.roam-lyric-line:hover {
background: var(--c-subtle);
color: var(--c-content) !important;
}
.roam-lyric-active:hover {
background: var(--c-subtle) !important;
color: var(--c-content) !important;
}
</style>

196
src/api.ts Normal file
View File

@ -0,0 +1,196 @@
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 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 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 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 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');
}
}

View 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>

View 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>

View File

@ -2,23 +2,23 @@
<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] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]"
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>{{ currentLabel }}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }"><polyline points="6 9 12 15 18 9"/></svg>
<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 overflow-hidden">
<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"
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>{{ label }}</span>
<svg v-if="modelValue === key" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span class="truncate">{{ label }}</span>
<IconCheck v-if="modelValue === key" style="font-size: 14px" />
</button>
</div>
</Transition>
@ -27,6 +27,8 @@
<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;

View File

@ -1,58 +1,83 @@
<template>
<div
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line z-50 select-none">
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-muted 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-emphasis rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<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>
<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.toggleRoamDrawer()" title="全屏展示" />
<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">{{ player.currentSong?.name }}</p>
<p class="text-xs text-content-2 truncate">
{{player.currentSong?.ar?.map((a: any) => a.name).join('/')}}
<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' : 'text-content-3 hover:text-danger'">
<svg v-if="player.currentSong && player.isLiked(player.currentSong.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<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="[
'transition',
player.isFmMode ? 'text-content-4 cursor-not-allowed' : 'text-content-2 hover:text-content',
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'),
]">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="19" x2="5" y2="5"/></svg>
<IconSkipBack class="w-5 h-5" />
</button>
<button @click="player.toggle()"
class="w-9 h-9 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis">
<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="text-content-2 hover:text-content transition">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
<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>
</div>
<div class="flex items-center gap-2 text-xs text-content-2">
<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>
@ -61,76 +86,173 @@
<div class="w-56 flex justify-end items-center gap-2">
<div class="flex items-center gap-1">
<button @click="toggleMute" class="text-content-2 hover:text-content transition">
<svg v-if="volume === 0" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
<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="volume"
<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-emphasis 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-content-2 hover:text-content transition" :title="modeTitle">
<svg v-if="player.playMode === 'loop'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
<svg v-else-if="player.playMode === 'shuffle'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="11" y="15" font-size="8" fill="currentColor" stroke="none" font-weight="bold">1</text></svg>
<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-content-2 hover:text-content transition relative" title="播放列表">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
<span v-if="player.queue.length > 0"
class="absolute -top-1 -right-1 bg-accent text-content 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-line bg-surface/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-danger hover:text-danger 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-accent-dim text-content' : 'hover:bg-subtle text-content-2',
]">
<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-content-3 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-content-3 hover:text-danger transition text-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<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">
<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>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format';
import { invoke } from '@tauri-apps/api/core';
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 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;
@ -145,7 +267,17 @@ onBeforeUnmount(() => {
if (unlistenCache) unlistenCache();
});
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
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'];
@ -154,13 +286,13 @@ function togglePlayMode() {
}
function toggleMute() {
if (volume.value > 0) {
prevVolume.value = volume.value;
volume.value = 0;
if (player.volume > 0) {
prevVolume.value = player.volume;
player.volume = 0;
} else {
volume.value = prevVolume.value || 100;
player.volume = prevVolume.value || 100;
}
invoke('set_volume', { vol: volume.value / 100 });
AudioApi.setVolume(player.volume / 100);
}
let onDocMove: ((e: MouseEvent) => void) | null = null;
@ -218,31 +350,85 @@ function playFromQueue(index: number) {
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, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${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}%)`;
});
</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;
@ -279,4 +465,23 @@ const volumeBarBg = computed(() => {
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.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>

View File

@ -0,0 +1,245 @@
<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="roamTab = 'lyric'"
class="px-3 py-1 rounded-full text-sm transition"
:class="player.dominantColor
? (roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
: (roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
歌词
</button>
<button @click="roamTab = 'comment'"
class="px-3 py-1 rounded-full text-sm transition"
:class="player.dominantColor
? (roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
: (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="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="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 roamTab = ref<'lyric' | 'comment'>('lyric');
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) {
roamTab.value = player.roamInitialTab;
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>

184
src/components/Sidebar.vue Normal file
View File

@ -0,0 +1,184 @@
<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>
</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';
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>

View 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>

View 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>

View 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>

View 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>

View File

@ -1,28 +1,40 @@
import { ref, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
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 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 });
@ -34,8 +46,15 @@ export function useLyric() {
}
});
function toggleTranslation() {
showTranslation.value = !showTranslation.value;
}
return {
lyrics,
currentLyricIdx,
hasTranslation,
showTranslation,
toggleTranslation,
};
}
}

View 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,
};
}

View 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 };
}

View 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;
}

View 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,
}
}

View File

@ -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);

View File

@ -6,23 +6,39 @@ import Login from '@/views/Login.vue';
import FavoriteSongs from '@/views/FavoriteSongs.vue';
import RecentPlays from '@/views/RecentPlays.vue';
import DailySongs from '@/views/DailySongs.vue';
import LocalMusic from '@/views/LocalMusic.vue';
import Settings from '@/views/Settings.vue';
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/discover', name: 'discover', component: Discover },
{ path: '/search', name: 'search', component: 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: '/local-music', name: 'local-music', component: LocalMusic },
{ path: '/login', name: 'login', component: Login, meta: { guest: true } },
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
{ 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: Settings },
];
export default createRouter({
const router = createRouter({
history: createWebHistory(),
routes,
});
});
router.beforeEach((to) => {
if (to.meta.guest) {
const raw = localStorage.getItem('user');
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
View File

@ -0,0 +1,5 @@
declare module '~icons/lucide/*' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -1,55 +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, nextTick } 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;
// 兼容不同接口返回的可选字段
album?: { picUrl?: string };
artists?: { name: string }[];
duration?: number; // 某些接口的时长字段(单位可能是秒)
}
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(),或者在应用入口调用
import { listen, emit } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
function loadRecentLocal(): Song[] {
try {
const raw = localStorage.getItem('recent_local');
if (raw) return JSON.parse(raw);
} catch {}
} catch { /* 忽略 */ }
return [];
}
function loadLikedIdsFromStorage(): Set<number> {
try {
const raw = localStorage.getItem('liked_ids');
if (raw) return new Set(JSON.parse(raw));
} catch {}
return new Set();
}
export const usePlayerStore = defineStore('player', () => {
const currentSong = ref<Song | null>(null);
const playing = ref(false);
@ -59,12 +29,38 @@ 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>>(loadLikedIdsFromStorage());
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);
@ -74,18 +70,20 @@ export const usePlayerStore = defineStore('player', () => {
const userStore = useUserStore();
if (!userStore.isLoggedIn) return;
try {
const json: string = await invoke('likelist', { uid: userStore.user!.userId });
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 { /* 忽略 */ }
} catch (e) {
console.error('加载喜欢列表失败', e);
}
}
async function toggleLike(songId: number) {
const wasLiked = likedIds.value.has(songId);
const newLike = !wasLiked;
try {
await invoke('like_song', { query: { id: songId, like: newLike ? 'true' : 'false' } });
await MusicApi.likeSong(songId, newLike);
if (newLike) {
likedIds.value.add(songId);
} else {
@ -107,13 +105,36 @@ export const usePlayerStore = defineStore('player', () => {
localStorage.setItem('recent_local', JSON.stringify(val));
}, { deep: true });
watch(likedIds, (val) => {
localStorage.setItem('liked_ids', 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) {
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;
@ -122,14 +143,51 @@ 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) {
clearTick();
reportScrobble();
if (!song.dt || song.dt === 0) {
try {
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
const jsonStr = await MusicApi.getSongDetail(String(song.id));
const data = JSON.parse(jsonStr);
const full = data.songs?.[0];
if (full) {
@ -137,38 +195,75 @@ 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 settings = useSettingsStore();
const url: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality, fm_mode: true });
const data = JSON.parse(jsonStr);
const url: string | undefined = data.url;
if (!url) throw new Error('无播放源');
await invoke('play_audio', { url });
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;
}
if (fmNextCallback) {
fmNextCallback();
} else {
disableFmMode();
}
return;
}
fmVipSkipCount = 0;
await AudioApi.playAudio(url);
await waitForAudioStart();
playing.value = true;
duration.value = (song.dt || 0) / 1000;
currentTime.value = 0;
startTick();
addRecent(song);
emitPlaybackState();
} catch (e) {
console.error('FM播放失败', e);
playing.value = false;
showToast('FM 播放失败', 'error');
if (fmNextCallback) {
fmNextCallback();
} else {
disableFmMode();
}
}
}
// 播放指定歌曲(如果不在队列中则加入并切换)
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 {
@ -177,7 +272,41 @@ 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;
function waitForAudioStart(): Promise<void> {
return new Promise<void>((resolve) => {
_audioStartedResolve = resolve;
});
}
async function playCurrent() {
clearTick();
reportScrobble();
const song = queue.value[currentIndex.value];
if (!song?.id) {
console.error('无效的歌曲数据', song);
@ -185,59 +314,121 @@ export const usePlayerStore = defineStore('player', () => {
}
try {
// 重置状态
currentSong.value = song;
playing.value = false;
currentTime.value = 0;
duration.value = (song.dt || 0) / 1000;
const settings = useSettingsStore();
const url: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
if (song.localPath) {
await AudioApi.playLocalAudio(song.localPath);
await waitForAudioStart();
playing.value = true;
startTick();
addRecent(song);
emitPlaybackState();
return;
}
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality });
const data = JSON.parse(jsonStr);
const url: string | undefined = data.url;
if (!url) {
console.error('未获取到有效播放地址', song);
return;
}
await invoke('play_audio', { url });
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;
}
next();
return;
}
await AudioApi.playAudio(url);
await waitForAudioStart();
playing.value = true;
startTick();
addRecent(song);
vipSkipCount = 0;
emitPlaybackState();
} catch (e) {
console.error('播放失败', e);
playing.value = false;
showToast('播放失败,请稍后重试', 'error');
}
}
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;
setTick(setInterval(async () => {
if (playing.value && duration.value > 0) {
currentTime.value += 0.25;
if (currentTime.value >= duration.value) {
if (seekGuard) return;
syncCounter++;
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 { /* 忽略 */ }
} 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() {
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();
}
@ -248,7 +439,6 @@ export const usePlayerStore = defineStore('player', () => {
playCurrent();
}
// 批量添加歌曲到队列并播放第一首(用于“播放全部”)
async function playAll(songs: Song[]) {
if (songs.length === 0) return;
queue.value = [...songs];
@ -260,22 +450,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;
}
@ -290,22 +475,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();
@ -316,11 +509,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 {
@ -331,7 +522,6 @@ export const usePlayerStore = defineStore('player', () => {
break;
case 'loop':
default:
// 顺序循环
nextIndex = (currentIndex.value + 1) % queue.value.length;
break;
}
@ -340,9 +530,19 @@ export const usePlayerStore = defineStore('player', () => {
}
const showRoamDrawer = ref(false);
const roamInitialTab = ref<'lyric' | 'comment'>('lyric');
const commentSongId = ref<number | null>(null);
const dominantColor = ref('');
function openRoamDrawer() {
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
roamInitialTab.value = tab;
showRoamDrawer.value = true;
nextTick(() => { roamInitialTab.value = 'lyric'; });
}
function openCommentForSong(songId: number) {
commentSongId.value = songId;
openRoamDrawer('comment');
}
function closeRoamDrawer() {
@ -355,58 +555,55 @@ export const usePlayerStore = defineStore('player', () => {
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;
@ -415,20 +612,74 @@ async function toggleFm() {
}
async function nextFm() {
await loadFm(); // 加载下一首 FM 歌曲
await loadFm();
}
// 监听全局播放变化,若用户选择了非 FM 歌曲,自动退出 FM 状态
let _audioStartedResolve: (() => void) | null = null;
listen('audio-started', () => {
if (_audioStartedResolve) {
_audioStartedResolve();
_audioStartedResolve = null;
}
});
listen('audio-ended', () => {
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 {
@ -451,6 +702,7 @@ watch(playing, (val) => {
playFmSong,
setPlayMode,
play,
playFromList,
playAll,
toggle,
stop,
@ -458,6 +710,8 @@ watch(playing, (val) => {
next,
seek,
playCurrent,
volume,
adjustVolume,
removeFromQueue,
clearQueue,
@ -470,15 +724,27 @@ watch(playing, (val) => {
toggleLike,
showRoamDrawer,
roamInitialTab,
commentSongId,
dominantColor,
openCommentForSong,
openRoamDrawer,
closeRoamDrawer,
toggleRoamDrawer,
loadFirstFmSong,
fetchFmBatch,
clearFmQueue,
fmTrash,
reportScrobble,
clearTick,
fmSong,
fmPlaying,
fmMode,
fmSubMode,
loadFm,
toggleFm,
nextFm,
};
});
});

View File

@ -1,10 +1,36 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
export type ThemeMode = 'dark' | 'light';
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: '较高',
@ -19,23 +45,77 @@ export const closeActionLabels: Record<CloseAction, string> = {
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;
theme: ThemeMode;
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) return JSON.parse(raw);
} catch {}
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 || '',
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 || '',
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: '',
theme: 'dark',
theme: 'blue',
appearance: 'dark',
closeAction: 'ask',
shortcuts: { ...defaultShortcuts },
outputDevice: null,
volume: 100,
};
}
@ -44,8 +124,16 @@ export const useSettingsStore = defineStore('settings', () => {
const audioQuality = ref<AudioQuality>(saved.audioQuality);
const downloadPath = ref<string>(saved.downloadPath);
const theme = ref<ThemeMode>(saved.theme);
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;
@ -55,20 +143,51 @@ export const useSettingsStore = defineStore('settings', () => {
downloadPath.value = p;
}
function setTheme(t: ThemeMode) {
function setTheme(t: ThemeColor) {
theme.value = t;
}
function setAppearance(a: Appearance) {
appearance.value = a;
}
function setCloseAction(a: CloseAction) {
closeAction.value = a;
}
watch([audioQuality, downloadPath, theme, closeAction], () => {
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 = '';
theme.value = 'blue';
appearance.value = 'dark';
closeAction.value = 'ask';
shortcuts.value = { ...defaultShortcuts };
outputDevice.value = null;
volume.value = 100;
}
watch([audioQuality, downloadPath, theme, appearance, closeAction, shortcuts, outputDevice, volume], () => {
const data: SettingsData = {
audioQuality: audioQuality.value,
downloadPath: downloadPath.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 });
@ -77,10 +196,20 @@ export const useSettingsStore = defineStore('settings', () => {
audioQuality,
downloadPath,
theme,
appearance,
dataTheme,
closeAction,
shortcuts,
outputDevice,
volume,
setAudioQuality,
setDownloadPath,
setTheme,
setAppearance,
setCloseAction,
setOutputDevice,
setShortcut,
resetShortcuts,
resetAll,
};
});

View File

@ -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');

View File

@ -23,18 +23,19 @@
}
@layer base {
:root {
--c-bg: #030712;
--c-surface: #111827;
--c-subtle: rgba(255, 255, 255, 0.05);
--c-muted: rgba(255, 255, 255, 0.10);
--c-emphasis: rgba(255, 255, 255, 0.18);
: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.10);
--c-line-2: rgba(255, 255, 255, 0.05);
--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;
@ -45,24 +46,288 @@
--c-info: #3b82f6;
}
[data-theme="light"] {
--c-bg: #f3f4f6;
[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(0, 0, 0, 0.04);
--c-muted: rgba(0, 0, 0, 0.08);
--c-emphasis: rgba(0, 0, 0, 0.12);
--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.10);
--c-line-2: rgba(0, 0, 0, 0.05);
--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: #16a34a;
--c-accent-dim: rgba(22, 163, 74, 0.15);
--c-accent-text: #15803d;
--c-accent-dim: rgba(34, 197, 94, 0.15);
--c-danger: #dc2626;
--c-danger-dim: rgba(220, 38, 38, 0.15);
--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;
}
@ -78,11 +343,19 @@
@apply antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
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 {

View File

@ -12,9 +12,18 @@ export function formatTime(sec: number): string {
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 >= 100000000) return (count / 100000000).toFixed(1) + '亿';
if (count >= 10000) return (count / 10000).toFixed(1) + '万';
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')}`;
}

View File

@ -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;
}
}

View File

@ -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 || [];
}
// 时长:只保留合理的 dt100ms ~ 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;
});
}

105
src/views/AlbumDetail.vue Normal file
View File

@ -0,0 +1,105 @@
<template>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div v-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="loading" 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>
<script setup lang="ts">
import { ref, onMounted, watch } 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 SongListItem from '../components/SongListItem.vue';
import IconPlay from '~icons/lucide/play';
const route = useRoute();
const router = useRouter();
const player = usePlayerStore();
const album = ref<any>(null);
const songs = ref<Song[]>([]);
const loading = ref(true);
async function fetchAlbum(id: number) {
loading.value = true;
album.value = null;
songs.value = [];
try {
const jsonStr: string = await MusicApi.albumDetail(id);
const data = JSON.parse(jsonStr);
album.value = data.album;
songs.value = (data.songs || []).map(normalizeSong);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchAlbum(Number(route.params.id));
});
watch(() => route.params.id, (newId) => {
if (newId) fetchAlbum(Number(newId));
});
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
</script>

150
src/views/ArtistDetail.vue Normal file
View File

@ -0,0 +1,150 @@
<template>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div v-if="artist" class="flex gap-6 mb-8">
<img :src="artist.cover" 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">{{ artist.name }}</h1>
<p class="text-xs text-content-3 mt-2">
{{ formatPlayCount(artist.followeds || 0) }} 粉丝 · {{ artist.musicSize || 0 }} 首歌曲
</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 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="loading" class="text-content-2">加载中...</div>
<template v-else>
<div v-if="activeTab === 'songs'" 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-if="activeTab === 'albums'" 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 v-if="activeTab === 'desc'" class="max-w-2xl">
<p class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } 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 SongListItem from '../components/SongListItem.vue';
import IconPlay from '~icons/lucide/play';
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 loading = ref(true);
const activeTab = ref('songs');
const tabs = [
{ key: 'songs', label: '热门歌曲' },
{ key: 'albums', label: '专辑' },
{ key: 'desc', label: '简介' },
];
async function fetchArtist(id: number) {
loading.value = true;
artist.value = null;
songs.value = [];
albums.value = [];
briefDesc.value = '';
try {
const [detailStr, songsStr, albumStr, descStr] = await Promise.all([
MusicApi.artistDetail(id),
MusicApi.artistSongs({ id, order: 'hot', limit: 50, offset: 0 }),
MusicApi.artistAlbum(id, 30, 0),
MusicApi.artistDesc(id),
]);
const detailData = JSON.parse(detailStr);
artist.value = detailData.artist;
const songsData = JSON.parse(songsStr);
songs.value = (songsData.songs || []).map(normalizeSong);
const albumData = JSON.parse(albumStr);
albums.value = albumData.hotAlbums || [];
const descData = JSON.parse(descStr);
briefDesc.value = descData.briefDesc || '';
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchArtist(Number(route.params.id));
});
watch(() => route.params.id, (newId) => {
if (newId) fetchArtist(Number(newId));
});
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
</script>

View File

@ -15,66 +15,75 @@
</div>
<div v-if="loading" class="text-content-2">加载中...</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-subtle transition cursor-pointer group"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
>
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrentSong(song.id)" 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>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<span class="text-xs text-content-3">{{ 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 { usePlayerStore } from '../stores/player';
import { formatDuration } from '../utils/format';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { normalizeSong, type Song } from '../utils/song';
import { useOnlineStatus } from '../composables/useOnlineStatus';
defineOptions({ name: 'DailySongsView' });
const player = usePlayerStore();
const songs = ref<any[]>([]);
const { isOnline } = useOnlineStatus();
const songs = ref<Song[]>([]);
const loading = ref(true);
function isCurrentSong(songId: number): boolean {
return player.currentSong?.id === songId;
}
onMounted(async () => {
async function loadData() {
const cached = pageCacheGet('dailySongs');
if (cached) {
songs.value = cached;
loading.value = false;
return;
}
loading.value = true;
try {
const jsonStr: string = await invoke('recommend_songs');
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);
} finally {
loading.value = false;
}
}
onMounted(loadData);
onActivated(() => {
if (pageCacheIsStale('dailySongs')) loadData();
});
watch(isOnline, (val, old) => {
if (val && !old && songs.value.length === 0) {
pageCacheInvalidate('dailySongs');
loadData();
}
});
</script>

View File

@ -1,16 +1,56 @@
<template>
<div class="p-8 text-content">
<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-muted p-3 text-content placeholder-content-2 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-muted hover:bg-emphasis 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-content-2">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-muted 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-content-2">搜索中...</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-subtle hover:bg-muted border border-line-2 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-content-2">
{{ 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-content-2">无结果</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>

View File

@ -3,13 +3,14 @@
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div class="flex items-center gap-4 mb-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
<button
v-if="songs.length"
@click="player.playAll(songs)"
class="px-4 py-1.5 bg-muted hover:bg-emphasis rounded-full text-sm transition"
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>
@ -18,51 +19,59 @@
</div>
<div v-else-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
<div v-else class="space-y-2">
<div
<div v-else class="space-y-1">
<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-subtle transition cursor-pointer"
>
<span class="text-xs text-content-3 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-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
: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 } 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 { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { normalizeSong } from '../utils/song';
import { formatDuration } from '../utils/format';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
import IconPlay from '~icons/lucide/play';
defineOptions({ name: 'FavoriteSongsView' });
const player = usePlayerStore();
const userStore = useUserStore();
const songs = ref<any[]>([]);
const { isOnline } = useOnlineStatus();
const songs = ref<Song[]>([]);
const loading = ref(true);
onMounted(async () => {
async function loadData() {
if (!userStore.isLoggedIn) {
loading.value = false;
return;
}
const cached = pageCacheGet('favoriteSongs');
if (cached) {
songs.value = cached;
loading.value = false;
return;
}
loading.value = true;
try {
const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId });
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) {
@ -70,13 +79,27 @@ onMounted(async () => {
return;
}
const likePlaylistId = created[0].id;
const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } });
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);
} finally {
loading.value = false;
}
}
onMounted(loadData);
onActivated(() => {
if (pageCacheIsStale('favoriteSongs')) loadData();
});
watch(isOnline, (val, old) => {
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
pageCacheInvalidate('favoriteSongs');
loadData();
}
});
</script>

View File

@ -11,9 +11,9 @@
<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>
@ -33,15 +33,15 @@
<div class="relative z-10 h-full flex flex-col justify-between p-6">
<div class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white/50"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
<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" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
<h2 class="text-xl font-bold" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
<h2 class="text-lg font-bold truncate" v-else>{{ fmDisplayName }}</h2>
<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>
@ -50,24 +50,17 @@
<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">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
<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">
<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>
<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">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-white"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
<IconSkipForward class="w-[14px] h-[14px] text-white" />
</button>
</template>
</div>
@ -80,9 +73,9 @@
<!-- 第二行为你推荐需登录 -->
<div v-if="userStore.isLoggedIn && recPlaylists.length" 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 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-subtle rounded-xl overflow-hidden hover:bg-muted 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>
@ -95,9 +88,9 @@
<!-- 第三行热门歌单排行榜 -->
<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 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-subtle rounded-xl overflow-hidden hover:bg-muted 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>
@ -109,15 +102,21 @@
</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 recPlaylists = ref<any[]>([]);
@ -125,20 +124,22 @@ 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() {
if (!player.fmSong) {
await player.loadFm();
@ -159,13 +160,16 @@ function onFmCardClick() {
player.openRoamDrawer();
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;
async function loadData() {
const cached = pageCacheGet('home');
if (cached) {
rankPlaylists.value = cached.rankPlaylists || [];
recPlaylists.value = cached.recPlaylists || [];
return;
}
// 排行榜
const results = await Promise.allSettled(
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
);
rankPlaylists.value = results
.filter(r => r.status === 'fulfilled')
@ -175,13 +179,31 @@ onMounted(async () => {
})
.filter(Boolean);
// 推荐歌单(需登录)
if (userStore.isLoggedIn) {
try {
const json = await invoke('recommend_resource');
const json = await MusicApi.recommendResource();
const data = JSON.parse(json as string);
recPlaylists.value = data.recommend || [];
} catch { }
} catch { /* 忽略 */ }
}
pageCacheSet('home', { rankPlaylists: rankPlaylists.value, recPlaylists: recPlaylists.value });
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;
await loadData();
});
onActivated(() => {
if (pageCacheIsStale('home')) loadData();
});
watch(isOnline, (val, old) => {
if (val && !old && rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
pageCacheInvalidate('home');
loadData();
}
});

218
src/views/LocalMusic.vue Normal file
View File

@ -0,0 +1,218 @@
<template>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div class="flex items-center gap-4 mb-6">
<h1 class="text-2xl font-bold">本地音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<button
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
</div>
<div v-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-3">
当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式
</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 { MusicApi, 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 SongListItem from '../components/SongListItem.vue';
import IconEllipsis from '~icons/lucide/ellipsis';
import IconTrash2 from '~icons/lucide/trash-2';
import type { Song } from '../utils/song';
defineOptions({ name: 'LocalMusicView' });
const player = usePlayerStore();
const download = useDownload();
const settings = useSettingsStore();
interface LocalSong {
id: number;
name: string;
artist: string;
album: string;
duration: number;
cover: string | null;
filename: string;
fileSize: number;
path: string;
local: boolean;
}
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(toSong));
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;
pageCacheInvalidate('localMusic');
try {
const list = await DownloadApi.listLocalSongs(settings.downloadPath || null);
songs.value = list;
pageCacheSet('localMusic', list);
fetchMissingCovers();
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
async function fetchMissingCovers() {
const missing = songs.value.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 { /* 忽略 */ }
}
onMounted(refresh);
onActivated(() => {
if (pageCacheIsStale('localMusic')) refresh();
});
watch(() => settings.downloadPath, () => { refresh(); });
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
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];
}
function toSong(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,
};
}
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);
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>

View File

@ -20,7 +20,7 @@
<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';
@ -52,7 +52,7 @@ async function refreshQr() {
qrError.value = '';
if (pollTimer) clearInterval(pollTimer);
try {
qrKey = await invoke('get_qr_key');
qrKey = await MusicApi.getQrKey();
if (!qrKey) {
qrError.value = '未获取到登录密钥';
qrLoading.value = false;
@ -76,7 +76,7 @@ async function refreshQr() {
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) {
@ -88,7 +88,7 @@ function startPolling() {
statusColor.value = 'text-content-2';
} else if (code === 802) {
statusText.value = '请在手机上确认登录';
statusColor.value = 'text-warning';
statusColor.value = 'text-yellow-400';
} else if (code === 803) {
clearInterval(pollTimer!);
statusText.value = '登录成功!';
@ -104,7 +104,7 @@ function startPolling() {
async function fetchUserProfile() {
try {
const profileJson: string = await invoke('get_login_status');
const profileJson: string = await MusicApi.getLoginStatus();
const profile = JSON.parse(profileJson);
if (profile.profile) {
userStore.setUser({

View File

@ -23,7 +23,7 @@
@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"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
<IconPlay class="w-4 h-4 fill-current" />
播放全部
</button>
<button
@ -32,10 +32,7 @@
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'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path v-if="subscribed" d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
<path v-else d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
</svg>
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
{{ subscribed ? '已收藏' : '收藏歌单' }}
</button>
</div>
@ -45,39 +42,25 @@
<div v-if="loading" class="text-content-2">加载中...</div>
<div v-else class="space-y-1">
<div
<SongListItem
v-for="(song, index) in songs"
:key="song.id"
@click="playSingle(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
>
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrentSong(song.id)" 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>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
: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-if="playlist" class="mt-8">
<CommentSection :type="2" :id="Number(route.params.id)" />
</div>
</div>
</template>
@ -85,18 +68,23 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } 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 { formatDuration, formatPlayCount } from '../utils/format';
import { formatPlayCount } from '../utils/format';
import { normalizeSong, type Song } from '../utils/song';
import SongListItem from '../components/SongListItem.vue';
import CommentSection from '../components/CommentSection.vue';
import IconPlay from '~icons/lucide/play';
import IconBookmark from '~icons/lucide/bookmark';
const route = useRoute();
const player = usePlayerStore();
const userStore = useUserStore();
const playlist = ref<any>(null);
const songs = ref<any[]>([]);
const songs = ref<Song[]>([]);
const loading = ref(true);
const subscribed = ref(false);
@ -110,10 +98,10 @@ async function fetchPlaylist(id: number) {
playlist.value = null;
songs.value = [];
try {
const jsonStr: string = await invoke('get_playlist_detail', { id });
const jsonStr: string = await MusicApi.getPlaylistDetail(id);
const data = JSON.parse(jsonStr);
playlist.value = data.playlist;
songs.value = data.playlist.tracks || [];
songs.value = (data.playlist.tracks || []).map(normalizeSong);
subscribed.value = data.playlist.subscribed || false;
} catch (e) {
console.error(e);
@ -131,14 +119,6 @@ watch(() => route.params.id, (newId) => {
if (newId) fetchPlaylist(Number(newId));
});
function isCurrentSong(songId: number): boolean {
return player.currentSong?.id === songId;
}
async function playSingle(song: any) {
player.play(song);
}
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
@ -148,7 +128,7 @@ async function toggleSubscribe() {
if (!playlist.value) return;
const newSubscribed = !subscribed.value;
try {
await invoke('playlist_subscribe', { query: { id: Number(playlist.value.id), subscribe: newSubscribed } });
await MusicApi.playlistSubscribe(Number(playlist.value.id), newSubscribed);
subscribed.value = newSubscribed;
showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success');
} catch {

View File

@ -6,33 +6,28 @@
<h1 class="text-2xl font-bold mb-6">最近播放</h1>
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录去听首歌吧</div>
<div v-else class="space-y-2">
<div
<SongListItem
v-for="(song, index) in player.recentLocal"
:key="song.id"
@click="player.play(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
>
<span class="text-xs text-content-3 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-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt ?? 0) }}</span>
</div>
: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>
<script setup lang="ts">
import { usePlayerStore } from '../stores/player';
import { formatDuration } from '../utils/format';
import SongListItem from '../components/SongListItem.vue';
const player = usePlayerStore();
</script>

View File

@ -1,93 +0,0 @@
<template>
<div class="p-8 text-content flex flex-col items-center justify-center min-h-full">
<div v-if="!currentSong" class="text-center">
<p class="text-content-2 mb-4">私人漫游未启动</p>
<button
@click="startFm"
class="px-6 py-2 bg-muted hover:bg-emphasis 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-content-2 mb-8">
{{ artists }}
</p>
<div class="flex items-center gap-8">
<button
@click="player.toggle()"
class="w-16 h-16 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis"
>
<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-content-2 hover:text-content transition"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
</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';
import { normalizeSong } from '../utils/song';
const player = usePlayerStore();
const currentSong = computed(() => {
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(' / ') || '';
});
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);
}
}
async function nextSong() {
await startFm();
}
</script>

View File

@ -1,92 +0,0 @@
<template>
<div class="text-content">
<h1 class="text-2xl font-bold mb-4">搜索</h1>
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲..."
class="mb-6 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
/>
<div v-if="loading" class="text-content-2">搜索中...</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-subtle hover:bg-muted border border-line-2 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-content-2">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</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();
watch(
() => route.query.q,
(newQ) => {
if (newQ) {
keyword.value = newQ as string;
handleSearch();
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[]>([]);
onMounted(async () => {
devices.value = await invoke('get_output_devices');
});
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="p-8 text-content max-w-2xl">
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
@ -8,6 +8,14 @@
<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>
@ -21,20 +29,34 @@
<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>
<div class="flex bg-subtle rounded-lg p-0.5">
<div>
<p class="text-sm font-medium mb-3">外观模式</p>
<div class="flex gap-3">
<button
v-for="t in themeOptions"
:key="t.value"
@click="settings.setTheme(t.value)"
class="px-3 py-1.5 rounded-md text-sm transition"
:class="settings.theme === t.value ? 'bg-muted text-content' : 'text-content-3 hover:text-content-2'"
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'"
>
{{ t.label }}
<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>
@ -64,28 +86,89 @@
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
</div>
</div>
<div class="flex gap-2">
<input
v-model="downloadPathInput"
type="text"
placeholder="例如:~/Music/Nekosonic"
class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content placeholder-content-4 outline-none focus:border-accent/50 transition"
/>
<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="saveDownloadPath"
class="px-4 py-2 bg-accent-dim hover:bg-accent-dim text-accent-text rounded-lg text-sm transition"
@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://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music')"
<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>
@ -96,35 +179,139 @@
<p class="text-xs text-content-3 leading-relaxed">
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端基于 Tauri 2 + Vue 3 构建提供轻量流畅的音乐播放体验
</p>
<button
@click="checkUpdate"
:disabled="checkingUpdate"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg v-if="!checkingUpdate" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9m-9 9a9 9 0 019-9"/></svg>
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
{{ checkingUpdate ? '检查中...' : '检查更新(暂未实现)' }}
</button>
<p v-if="updateMessage" class="text-xs" :class="updateMessageClass">{{ updateMessage }}</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 } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, type CloseAction } from '../stores/settings';
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 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({
@ -132,33 +319,134 @@ const closeActionValue = computed({
set: (val: CloseAction) => settings.setCloseAction(val),
});
const downloadPathInput = ref(settings.downloadPath);
const checkingUpdate = ref(false);
const updateMessage = ref('');
const updateMessageClass = ref('text-content-2');
const themeOptions = [
{ label: '深色', value: 'dark' as const },
{ label: '浅色', value: 'light' as const },
];
function saveDownloadPath() {
settings.setDownloadPath(downloadPathInput.value.trim());
showToast('下载路径已保存', 'success');
}
async function checkUpdate() {
checkingUpdate.value = true;
updateMessage.value = '';
try {
await new Promise(r => setTimeout(r, 1500));
updateMessage.value = '当前已是最新版本';
updateMessageClass.value = 'text-accent-text';
} catch {
updateMessage.value = '检查更新失败,请稍后重试';
updateMessageClass.value = 'text-danger';
} finally {
checkingUpdate.value = false;
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>

View File

@ -7,4 +7,4 @@
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
}

View File

@ -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/**"],
},
},