28 Commits
Author SHA1 Message Date
atdunbg 263e9b31d9 feat: v0.8.0 详情页架构重构、皮肤系统、侧边栏交互升级、底层模块拆分
- 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画)
- 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索
- 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸)
- 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择)
- 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar)
- 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表
- audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store
- 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等
2026-06-26 21:05:51 +08:00
atdunbg dcfada6940 feat: 皮肤系统重构、seek暂停修复、本地音乐优化、外观一体化
- 重构皮肤系统:提取 skins.ts 管理预设皮肤,CSS 变量由 JS 动态设置

- 提取公共 color.ts 工具函数(hexToRgba/toHex),消除重复定义

- 修复 seek 时暂停状态丢失的 bug(后端 audio_paused 状态保留)

- 本地音乐页面:循环排序切换、三点菜单、打开所在文件夹

- 本地音乐文件夹管理:支持启用/禁用切换,兼容旧数据迁移

- 新增 show_item_in_folder 命令(Windows/macOS/Linux 跨平台)

- 外观一体化:有壁纸时 TitleBar/Sidebar 透明,PlayerBar 统一透明度+backdrop-blur

- 进度条外层直角、内层填充圆角

- 滚动条默认透明,悬停时显示

- 移除 PageHeader 粘性栏

- 内存优化:keep-alive TTL 5min、pageCache TTL 30min/上限30条、colorCache 上限200

- recentLocal 防抖写入、播放器 tick interval 500ms
2026-06-07 07:45:41 +08:00
atdunbg 3535e2e8a0 feat: 云盘/下载音乐分离/粘性头部/播放状态同步/歌手关注
新增:
- 音乐云盘页面(列表/详情弹窗/删除/存储空间, NOS multipart上传+LBS区域查询+进度事件)
- 下载音乐页面(独立于本地音乐, 只显示应用下载的歌曲)
- PageHeader粘性头部组件(IntersectionObserver控制显隐, 渐变模糊背景)
- useLocalMusic composable(LocalSong类型/formatFileSize/localSongToSong/fetchMissingCovers)
- 云盘上传完整流程(cloud_upload命令: check->token->LBS->NOS分块上传->info->publish)
- 云盘API(user_cloud/user_cloud_detail/user_cloud_del)
- 歌手关注/取关(artist_sub/artist_sublist命令, ArtistDetail关注按钮+artistSublist查询状态)
- 本地音乐多文件夹扫描(scan_local_folders命令, settings.localMusicPaths, 模态框管理)
- 侧边栏下载音乐和云盘导航项, 路由新增downloaded-music和cloud-music
- md5 crate依赖

改进:
- 路由全部改为懒加载
- keep-alive缓存管理重写(30s TTL+导航栈保护+FavoriteSongs常驻+10s定时清理)
- 播放器状态同步改为轮询isAudioPlaying(替代audio-started事件), 超时后watchForLatePlayback继续监听
- audio.rs新增is_playing原子状态+is_audio_playing命令
- 同步命令改async+spawn_blocking(list_local_songs/delete_local_song/check_local_song/get_default_download_path)
- scan_dir_for_songs抽取为公共函数, 新增downloaded_only参数
- RoamDrawer tab状态从组件本地ref移至store(roamTab替换roamInitialTab)
- App.vue onMounted改为非阻塞
- 多页面添加骨架屏加载态和加载失败重试
- 多页面使用PageHeader替代手动返回按钮
- PlaylistDetail/ArtistDetail添加简介弹窗(溢出时显示查看完整介绍)
- Home推荐/排行榜拆分为独立fetch函数支持分别重试
- Toast去重(3s窗口)+数量限制(最多3条)
- LocalMusic移除删除功能改文件夹模态框, ArtistDetail头像改圆形简介内嵌
- README重写

修复:
- 播放超时后后端实际开始播放但UI显示暂停(watchForLatePlayback+tick定期同步isAudioPlaying)
- FM播放缺少playSeq竞态保护
- scrobble离线时仍发送(添加navigator.onLine检查)
- RoamDrawer已打开时点击评论按钮无法切换(roamTab移至store)
- 关闭RoamDrawer后再打开永远显示评论(closeRoamDrawer重置roamTab)
- 歌手详情页关注状态离开后丢失(artist_detail不返回followed, 改用artistSublist查询)
- audio-ended事件在切歌时误触发(新增_switchingSong标志拦截)
- 路由beforeEach中localStorage key从user改为user_profile
- toggle播放前先同步后端状态
2026-06-04 07:36:00 +08:00
atdunbg 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
atdunbg 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
atdunbg 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
atdunbg 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
atdunbg 6da544cffb v0.5.1: 修复缓存/FM/翻译/Linux等问题 2026-05-25 19:38:48 +08:00
atdunbg 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
atdunbg 970fb15f5a 重构播放列表为右侧弹出式
播放列表可以定位正在播放的歌曲位置
添加歌词翻译
新增快捷键 播放/暂停
重构主题设置,支持多种主题

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

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

-
优化播放逻辑,歌曲列表在点击时候不在单首累加, 而是直接获取当前列表所有的歌曲作为播放内容
2026-05-16 00:11:37 +08:00
atdunbg 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
atdunbg 02f7df4201 设置应用为单例,防止多个实例同时存在 2026-05-12 06:31:37 +08:00
atdunbg 7847a9f6b2 feat: 跨平台持久化与版本管理优化
- Cookie 存储从 temp_dir 迁移至 Tauri app_data_dir,兼容 Linux
- 简单统一风格,UI优化
- recentLocal 播放历史持久化到 localStorage
- 添加设置界面可以修改简单的设置
2026-05-12 09:58:07 +08:00
104 changed files with 15695 additions and 2759 deletions
+81
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
+237
View File
@@ -0,0 +1,237 @@
## v0.8.0
本次版本为架构重构与交互升级版本,涵盖皮肤系统、详情页架构、侧边栏交互、播放器稳定性、底层模块拆分等方面的系统性优化。
### ✨ 新功能
- **皮肤系统重构**:新增 `skins.ts` 模块,定义 `SkinColors` 色板(bg/surface/subtle/muted/emphasis/content×4/line×2/accent×4/danger×2/warning/info 共 19 个语义色),通过 `applySkinColors()` 写入 CSS 变量到 `document.documentElement`。内置 14 套预设皮肤(7 色 × 深浅),支持自定义皮肤(颜色 + 壁纸 + 模糊度 + 透明度)
- **设置页皮肤模块**Settings.vue 新增「皮肤」分区——深浅外观切换、7 色主题色选择、自定义皮肤网格(带壁纸预览)、皮肤编辑器(19 个颜色色板 + 壁纸选择 + 模糊/透明度滑杆)。壁纸通过 Rust 命令读取本地图片转 data URL 渲染
- **顶栏导航**:新增 TopBar 组件,集成前进/后退按钮和搜索框,前进后退按钮仅在与主内容区对齐的位置显示,搜索可直接在顶栏输入,无需再点击搜索选项
- **可折叠侧边栏**:侧边栏支持展开/折叠/抽屉三种模式,折叠时仅显示图标,窗口过窄自动切换为抽屉模式。折叠/展开触发条采用 SPlayer 风格的双竖条设计,hover 时通过 ±12deg 旋转形成方向箭头(参考 naive-ui n-layout-toggle-bar
- **折叠态歌单浮层**:侧边栏折叠后,hover「我的歌单」「收藏的歌单」图标弹出浮层显示歌单列表,通过 Teleport 渲染到 body 避免被裁剪。浮层高度动态计算,底部永不越过 PlayerBar
- **首页内容扩展**:首页新增「新歌速递」「热门歌手」「新碟上架」三个区块,配合原有的「推荐歌单」「每日推荐」「热门歌单」,共 6 个内容区块
- **通用卡片组件**:新增 PlaylistCard、ArtistCard、AlbumCard、SectionHeader、CardGrid 五个可复用卡片组件,统一封面、标题、副标题、悬浮播放按钮的展示风格
- **虚拟歌曲列表**:新增 VirtualSongList 组件,复用 SongListItem 并支持自定义配置
- **推荐 API**:后端新增 personalized、personalized_newsong、top_artists、top_song、album_newest 五个 Tauri 命令,前端新增 rec.ts API 模块
- **详情页统一架构**:新增 DetailLayout 组件,统一歌单/专辑/歌手三个详情页布局——头部常驻 + 内容独立滚动 + 滚动触发紧凑态(参考 SPlayer ListDetail/useListScroll)。`absolute;inset:0` 脱离主滚动流,flex 列布局让 header `flex-shrink:0` 不被带走,content `flex-1 min-h:0` 独立滚动
- **紧凑态动画**:滚动列表超过 10px 时触发头部收缩,封面/标题缩小、元信息折叠隐藏、按钮缩小,所有过渡统一 `cubic-bezier(0.4, 0, 0.2, 1)` 0.3s,参考 SPlayer `.small` 类行为
- **歌曲/评论 tab 切换**:三个详情页头部均加入歌曲/评论(或热门歌曲/专辑)tab 切换。songs 用 `v-if` 仅激活时渲染,comments 用「首次激活后保活」模式(`v-if` 控制挂载 + `v-show` 控制显示),保留滚动位置且不提前发起评论 API 请求
- **专辑模糊搜索**:专辑详情页头部加入模糊搜索框,按歌名/歌手/专辑名实时过滤歌曲列表,播放全部时只播过滤结果
- **本地音乐多文件夹管理**:本地音乐支持添加多个扫描文件夹,每条可独立启用/禁用/移除,扫描时跳过禁用文件夹
### 🐛 修复
- **个性化推荐错乱**:首页聚合缓存在未登录时生成,登录后未清除导致「依据口味推荐」显示的全站数据。改为登录/登出时清除首页缓存并重新加载,区分登录态调用 `recommend_resource`(个性化)和 `personalized`(全站)
- **播放竞态条件**:切歌、播放/暂停、拖动进度条等异步操作可能产生竞态冲突。引入序列号(`_playSeq`)和状态锁(`_switchingSong`)防护
- **拖动进度条暂停修复**:seek 过程中状态不一致导致播放/暂停混乱
- **折叠态歌单不可用**:折叠时歌单图标用 popover 浮层但被 `overflow-y-auto` 容器裁剪,导致「空有两个按钮没有实际作用」。改用 Teleport + fixed 定位渲染浮层
- **折叠态浮层被 PlayerBar 遮挡**:浮层原用固定 `max-h-60`,触发图标靠下时底部歌单被 PlayerBar 盖住。改为按视口高度动态计算 maxHeight,浮层底部永不越过 PlayerBar 顶部
- **详情页头部被滚走**:原实现头部和列表在同一滚动容器中,向下滚动时封面/标题/按钮全部滚走。重构为 DetailLayout 后头部 `flex-shrink:0` 常驻顶部,列表独立滚动
- **评论 tab 提前加载**:原 PlaylistDetail 用 `v-show` 包裹 CommentSection 导致组件挂载即触发评论 API。改为懒加载 + 保活模式,首次切到评论 tab 才发起请求
- **切换 tab 紧凑态卡死**:评论/歌曲高度差异大,切换 tab 后紧凑态可能卡在错误状态。新增 `watch(currentTab)` 调用 `resetScroll()` 重置滚动和紧凑态
- **侧边栏折叠按钮非箭头**:原实现用单根竖条倾斜,hover 时呈斜线非箭头。改为 naive-ui 双竖条方案:两根 4×38px 竖条 4px 重叠,旋转 ±12deg 形成 `<``>` 箭头
- **箭头重叠区颜色加深**:opacity 应用到单个竖条导致重叠区颜色叠加变深。改为 opacity 应用到容器(默认 0.5hover 1.0),竖条使用纯色
- **歌词截断与抖动**:长歌词被 `whitespace-nowrap` 截断。移除该属性并加 `word-break:break-word` + `overflow-wrap:anywhere`;活跃行字号变化导致抖动,锁定 `line-height:1.5`
- **滚动条主题色丢失**:之前误将 `--c-content-3` 用作 thumb 颜色。从 git 历史恢复原实现:hover 容器显示 `--c-muted`hover thumb 变 `--c-emphasis`,主题切换自动跟随
- **专辑标题字号变化抖动**:从 30px 切到 22px 时容器高度变化导致布局抖动。固定 `line-height:1.3` + `min-height:39px`,过渡期间容器高度恒定
- **AlbumDetail 重复绑定滚动**keep-alive 首次挂载时 onMounted 和 onActivated 都触发 `bindScroll()`。重构后 DetailLayout 自管滚动监听,AlbumDetail 移除全部 bindScroll/unbindScroll 逻辑
### 🎨 变更
- **侧边栏布局重构**:侧边栏改为全高布局,TopBar 仅在主内容区上方,前进/后退按钮与主内容对齐。侧边栏顶部新增 Logo 占位区(与 TopBar 等高)
- **侧边栏透明化**:移除侧边栏和顶栏的不透明背景色,仅靠一条 40% 透明度的边框线分隔,壁纸/主题色从底层透出,实现「半隐藏」视觉
- **文字不乱跳**:侧边栏展开/折叠过渡时,菜单文字改为 `max-width` + `overflow-hidden` + `whitespace-nowrap` 平滑过渡,不再因宽度变化导致换行乱跳
- **顶栏透明化**:TopBar 移除背景色,加底部细线与侧边栏右边框呼应
- **专辑封面立体感**:专辑详情页封面加入模糊阴影背板(`blur(12px) opacity(0.6) scale(0.92,0.96)`)和顶部渐变遮罩,紧凑时遮罩淡出,参考 SPlayer `.cover-shadow`/`.cover-mask`
- **漫游抽屉外观一体化**RoamDrawer 改用 `backdrop-blur-xl` 配合皮肤系统色板,外观跟随当前皮肤
- **全局样式精简**`style.css` 精简约 298 行,原硬编码色值迁移至皮肤 CSS 变量系统
### ⚡ 优化
- **音频模块拆分**:将单文件 audio.rs1228 行)拆分为 audio/ 目录下 8 个模块(mod/buffer/commands/controller/decoder/device/download/output),提升可维护性
- **状态管理职责分离**:将单一 player store 拆分为 player、liked、recent、ui 四个职责单一的 store
- **类型定义抽取**:Song 等类型定义抽取到 types/song.ts
- **API 层领域分离**:将 api.ts225 行)拆分为 api/ 目录下 song/playlist/artist/album/search/rec/comment/device 等领域模块,index.ts 作向后兼容层
- **两级缓存**:页面数据和歌词实现 L1 内存 + L2 localStorage 两级缓存,带 TTL 和配额管理
- **设置迁移**:设置数据引入版本号和迁移机制(migrations/settingsMigrations.ts),支持未来 schema 变更
- **AlbumDetail 重构接入 DetailLayout**:原本自行实现 absolute 布局 + isCompact + 滚动监听,与 DetailLayout 逻辑重复。重构后改用 DetailLayout 组件 + slot prop,删除约 100 行重复代码(onScroll/bindScroll/unbindScroll/resetScrollState/listScrollRef/rafId 等)
- **窗口控制逻辑抽取**TitleBar 与 TopBar 重复实现 minimize/toggleMaximize。抽取为 `composables/useWindowControls.ts` 共用
- **溢出检测抽取**PlaylistDetail 与 ArtistDetail 重复实现 `checkDescOverflow`。抽取为 `utils/dom.ts``checkOverflow` 函数共用
- **骨架动画全局化**`@keyframes pulse` 原在三个详情页分别定义,移至全局 `style.css` 共用
- **死代码清理**:删除未使用的 `pageCacheDelete`(与 `pageCacheInvalidate` 重复)、`getAlbumDisplay``stores/player.ts` 中冗余的 `Song/PlayMode` re-export
- **错误日志可读性**9 处裸 `console.error(e)` 添加描述性前缀(如 `console.error('获取歌单详情失败', e)`),方便定位
- **AlbumDetail watcher 合并**:原本监听 `route.params.id` 的两个 watcher(一个重置状态、一个 fetchAlbum)合并为一个
- **新增后端命令**`read_image_as_data_url`(读取本地图片转 data URL,供壁纸预览使用)、`show_item_in_folder`(在文件管理器中打开文件位置)
## v0.7.0
### ✨ 新功能
- **音乐云盘**:新增云盘页面,可浏览、播放云盘中的歌曲,查看文件详情(文件名、大小、比特率、上传时间),删除云盘歌曲,查看存储空间使用情况
- **云盘上传**:支持上传本地音频文件到云盘,上传过程显示实时进度,支持 mp3/flac/wav/ogg/aac/m4a 格式
- **下载音乐**:本地音乐拆分为「本地音乐」和「下载音乐」两个独立页面,下载音乐只显示通过应用下载的歌曲
- **本地音乐多文件夹**:本地音乐支持添加多个扫描文件夹,通过三点按钮+弹窗管理文件夹路径
- **歌手关注**:歌手详情页新增关注/取关按钮,关注状态在离开页面后不会丢失
- **粘性导航栏**:页面滚动较深时顶部自动显示返回按钮和功能按钮,渐变模糊效果,不影响阅读
- **骨架屏加载**:首页、歌单、歌手、专辑、云盘等多个页面加载时显示骨架占位动画,不再只有"加载中"文字
### 🐛 修复
- 网络较差时播放启动超时,音乐实际已开始播放但界面仍显示暂停
- 全屏漫游抽屉打开时点击评论按钮无法切换到评论页
- 关闭漫游抽屉后再打开,始终显示评论而非歌词
- 歌手详情页关注后离开再回来,关注状态丢失
- 切歌时偶尔触发上一首歌的播放结束事件导致异常
- 评论点赞无限叠加(改为服务端状态驱动)
- 播放栏进度条上方多余分隔线
### 🎨 变更
- 歌手详情页头像改为圆形,简介从独立标签页移至头部内嵌显示,溢出时可展开查看完整介绍
- 歌单详情页描述溢出时显示"查看完整介绍"按钮
- 首页推荐和排行榜加载失败时显示重试按钮,支持分别重试
- 多个页面的返回按钮统一为粘性导航栏组件
- 消息提示增加去重和数量限制,避免重复弹出
### ⚡ 优化
- 页面切换更流畅,路由全部改为懒加载
- 页面缓存管理优化,30 秒未访问自动释放,多级跳转时保留导航链上的页面,「我喜欢的音乐」常驻缓存
- 本地音乐扫描不再阻塞界面导航
- 应用启动不再等待网络请求完成
## v0.6.0
### ✨ 新功能
- **亮色主题**:新增浅色外观模式,支持深色/浅色切换,7 种主题色各有对应亮色变体
- **封面主色背景**:全屏漫游抽屉背景自动提取封面图主色调,沉浸感更强;抽屉打开时底部播放栏也跟随封面主色,视觉融为一体
- **发现页重做**:支持多类型搜索(歌曲/歌手/专辑),输入时自动显示搜索建议,搜索历史和热门搜索
- **漫游增强**:全屏抽屉支持歌词/评论切换,播放栏新增减少推荐按钮
- **减少推荐**:FM 模式下可标记"不推荐这首歌"或"不推荐这个歌手",后续不会再收到类似推荐
- **列表风格统一**:正在播放的歌曲序号位置显示跳动动画,鼠标悬停显示播放图标;红心/下载等图标统一使用图标库
### 🐛 修复
- 专辑页艺术家过多时窗口缩小会竖排显示,现在支持自动换行
- FM 播放时退出登录后首页仍可点击下一首
- 本地音乐播放时缓冲进度条未重置
- 亮色主题下多处文字看不见
- 退出 FM 模式时状态未正确清理
- 暗色模式下关闭抽屉时播放栏短暂闪烁亮色
### 🎨 变更
- 移除播放列表按钮上的数字角标
- 主页每日推荐和 FM 卡片标题固定为白色,不随主题变化
### 🧹 清理
- 内部代码优化和冗余清理
## v0.5.1
### 🐛 修复
- 修复页面缓存不刷新的问题:切换回已缓存的页面时数据永远不更新,现在超过 5 分钟会自动重新加载
- 修复本地音乐页面空列表时刷新按钮不显示的问题
- 修复修改下载路径后本地音乐列表不更新的问题,现在会自动刷新
- 修复私人 FM 播放约二三十首后循环重复的问题:新增听歌打卡上报,服务端推荐不再重复
- 修复歌词界面切换翻译开关时歌词未居中的问题
- 修复 Linux 下从外部控制暂停时进度条跳回 0 的问题:MPRIS 现在正确报告播放进度位置
### ⚡ 优化
- 私人 FM 预取队列优化,队列剩余不足时自动后台拉取下一批
## v0.5.0
### ✨ 新功能
- **蓝牙耳机/键盘媒体键控制**:支持通过蓝牙耳机按钮、键盘媒体键、系统通知栏/锁屏面板控制播放、暂停、切歌(Windows / Linux / macOS
- **网络状态检测**:断网和恢复时弹出提示,网络恢复后自动重新加载页面内容
- **音量记忆**:关闭应用后音量设置不丢失,下次打开自动恢复
- **歌词翻译**:支持显示歌词翻译,可在漫游页面切换开关
- **登录页优化**:已登录用户访问登录页会自动跳转回首页
### 🎨 变更
- 默认主题色改为天蓝色
- 全局快捷键显示顺序调整为 Ctrl + Alt(之前是 Alt + Ctrl
- 快捷键显示优化:按键名更简洁,如 KeyP 显示为 P
- 页面缓存优化:更多页面切换时保留状态,窗口隐藏时自动释放
- 登录页等待确认时的文字颜色修正
### 🐛 修复
- 手动检查更新时,之前跳过的版本现在会正常弹出更新提示
- 点击正在播放的歌曲无法恢复播放的问题
- 部分内部类型定义问题导致的潜在隐患
### ⚡ 底层优化
- 音频播放引擎全面重构,播放更稳定
- 后端 API 调用模式统一,代码更易维护
- 歌曲数据模型统一,各页面显示更一致
## v0.4.1
添加音频输出外设选择
## v0.4.0
### ✨ 新功能
- 添加歌曲的艺术家入口,歌曲的艺术家现可点击查看其他歌曲、专辑和介绍
- 添加歌曲的评论查看功能
### 🐛 修复
- 修复私人漫游自动播放下一首调用多次问题
### ⚡ 优化
- 优化播放逻辑,歌曲列表在点击时候不再单首累加,而是直接获取当前列表所有的歌曲作为播放内容
## v0.3.0
### ✨ 新功能
- **本地音乐页面**:支持浏览、播放本地歌曲,横向菜单添加「从磁盘删除」功能
- **下载系统**:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长)
- **封面补全**:本地音乐缺少封面时尝试从网易云 API 获取
- **更新信息**:添加查看最新版更新日志按钮
- **下载路径**:支持自定义下载路径
- **本地音乐**:支持本地音乐播放
- **下载提示**:下载进度与完成提示
- **快捷键绑定**:支持自定义全局和本地快捷键(播放/暂停、上一首/下一首、音量调节)
### 🐛 修复
- 修复私人漫游播完一首歌后跳三首的问题
- 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片
- 修复 PlayerBar 无封面歌曲显示破损图片
- 修复播放网络歌曲时进度条先走但无声音
### ⚡ 优化
- **流式播放**:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载
## v0.2.0
### 🎵 播放
- 优化私人漫游(个人 FM)功能
- 新增歌曲喜欢/取消喜欢红心
- 新增播放历史本地记录(最近 200 首)
### 📋 歌单
- 修改逻辑 我的歌单 不再显示收藏按钮
- 收藏歌单支持取消收藏
- 实现我的音乐功能
- 实现历史播放记录功能
### 🎨 外观
- 全局复选框与选择框优化
- 部分UI优化,统一风格
### 🖥️ 窗口
- 关闭窗口弹出确认弹窗:最小化到托盘 / 退出程序
- 支持"不再询问"选项,可在设置中修改
- 修复退出时 WebView2 报错(Error 1410
- 修复歌词抽屉全屏时候顶栏无法接收事件问题
### 💾 持久化
- Cookie 存储迁移至 Tauri app_data_dir
- 播放历史持久化到 localStorage
### ⚙️ 其他
- 添加设置功能
- 关于添加链接可直接访问仓库
## v0.1.0
Nekosonic 是一款基于 Tauri 2 + Rust 的跨平台桌面音乐播放器,音源主要来自网易云音乐,开箱即用。
+99 -4
View File
@@ -1,7 +1,102 @@
# Tauri + Vue + TypeScript
<div align="center">
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
# Nekosonic
## Recommended IDE Setup
轻量跨平台桌面音乐播放器 · 网易云音乐
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
[![Windows](https://img.shields.io/badge/Windows-0078D4?logo=windows11&logoColor=white)](https://github.com/atdunbg/Nekosonic-Music/releases)
[![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black)](https://github.com/atdunbg/Nekosonic-Music/releases)
[![macOS](https://img.shields.io/badge/macOS-000000?logo=apple&logoColor=white)](https://github.com/atdunbg/Nekosonic-Music/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[下载安装](https://github.com/atdunbg/Nekosonic-Music/releases)
---
</div>
## 🎵 播放
- 多音质选择(标准 / 较高 / HQ / SQ / Hi-Res
- 私人漫游 FM(个性化推荐)
- 系统媒体控制集成(MPRIS / SMTC / Now Playing
- 音频输出设备选择
## 🔍 发现
- 关键词搜索(歌曲 / 歌手 / 专辑)+ 搜索建议 + 热门搜索
- 歌单浏览(推荐 / 排行榜 / 用户 / 收藏)
- 歌手详情(热门歌曲 / 专辑 / 简介 + 关注)
- 专辑详情
- 每日推荐歌曲
## 🎤 歌词与评论
- 实时滚动歌词(ease-out 缓动 / 点击跳转 / 渐变透明度)
- 歌词翻译
- 全屏漫游模式(封面主色提取 + 歌词/评论双标签)
- 歌曲评论(无限滚动 + 点赞)
## ❤️ 收藏与下载
- 一键喜欢 / 取消喜欢(同步到网易云账号)
- 歌曲下载
- 音乐云盘(上传 / 删除 / 详情 / 存储空间 / 上传进度)
- 本地音乐(多文件夹扫描 / 封面补全)
- 下载音乐(独立管理 / 删除)
## 🎨 个性化
- 多主题色(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色)
- 自定义快捷键(应用内 + 系统全局)
- 关闭行为设置
- 自动更新
---
## 安装
前往 [Releases](https://github.com/atdunbg/Nekosonic-Music/releases) 下载对应平台安装包。
## 配置开发环境
```bash
npm install
npm run tauri dev # 开发
npm run tauri build # 构建
```
> 环境要求:Node.js ≥ 18 · Rust ≥ 1.70 · Tauri CLI 2
## 技术栈
| 层级 | 技术 |
|:------|:------|
| 桌面框架 | Tauri 2 |
| 前端 | Vue 3 + TypeScript + Pinia |
| 样式 | Tailwind CSS v4 + CSS 变量主题 |
| 音频解码 | symphonia + ringbuf |
| 媒体控制 | souvlaki |
| 网易云 API | ncm-api-rs |
| 构建 | Vite 6 |
## Todo
- [x] 评论查看
- [x] 歌曲下载
- [x] 本地音乐
- [x] 歌手详情页
- [x] 专辑详情页
- [x] 自定义全局快捷键
- [x] 自动更新
- [x] 歌词翻译
- [x] 更多主题
- [x] 音乐云盘
- [ ] MV 播放
- [ ] 桌面歌词
欢迎提 Issue 和 Pull request。
## 开源许可
本项目仅供个人学习研究使用,禁止用于商业及非法用途。基于 [MIT License](https://opensource.org/licenses/MIT) 开源。
+69 -704
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -1,7 +1,7 @@
{
"name": "nekosonic",
"private": true,
"version": "0.1.0",
"version": "0.8.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",
+1605 -331
View File
File diff suppressed because it is too large Load Diff
+31 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "Nekosonic"
version = "0.1.0"
version = "0.8.0"
description = "A Simple music app"
authors = ["atdunbg"]
edition = "2021"
@@ -12,7 +12,7 @@ edition = "2021"
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "demo_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
crate-type = ["cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -20,12 +20,39 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
rodio = "0.20"
tauri-plugin-single-instance = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-dialog = "2"
symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "wav", "ogg", "vorbis", "isomp4", "mkv"] }
ringbuf = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
cpal = { version = "0.15" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "stream"] }
futures-util = "0.3"
dirs = "5"
lofty = "0.22"
base64 = "0.22"
md5 = "0.7"
ncm-api-rs = "0.1"
tokio = { version = "1", features = ["rt", "sync"] }
tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2"
[target.'cfg(target_os = "linux")'.dependencies]
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
[target.'cfg(target_os = "windows")'.dependencies]
souvlaki = "0.8"
raw-window-handle = "0.6"
[target.'cfg(target_os = "macos")'.dependencies]
souvlaki = "0.8"
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "abort"
opt-level = "s"
+12 -4
View File
@@ -5,12 +5,20 @@
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"core:window:default",
"core:window:allow-minimize",
"core:window:allow-unminimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize"
"core:path:default",
"opener:default",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"dialog:allow-open",
"process:allow-restart",
"updater:default"
]
}
}
-1
View File
@@ -1 +0,0 @@
NMTID=00OvETy78e8ay9VhUTKgcUSdB6-yKQAAAGeAJacOg
+1329 -102
View File
File diff suppressed because it is too large Load Diff
-382
View File
@@ -1,382 +0,0 @@
use rodio::{Decoder, OutputStream, Sink, Source};
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::io::Cursor;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tauri::AppHandle;
use tauri::Emitter;
// ---------- 命令 ----------
enum AudioCmd {
Play(String),
Pause,
Resume,
Stop,
Seek(f64),
SetVolume(f32),
SetDevice(Option<String>),
}
pub struct AudioController {
tx: Sender<AudioCmd>,
current_url: Arc<Mutex<Option<String>>>,
}
impl AudioController {
pub fn new(app_handle: AppHandle) -> Self {
let (tx, rx) = channel();
let current_url = Arc::new(Mutex::new(None));
let url_clone = current_url.clone();
let ah_clone = app_handle.clone(); // 克隆一个用于闭包
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
AudioController {
tx,
current_url,
}
}
pub fn play_url(&self, url: &str) {
*self.current_url.lock().unwrap() = Some(url.to_string());
let _ = self.tx.send(AudioCmd::Play(url.to_string()));
}
pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); }
pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); }
pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); }
pub fn set_device(&self, device: Option<String>) {
let _ = self.tx.send(AudioCmd::SetDevice(device));
}
pub fn seek(&self, time: f64) {
let _ = self.tx.send(AudioCmd::Seek(time));
}
pub fn set_volume(&self, vol: f32) {
let _ = self.tx.send(AudioCmd::SetVolume(vol));
}
}
use std::io::Read;
fn download_audio_with_progress(
url: &str,
app_handle: &AppHandle,
) -> Result<Vec<u8>, String> {
let resp = reqwest::blocking::get(url)
.map_err(|e| format!("下载失败: {}", e))?;
let total_size = resp.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut buffer = Vec::new();
let mut reader = resp;
loop {
let mut chunk = [0u8; 8192];
let read_size = reader.read(&mut chunk)
.map_err(|e| format!("读取失败: {}", e))?;
if read_size == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read_size]);
downloaded += read_size as u64;
// 发送进度事件给前端(每 8192 字节发一次,不必太频繁)
let progress = if total_size > 0 {
(downloaded as f64 / total_size as f64) * 100.0
} else {
0.0 // 未知大小时为 0
};
let _ = app_handle.emit("cache-progress", progress);
}
// 下载完成,确保进度为 100
let _ = app_handle.emit("cache-progress", 100f64);
Ok(buffer)
}
// ---------- 音频线程 ----------
fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>, app_handle: AppHandle) {
let mut selected_device: Option<String> = None;
let mut output = create_output(&selected_device);
let mut last_default_name = get_system_default_device_name();
let mut current_volume: f32 = 1.0;
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
}
let mut current_audio_data: Option<Vec<u8>> = None; // 缓存原始音频字节
loop {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(cmd) => {
match cmd {
AudioCmd::Play(url) => {
// 停止旧播放并重建干净输出
if let Some(ref sink) = output.sink {
sink.stop();
}
output = create_output(&selected_device);
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
match download_audio_with_progress(&url, &app_handle) {
Ok(bytes) => {
current_audio_data = Some(bytes.clone());
let play_res = play_bytes(&bytes, sink);
if let Err(e) = play_res {
eprintln!("[audio] 播放失败: {}", e);
}
}
Err(e) => eprintln!("[audio] 下载失败: {}", e),
}
}
}
AudioCmd::Pause => {
if let Some(ref sink) = output.sink { sink.pause(); }
}
AudioCmd::Resume => {
if let Some(ref sink) = output.sink { sink.play(); }
}
AudioCmd::Stop => {
if let Some(ref sink) = output.sink { sink.stop(); }
}
AudioCmd::Seek(time) => {
if let Some(ref sink) = output.sink {
// 优先尝试高效的 sink.try_seek(毫秒级)
let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sink.try_seek(Duration::from_secs_f64(time))
}));
match seek_res {
Ok(Ok(_)) => { /* 成功 */ }
Ok(Err(e)) => {
eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e);
// 回退方案:重新解码并从目标时间开始
if let Some(ref bytes) = current_audio_data {
sink.stop();
sink.clear();
let _ = play_bytes_with_seek(bytes, sink, time);
}
}
Err(_) => {
eprintln!("[audio] try_seek 崩溃,回退重建解码");
if let Some(ref bytes) = current_audio_data {
sink.stop();
sink.clear();
let _ = play_bytes_with_seek(bytes, sink, time);
}
}
}
}
}
AudioCmd::SetVolume(vol) => {
current_volume = vol;
if let Some(ref sink) = output.sink {
sink.set_volume(vol);
}
}
AudioCmd::SetDevice(dev) => {
selected_device = dev;
output = create_output(&selected_device);
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
// 如果正在播放,恢复播放
if current_url.lock().unwrap().is_some() {
if let Some(ref bytes) = current_audio_data {
let _ = play_bytes(bytes, sink);
}
}
}
if selected_device.is_none() {
last_default_name = get_system_default_device_name();
}
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// 跟随系统默认设备变化
if selected_device.is_none() {
let current_default = get_system_default_device_name();
if current_default != last_default_name {
println!("[audio] 系统默认设备变化: {:?} -> {:?}", last_default_name, current_default);
last_default_name = current_default;
output = create_output(&selected_device);
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
if let Some(ref bytes) = current_audio_data {
let _ = play_bytes(bytes, sink);
}
}
}
}
}
Err(_) => break,
}
}
}
// ---------- 播放辅助函数 ----------
/// 直接播放字节数据
fn play_bytes(bytes: &[u8], sink: &Sink) -> Result<(), String> {
let cursor = Cursor::new(bytes.to_vec());
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
sink.append(source);
sink.play();
Ok(())
}
/// 播放字节数据并跳过指定秒数(用于 seek 回退)
fn play_bytes_with_seek(bytes: &[u8], sink: &Sink, seek_secs: f64) -> Result<(), String> {
let cursor = Cursor::new(bytes.to_vec());
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
let source = source.skip_duration(Duration::from_secs_f64(seek_secs));
sink.append(source);
sink.play();
Ok(())
}
// ---------- 其余函数保持不变(获取设备、创建输出等) ----------
fn get_system_default_device_name() -> Option<String> {
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}
pub fn list_output_devices() -> Vec<String> {
let host = rodio::cpal::default_host();
if let Ok(devices) = host.output_devices() {
let mut names: Vec<String> = devices.filter_map(|d| d.name().ok()).collect();
names.sort();
names.dedup();
names
} else {
vec![]
}
}
fn find_device_by_name(name: &str) -> Option<rodio::cpal::Device> {
let host = rodio::cpal::default_host();
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Ok(n) = d.name() {
if n == name {
return Some(d);
}
}
}
}
None
}
struct Output {
_stream: OutputStream,
sink: Option<Sink>,
}
fn create_output(selected_device: &Option<String>) -> Output {
match selected_device {
Some(dev_name) => {
if let Some(dev) = find_device_by_name(dev_name) {
println!("[audio] 使用指定设备: {}", dev_name);
match OutputStream::try_from_device(&dev) {
Ok((stream, handle)) => {
match Sink::try_new(&handle) {
Ok(sink) => Output { _stream: stream, sink: Some(sink) },
Err(e) => {
eprintln!("[audio] Sink 创建失败: {}", e);
Output { _stream: stream, sink: None }
}
}
}
Err(e) => {
eprintln!("[audio] 指定设备无效,回退默认: {}", e);
create_default_output()
}
}
} else {
eprintln!("[audio] 未找到设备 `{}`,回退默认", dev_name);
create_default_output()
}
}
None => {
println!("[audio] 跟随系统默认设备");
create_default_output()
}
}
}
fn create_default_output() -> Output {
match OutputStream::try_default() {
Ok((stream, handle)) => {
match Sink::try_new(&handle) {
Ok(sink) => Output { _stream: stream, sink: Some(sink) },
Err(e) => {
eprintln!("[audio] 默认 Sink 失败: {}", e);
Output { _stream: stream, sink: None }
}
}
}
Err(e) => panic!("无法创建默认音频输出: {}", e),
}
}
// ===================== Tauri 命令 =====================
use tauri::State;
use std::sync::Mutex as StdMutex;
pub struct AppAudio(pub StdMutex<AudioController>);
#[tauri::command]
pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> {
let ctrl = state.0.lock().map_err(|e| e.to_string())?;
ctrl.play_url(&url);
Ok(())
}
#[tauri::command]
pub fn pause_audio(state: State<'_, AppAudio>) {
if let Ok(ctrl) = state.0.lock() { ctrl.pause(); }
}
#[tauri::command]
pub fn resume_audio(state: State<'_, AppAudio>) {
if let Ok(ctrl) = state.0.lock() { ctrl.resume(); }
}
#[tauri::command]
pub fn stop_audio(state: State<'_, AppAudio>) {
if let Ok(ctrl) = state.0.lock() { ctrl.stop(); }
}
#[tauri::command]
pub fn get_output_devices() -> Vec<String> {
list_output_devices()
}
#[tauri::command]
pub fn set_output_device(state: State<'_, AppAudio>, device: Option<String>) {
if let Ok(ctrl) = state.0.lock() {
ctrl.set_device(device);
}
}
#[tauri::command]
pub fn seek_audio(state: State<'_, AppAudio>, time: f64) {
if let Ok(ctrl) = state.0.lock() {
ctrl.seek(time);
}
}
#[tauri::command]
pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
if let Ok(ctrl) = state.0.lock() {
ctrl.set_volume(vol);
}
}
+203
View File
@@ -0,0 +1,203 @@
//! 流式音频缓冲区
//!
//! 提供线程安全的共享缓冲区与流式读取器,用于在下载线程与解码线程之间传递音频数据。
use ringbuf::{HeapCons, HeapProd, HeapRb, traits::Split};
use std::io::Read;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use symphonia::core::io::MediaSource;
/// 缓冲区内部状态,存储已下载的字节数据及完成/取消标志
pub(crate) struct BufferState {
pub(crate) bytes: Vec<u8>,
pub(crate) done: bool,
pub(crate) cancelled: bool,
}
/// 线程安全的共享缓冲区,支持生产者写入和消费者读取的同步等待
pub struct SharedBuffer {
state: Mutex<BufferState>,
available: Condvar,
}
impl SharedBuffer {
/// 创建新的空共享缓冲区
pub fn new() -> Self {
SharedBuffer {
state: Mutex::new(BufferState {
bytes: Vec::new(),
done: false,
cancelled: false,
}),
available: Condvar::new(),
}
}
/// 向缓冲区追加写入一块数据,并通知等待的读取者
pub fn write_chunk(&self, chunk: &[u8]) {
let mut state = self.state.lock().unwrap();
state.bytes.extend_from_slice(chunk);
self.available.notify_all();
}
/// 标记缓冲区写入已完成,通知读取者不再有新数据
pub fn mark_done(&self) {
let mut state = self.state.lock().unwrap();
state.done = true;
self.available.notify_all();
}
/// 取消缓冲区,中断正在进行的读写操作
pub fn cancel(&self) {
let mut state = self.state.lock().unwrap();
state.cancelled = true;
self.available.notify_all();
}
/// 返回已缓冲的数据字节数
pub fn len(&self) -> usize {
self.state.lock().unwrap().bytes.len()
}
/// 检查缓冲区是否已标记为写入完成
pub fn is_done(&self) -> bool {
self.state.lock().unwrap().done
}
/// 检查缓冲区是否已被取消
pub fn is_cancelled(&self) -> bool {
self.state.lock().unwrap().cancelled
}
/// 获取内部状态锁(供 StreamingReader 使用)
pub(crate) fn state_mutex(&self) -> &Mutex<BufferState> {
&self.state
}
/// 获取内部条件变量(供 StreamingReader 使用)
pub(crate) fn condvar(&self) -> &Condvar {
&self.available
}
}
/// 流式读取器,从共享缓冲区中按需读取数据,实现 `Read` 和 `Seek` trait
pub struct StreamingReader {
buffer: Arc<SharedBuffer>,
pos: usize,
}
impl StreamingReader {
/// 创建新的流式读取器,绑定到指定的共享缓冲区
pub fn new(buffer: Arc<SharedBuffer>) -> Self {
StreamingReader { buffer, pos: 0 }
}
}
impl Read for StreamingReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let mut state = self.buffer.state_mutex().lock().unwrap();
loop {
let available = state.bytes.len().saturating_sub(self.pos);
if available > 0 {
let to_read = std::cmp::min(buf.len(), available);
buf[..to_read].copy_from_slice(&state.bytes[self.pos..self.pos + to_read]);
self.pos += to_read;
return Ok(to_read);
}
if state.done {
return Ok(0);
}
if state.cancelled {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
}
let result = self
.buffer
.condvar()
.wait_timeout(state, Duration::from_millis(500))
.unwrap();
state = result.0;
}
}
}
impl std::io::Seek for StreamingReader {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
let new_pos = match pos {
std::io::SeekFrom::Start(offset) => offset as i64,
std::io::SeekFrom::Current(offset) => self.pos as i64 + offset,
std::io::SeekFrom::End(offset) => {
let mut state = self.buffer.state_mutex().lock().unwrap();
loop {
if state.done {
break state.bytes.len() as i64 + offset;
}
if state.cancelled {
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"cancelled",
));
}
let result = self
.buffer
.condvar()
.wait_timeout(state, Duration::from_millis(500))
.unwrap();
state = result.0;
}
}
};
if new_pos < 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"seek before start",
));
}
let mut state = self.buffer.state_mutex().lock().unwrap();
loop {
if new_pos as usize <= state.bytes.len() {
self.pos = new_pos as usize;
return Ok(self.pos as u64);
}
if state.done {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"seek past end",
));
}
if state.cancelled {
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"cancelled",
));
}
let result = self
.buffer
.condvar()
.wait_timeout(state, Duration::from_millis(500))
.unwrap();
state = result.0;
}
}
}
/// 环形缓冲区类型别名,便于在模块间共享
pub type RingProducer = HeapProd<f32>;
pub type RingConsumer = HeapCons<f32>;
/// 创建指定容量的环形缓冲区,返回 (生产者, 消费者)
pub fn create_ring_buffer(capacity: usize) -> (RingProducer, RingConsumer) {
let rb = HeapRb::<f32>::new(capacity);
rb.split()
}
/// 为 StreamingReader 实现 symphonia 的 MediaSource trait
impl MediaSource for StreamingReader {
fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<u64> {
None
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Tauri 命令
//!
//! 暴露给前端调用的所有 `#[tauri::command]` 函数。
//! `AppAudio` 是 Tauri 管理的状态,内部包装 `AudioController` 的互斥锁。
use std::sync::Mutex as StdMutex;
use tauri::State;
use crate::audio::controller::AudioController;
use crate::audio::device::list_output_devices;
/// Tauri 管理的音频状态,内部包装 `AudioController` 的互斥锁
pub struct AppAudio(pub StdMutex<AudioController>);
/// Tauri 命令:播放网络音频
#[tauri::command]
pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> {
let ctrl = state.0.lock().map_err(|e| e.to_string())?;
ctrl.play_url(&url);
Ok(())
}
/// Tauri 命令:播放本地音频文件
#[tauri::command]
pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), String> {
let ctrl = state.0.lock().map_err(|e| e.to_string())?;
ctrl.play_local(&path);
Ok(())
}
/// Tauri 命令:暂停当前播放
#[tauri::command]
pub fn pause_audio(state: State<'_, AppAudio>) {
if let Ok(ctrl) = state.0.lock() { ctrl.pause(); }
}
/// Tauri 命令:恢复播放
#[tauri::command]
pub fn resume_audio(state: State<'_, AppAudio>) {
if let Ok(ctrl) = state.0.lock() { ctrl.resume(); }
}
/// Tauri 命令:停止当前播放
#[tauri::command]
pub fn stop_audio(state: State<'_, AppAudio>) {
if let Ok(ctrl) = state.0.lock() { ctrl.stop(); }
}
/// Tauri 命令:获取所有可用的音频输出设备列表
#[tauri::command]
pub fn get_output_devices() -> Vec<String> {
list_output_devices()
}
/// Tauri 命令:设置音频输出设备,传入 None 使用系统默认设备
#[tauri::command]
pub fn set_output_device(state: State<'_, AppAudio>, device: Option<String>) {
if let Ok(ctrl) = state.0.lock() { ctrl.set_device(device); }
}
/// Tauri 命令:跳转到指定播放位置(秒)
#[tauri::command]
pub fn seek_audio(state: State<'_, AppAudio>, time: f64) {
if let Ok(ctrl) = state.0.lock() { ctrl.seek(time); }
}
/// Tauri 命令:获取当前播放位置(秒)
#[tauri::command]
pub fn get_audio_position(state: State<'_, AppAudio>) -> f64 {
if let Ok(ctrl) = state.0.lock() { ctrl.get_position() } else { 0.0 }
}
/// Tauri 命令:设置播放音量
#[tauri::command]
pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
if let Ok(ctrl) = state.0.lock() { ctrl.set_volume(vol); }
}
#[tauri::command]
pub fn is_audio_playing(state: State<'_, AppAudio>) -> bool {
if let Ok(ctrl) = state.0.lock() { ctrl.get_is_playing() } else { false }
}
+389
View File
@@ -0,0 +1,389 @@
//! 音频控制器
//!
//! 负责命令分发与播放生命周期管理。`AudioController` 是对外接口,
//! 通过通道向后台 `audio_thread` 发送命令;`audio_thread` 是核心事件循环,
//! 管理播放状态、设备热切换、播放结束检测等。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use symphonia::core::io::MediaSourceStream;
use tauri::{AppHandle, Emitter};
use crate::audio::buffer::{SharedBuffer, StreamingReader};
use crate::audio::device::{get_output_device, get_system_default_device_name};
use crate::audio::download::download_audio_streaming;
use crate::audio::output::{OutputContext, INITIAL_BUFFER_SIZE, start_playback};
/// 音频控制命令枚举,用于音频线程之间的消息传递
enum AudioCmd {
Play(String),
PlayLocal(String),
Pause,
Resume,
Stop,
Seek(f64),
SetVolume(f32),
SetDevice(Option<String>),
}
/// 音频控制器,通过通道向音频线程发送控制命令
pub struct AudioController {
tx: Sender<AudioCmd>,
current_url: Arc<Mutex<Option<String>>>,
position: Arc<Mutex<f64>>,
is_playing: Arc<AtomicBool>,
}
impl AudioController {
/// 创建新的音频控制器,并启动后台音频线程
pub fn new(app_handle: AppHandle) -> Self {
let (tx, rx) = channel();
let current_url = Arc::new(Mutex::new(None));
let position = Arc::new(Mutex::new(0.0));
let is_playing = Arc::new(AtomicBool::new(false));
let url_clone = current_url.clone();
let pos_clone = position.clone();
let playing_clone = is_playing.clone();
let ah_clone = app_handle.clone();
thread::spawn(move || audio_thread(rx, url_clone, pos_clone, playing_clone, ah_clone));
AudioController { tx, current_url, position, is_playing }
}
/// 播放指定URL的网络音频
pub fn play_url(&self, url: &str) {
*self.current_url.lock().unwrap() = Some(url.to_string());
let _ = self.tx.send(AudioCmd::Play(url.to_string()));
}
/// 播放指定路径的本地音频文件
pub fn play_local(&self, path: &str) {
*self.current_url.lock().unwrap() = Some(path.to_string());
let _ = self.tx.send(AudioCmd::PlayLocal(path.to_string()));
}
/// 暂停当前播放
pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); }
/// 恢复播放
pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); }
/// 停止当前播放
pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); }
/// 设置音频输出设备,传入 None 则使用系统默认设备
pub fn set_device(&self, device: Option<String>) {
let _ = self.tx.send(AudioCmd::SetDevice(device));
}
/// 跳转到指定时间位置(秒)
pub fn seek(&self, time: f64) { let _ = self.tx.send(AudioCmd::Seek(time)); }
/// 设置播放音量,范围 0.0 ~ 1.0
pub fn set_volume(&self, vol: f32) { let _ = self.tx.send(AudioCmd::SetVolume(vol)); }
/// 获取当前播放位置(秒)
pub fn get_position(&self) -> f64 {
*self.position.lock().unwrap()
}
pub fn get_is_playing(&self) -> bool {
self.is_playing.load(Ordering::Relaxed)
}
}
/// 停止播放,取消解码并重置共享播放位置
fn stop_playback(output_ctx: &mut Option<OutputContext>, shared_position: &Arc<Mutex<f64>>) {
if let Some(ref mut ctx) = output_ctx {
ctx.playback.cancelled.store(true, Ordering::Relaxed);
ctx.playback.playing.store(false, Ordering::Relaxed);
}
*output_ctx = None;
*shared_position.lock().unwrap() = 0.0;
}
/// 根据当前播放源(本地文件或网络缓冲区)重建 MediaSourceStream
fn rebuild_mss(
local_path: &Option<String>,
audio_buffer: &Option<Arc<SharedBuffer>>,
) -> Option<MediaSourceStream> {
if let Some(ref path) = local_path {
let file = std::fs::File::open(path).ok()?;
Some(MediaSourceStream::new(Box::new(file), Default::default()))
} else if let Some(ref buffer) = audio_buffer {
let reader = StreamingReader::new(buffer.clone());
Some(MediaSourceStream::new(Box::new(reader), Default::default()))
} else {
None
}
}
/// 设备切换时重启播放,保留当前播放位置与暂停状态
fn restart_playback_on_device_change(
output_ctx: &mut Option<OutputContext>,
shared_position: &Arc<Mutex<f64>>,
local_path: &Option<String>,
audio_buffer: &Option<Arc<SharedBuffer>>,
selected_device: &Option<String>,
current_volume: f32,
audio_paused: bool,
) -> Result<OutputContext, String> {
let current_pos = output_ctx
.as_ref()
.map(|ctx| ctx.playback.position())
.unwrap_or(0.0);
let was_paused = audio_paused;
stop_playback(output_ctx, shared_position);
let mss = rebuild_mss(local_path, audio_buffer)
.ok_or_else(|| "无法重建音频源".to_string())?;
let device = get_output_device(selected_device);
let ctx = start_playback(mss, &device, current_volume, Some(current_pos))?;
if was_paused {
ctx.playback.playing.store(false, Ordering::Relaxed);
}
Ok(ctx)
}
/// 音频线程主循环,接收命令并管理播放生命周期,包括设备热切换和播放结束检测
fn audio_thread(
rx: Receiver<AudioCmd>,
_current_url: Arc<Mutex<Option<String>>>,
shared_position: Arc<Mutex<f64>>,
is_playing: Arc<AtomicBool>,
app_handle: AppHandle,
) {
let mut selected_device: Option<String> = None;
let mut current_volume: f32 = 1.0;
let mut output_ctx: Option<OutputContext> = None;
let mut current_audio_buffer: Option<Arc<SharedBuffer>> = None;
let mut current_local_path: Option<String> = None;
let mut audio_active = false;
let mut audio_paused = false;
let mut manual_stop = false;
let mut last_default_name = get_system_default_device_name();
loop {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(cmd) => match cmd {
AudioCmd::Play(url) => {
audio_active = false;
audio_paused = false;
is_playing.store(false, Ordering::Relaxed);
manual_stop = false;
current_local_path = None;
stop_playback(&mut output_ctx, &shared_position);
if let Some(ref buf) = current_audio_buffer {
buf.cancel();
}
let buffer = Arc::new(SharedBuffer::new());
current_audio_buffer = Some(buffer.clone());
let buffer_clone = buffer.clone();
let ah_clone = app_handle.clone();
let url_clone = url.clone();
thread::spawn(move || {
if let Err(e) = download_audio_streaming(&url_clone, &buffer_clone, &ah_clone) {
if !buffer_clone.is_cancelled() {
eprintln!("[audio] 流式下载失败: {}", e);
}
}
buffer_clone.mark_done();
});
loop {
let len = buffer.len();
if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() {
break;
}
thread::sleep(Duration::from_millis(50));
}
if buffer.is_cancelled() || buffer.len() == 0 {
current_audio_buffer = None;
continue;
}
let mss = MediaSourceStream::new(
Box::new(StreamingReader::new(buffer.clone())),
Default::default(),
);
let device = get_output_device(&selected_device);
match start_playback(mss, &device, current_volume, None) {
Ok(ctx) => {
output_ctx = Some(ctx);
audio_active = true;
is_playing.store(true, Ordering::Relaxed);
let _ = app_handle.emit("audio-started", ());
}
Err(e) => {
eprintln!("[audio] 播放启动失败: {}", e);
}
}
}
AudioCmd::PlayLocal(path) => {
audio_active = false;
audio_paused = false;
is_playing.store(false, Ordering::Relaxed);
manual_stop = false;
current_local_path = Some(path.clone());
stop_playback(&mut output_ctx, &shared_position);
if let Some(ref buf) = current_audio_buffer {
buf.cancel();
}
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) => {
eprintln!("[audio] 打开本地文件失败: {}", e);
continue;
}
};
let buffer = Arc::new(SharedBuffer::new());
current_audio_buffer = Some(buffer.clone());
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let device = get_output_device(&selected_device);
match start_playback(mss, &device, current_volume, None) {
Ok(ctx) => {
output_ctx = Some(ctx);
audio_active = true;
is_playing.store(true, Ordering::Relaxed);
let _ = app_handle.emit("audio-started", ());
}
Err(e) => {
eprintln!("[audio] 本地播放失败: {}", e);
}
}
}
AudioCmd::Pause => {
audio_paused = true;
is_playing.store(false, Ordering::Relaxed);
if let Some(ref ctx) = output_ctx {
ctx.playback.playing.store(false, Ordering::Relaxed);
}
}
AudioCmd::Resume => {
audio_paused = false;
if audio_active {
is_playing.store(true, Ordering::Relaxed);
}
if let Some(ref ctx) = output_ctx {
ctx.playback.playing.store(true, Ordering::Relaxed);
}
}
AudioCmd::Stop => {
audio_active = false;
audio_paused = false;
is_playing.store(false, Ordering::Relaxed);
manual_stop = true;
stop_playback(&mut output_ctx, &shared_position);
if let Some(ref buf) = current_audio_buffer {
buf.cancel();
}
}
AudioCmd::Seek(time) => {
stop_playback(&mut output_ctx, &shared_position);
let mss = match rebuild_mss(&current_local_path, &current_audio_buffer) {
Some(mss) => mss,
None => continue,
};
let device = get_output_device(&selected_device);
match start_playback(mss, &device, current_volume, Some(time)) {
Ok(ctx) => {
if audio_paused {
is_playing.store(false, Ordering::Relaxed);
ctx.playback.playing.store(false, Ordering::Relaxed);
} else {
is_playing.store(true, Ordering::Relaxed);
}
output_ctx = Some(ctx);
audio_active = true;
}
Err(e) => {
eprintln!("[audio] seek 播放失败: {}", e);
}
}
}
AudioCmd::SetVolume(vol) => {
current_volume = vol;
if let Some(ref ctx) = output_ctx {
*ctx.playback.volume.lock().unwrap() = vol;
}
}
AudioCmd::SetDevice(dev) => {
selected_device = dev;
if audio_active {
match restart_playback_on_device_change(
&mut output_ctx,
&shared_position,
&current_local_path,
&current_audio_buffer,
&selected_device,
current_volume,
audio_paused,
) {
Ok(ctx) => { output_ctx = Some(ctx); }
Err(e) => { eprintln!("[audio] 设备切换失败: {}", e); }
}
}
if selected_device.is_none() {
last_default_name = get_system_default_device_name();
}
}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if audio_active {
if let Some(ref ctx) = output_ctx {
if ctx.playback.decode_done.load(Ordering::Relaxed)
&& ctx.playback.buffer_exhausted.load(Ordering::Relaxed)
&& !manual_stop && !audio_paused {
audio_active = false;
is_playing.store(false, Ordering::Relaxed);
let _ = app_handle.emit("audio-ended", ());
}
let pos = ctx.playback.position();
*shared_position.lock().unwrap() = pos;
}
}
if selected_device.is_none() {
let current_default = get_system_default_device_name();
if current_default != last_default_name {
println!(
"[audio] 系统默认设备变化: {:?} -> {:?}",
last_default_name, current_default
);
last_default_name = current_default;
if audio_active {
if let Ok(ctx) = restart_playback_on_device_change(
&mut output_ctx,
&shared_position,
&current_local_path,
&current_audio_buffer,
&selected_device,
current_volume,
audio_paused,
) {
output_ctx = Some(ctx);
}
}
}
}
}
Err(_) => break,
}
}
}
+314
View File
@@ -0,0 +1,314 @@
//! 音频解码
//!
//! 基于 symphonia 解码音频,并提供采样格式转换、声道重混、重采样等工具函数。
//! `decode_to_ring` 是核心入口,将解码后的样本写入环形缓冲区供播放回调消费。
use ringbuf::traits::Producer;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use symphonia::core::audio::{AudioBufferRef, Signal};
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::core::units::Time;
use crate::audio::buffer::RingProducer;
/// 将 Symphonia 解码后的音频缓冲区转换为交错排列的 f32 采样数据
fn convert_to_interleaved_f32(decoded: &AudioBufferRef) -> Vec<f32> {
let channels = decoded.spec().channels.count();
let frames = decoded.frames();
let mut out = Vec::with_capacity(frames * channels);
match decoded {
AudioBufferRef::U8(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame] as f32 / u8::MAX as f32 * 2.0 - 1.0);
}
}
}
AudioBufferRef::U16(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame] as f32 / u16::MAX as f32 * 2.0 - 1.0);
}
}
}
AudioBufferRef::U24(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0);
}
}
}
AudioBufferRef::U32(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame] as f32 / u32::MAX as f32 * 2.0 - 1.0);
}
}
}
AudioBufferRef::S8(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame] as f32 / i8::MAX as f32);
}
}
}
AudioBufferRef::S16(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame] as f32 / i16::MAX as f32);
}
}
}
AudioBufferRef::S24(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0);
}
}
}
AudioBufferRef::S32(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame] as f32 / i32::MAX as f32);
}
}
}
AudioBufferRef::F32(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame]);
}
}
}
AudioBufferRef::F64(buf) => {
for frame in 0..frames {
for ch in 0..channels {
out.push(buf.chan(ch)[frame] as f32);
}
}
}
}
out
}
/// 重混声道数,将交错采样数据从源声道数转换为目标声道数
fn remix_channels(
interleaved: &[f32],
src_channels: u16,
target_channels: u16,
src_frames: usize,
) -> Vec<f32> {
if src_channels == target_channels {
return interleaved.to_vec();
}
let src_ch = src_channels as usize;
let tgt_ch = target_channels as usize;
let mut out = Vec::with_capacity(src_frames * tgt_ch);
if src_ch == 1 && tgt_ch == 2 {
for &s in interleaved {
out.push(s);
out.push(s);
}
} else if src_ch == 2 && tgt_ch == 1 {
for i in 0..src_frames {
let l = interleaved[i * 2];
let r = interleaved[i * 2 + 1];
out.push((l + r) * 0.5);
}
} else {
for i in 0..src_frames {
for ch in 0..tgt_ch {
let src_ch_idx = ch.min(src_ch.saturating_sub(1));
out.push(interleaved[i * src_ch + src_ch_idx]);
}
}
}
out
}
/// 对解码音频进行重采样和声道重混,输出目标采样率和声道数的交错 f32 数据
fn resample_and_remix(
decoded: &AudioBufferRef,
target_sample_rate: u32,
target_channels: u16,
src_rate: f64,
src_channels: u16,
) -> Vec<f32> {
let interleaved = convert_to_interleaved_f32(decoded);
let src_frames = if src_channels > 0 {
interleaved.len() / src_channels as usize
} else {
0
};
if src_frames == 0 {
return Vec::new();
}
let remixed = remix_channels(&interleaved, src_channels, target_channels, src_frames);
let remixed_ch = target_channels as usize;
let ratio = target_sample_rate as f64 / src_rate;
let need_resample = (ratio - 1.0).abs() > 0.001;
if !need_resample {
return remixed;
}
let target_frames = (src_frames as f64 * ratio).round() as usize;
if target_frames == 0 {
return Vec::new();
}
let mut out = Vec::with_capacity(target_frames * remixed_ch);
for i in 0..target_frames {
let src_pos = i as f64 / ratio;
let src_idx = src_pos as usize;
let frac = src_pos - src_idx as f64;
let next_idx = (src_idx + 1).min(src_frames - 1);
for ch in 0..remixed_ch {
let s0 = remixed[src_idx * remixed_ch + ch];
let s1 = remixed[next_idx * remixed_ch + ch];
out.push(s0 + (s1 - s0) * frac as f32);
}
}
out
}
/// 将音频数据解码并写入环形缓冲区,供播放回调消费
pub fn decode_to_ring(
mss: MediaSourceStream,
mut producer: RingProducer,
playing: Arc<AtomicBool>,
cancelled: Arc<AtomicBool>,
decode_done: Arc<AtomicBool>,
seek_time: Option<f64>,
target_sample_rate: u32,
target_channels: u16,
) {
let hint = Hint::new();
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts = MetadataOptions::default();
let decoder_opts = DecoderOptions::default();
let probed = match symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts) {
Ok(p) => p,
Err(e) => {
eprintln!("[audio] 探测格式失败: {}", e);
decode_done.store(true, Ordering::Relaxed);
return;
}
};
let mut format_reader = probed.format;
let track = match format_reader
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
{
Some(t) => t,
None => {
eprintln!("[audio] 未找到有效音频轨道");
decode_done.store(true, Ordering::Relaxed);
return;
}
};
let track_id = track.id;
let codec_params = &track.codec_params;
let src_rate = codec_params.sample_rate.unwrap_or(44100) as f64;
let src_channels = codec_params.channels.unwrap_or_else(|| {
symphonia::core::audio::Channels::FRONT_LEFT | symphonia::core::audio::Channels::FRONT_RIGHT
}).count() as u16;
let mut decoder = match symphonia::default::get_codecs().make(codec_params, &decoder_opts) {
Ok(d) => d,
Err(e) => {
eprintln!("[audio] 创建解码器失败: {}", e);
decode_done.store(true, Ordering::Relaxed);
return;
}
};
if let Some(time) = seek_time {
let seek_to = SeekTo::Time {
time: Time::from(time),
track_id: Some(track_id),
};
let _ = format_reader.seek(SeekMode::Accurate, seek_to);
}
let ratio = target_sample_rate as f64 / src_rate;
let need_resample = (ratio - 1.0).abs() > 0.001;
let need_remix = src_channels != target_channels;
while !cancelled.load(Ordering::Relaxed) {
let packet = match format_reader.next_packet() {
Ok(p) => p,
Err(SymphoniaError::IoError(ref e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break;
}
Err(SymphoniaError::ResetRequired) => continue,
Err(e) => {
eprintln!("[audio] 读取包失败: {}", e);
break;
}
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(e) => {
eprintln!("[audio] 解码失败: {}", e);
continue;
}
};
let samples = if need_resample || need_remix {
resample_and_remix(&decoded, target_sample_rate, target_channels, src_rate, src_channels)
} else {
convert_to_interleaved_f32(&decoded)
};
let mut write_pos = 0;
while write_pos < samples.len() && !cancelled.load(Ordering::Relaxed) {
let remaining = &samples[write_pos..];
let n = producer.push_slice(remaining);
if n == 0 {
if !playing.load(Ordering::Relaxed) {
while !playing.load(Ordering::Relaxed) && !cancelled.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(10));
}
}
thread::sleep(Duration::from_millis(1));
continue;
}
write_pos += n;
}
}
decode_done.store(true, Ordering::Relaxed);
}
+55
View File
@@ -0,0 +1,55 @@
//! 音频输出设备管理
//!
//! 列举、查找、选择音频输出设备。当用户指定的设备不存在时回退到系统默认设备。
use cpal::traits::{DeviceTrait, HostTrait};
/// 获取系统默认输出设备的名称
pub fn get_system_default_device_name() -> Option<String> {
cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}
/// 列出系统中所有可用的音频输出设备名称(去重排序后)
pub fn list_output_devices() -> Vec<String> {
let host = cpal::default_host();
if let Ok(devices) = host.output_devices() {
let mut names: Vec<String> = devices.filter_map(|d| d.name().ok()).collect();
names.sort();
names.dedup();
names
} else {
vec![]
}
}
/// 按名称查找音频输出设备,未找到则返回 None
pub fn find_device_by_name(name: &str) -> Option<cpal::Device> {
let host = cpal::default_host();
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Ok(n) = d.name() {
if n == name {
return Some(d);
}
}
}
}
None
}
/// 获取音频输出设备,优先使用指定名称的设备,否则回退到系统默认设备
pub fn get_output_device(selected: &Option<String>) -> cpal::Device {
match selected {
Some(name) => find_device_by_name(name).unwrap_or_else(|| {
eprintln!("[audio] 未找到设备 `{}`,回退默认", name);
cpal::default_host()
.default_output_device()
.expect("无可用音频设备")
}),
None => cpal::default_host()
.default_output_device()
.expect("无可用音频设备"),
}
}
+44
View File
@@ -0,0 +1,44 @@
//! 音频流式下载
//!
//! 负责从网络 URL 流式下载音频数据到共享缓冲区,并通过事件通知下载进度。
use std::io::Read;
use tauri::{AppHandle, Emitter};
use crate::audio::buffer::SharedBuffer;
/// 流式下载音频数据到共享缓冲区,支持下载进度事件通知
pub fn download_audio_streaming(
url: &str,
buffer: &SharedBuffer,
app_handle: &AppHandle,
) -> Result<(), String> {
let resp = reqwest::blocking::get(url).map_err(|e| format!("下载失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("HTTP 错误: {}", resp.status()));
}
let total_size = resp.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut reader = resp;
loop {
if buffer.is_cancelled() {
return Err("下载已取消".to_string());
}
let mut chunk = [0u8; 8192];
let read_size = reader
.read(&mut chunk)
.map_err(|e| format!("读取失败: {}", e))?;
if read_size == 0 {
break;
}
buffer.write_chunk(&chunk[..read_size]);
downloaded += read_size as u64;
let progress = if total_size > 0 {
(downloaded as f64 / total_size as f64) * 100.0
} else {
0.0
};
let _ = app_handle.emit("cache-progress", progress);
}
Ok(())
}
+24
View File
@@ -0,0 +1,24 @@
//! 音频模块
//!
//! 负责音频的解码、播放、设备管理与对外 Tauri 命令。
//!
//! 模块结构:
//! - [`buffer`]: 流式缓冲区与读取器
//! - [`download`]: 网络音频流式下载
//! - [`decoder`]: symphonia 解码 + 重采样 + 声道重混
//! - [`output`]: cpal 输出流与播放状态
//! - [`device`]: 输出设备列举与选择
//! - [`controller`]: 命令分发与播放生命周期
//! - [`commands`]: 对外 Tauri 命令
pub mod buffer;
pub mod commands;
pub mod controller;
pub mod decoder;
pub mod device;
pub mod download;
pub mod output;
// 对外重导出,保持与旧 `audio.rs` 相同的公开 API
pub use commands::AppAudio;
pub use controller::AudioController;
+253
View File
@@ -0,0 +1,253 @@
//! 音频输出
//!
//! 基于 cpal 构建音频输出流,将环形缓冲区中的样本推送到设备。
//! 同时定义播放状态 `PlaybackState` 与输出上下文 `OutputContext`。
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::{SampleRate, Stream, StreamConfig};
use ringbuf::traits::Consumer;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use symphonia::core::io::MediaSourceStream;
use crate::audio::buffer::{RingConsumer, create_ring_buffer};
use crate::audio::decoder::decode_to_ring;
/// 初始缓冲区大小,达到此字节数后才开始播放
pub const INITIAL_BUFFER_SIZE: usize = 65536;
/// 环形缓冲区容量(采样数),约 4 秒的 48kHz 立体声数据
pub const RING_BUFFER_SAMPLES: usize = 48000 * 4;
/// 播放状态,记录当前播放的运行时信息
pub struct PlaybackState {
pub playing: Arc<AtomicBool>,
pub cancelled: Arc<AtomicBool>,
pub decode_done: Arc<AtomicBool>,
pub buffer_exhausted: Arc<AtomicBool>,
pub volume: Arc<Mutex<f32>>,
pub sample_rate: u32,
pub channels: u16,
pub samples_played: Arc<AtomicU64>,
pub start_time: f64,
}
impl PlaybackState {
/// 根据已播放采样数计算当前播放位置(秒)
pub fn position(&self) -> f64 {
let samples = self.samples_played.load(Ordering::Relaxed) as f64;
self.start_time + samples / (self.sample_rate as f64 * self.channels as f64)
}
}
/// 输出上下文,持有音频输出流和解码线程的句柄
pub struct OutputContext {
pub _stream: Stream,
pub _decode_thread: thread::JoinHandle<()>,
pub playback: PlaybackState,
}
/// 启动音频播放,创建解码线程和 cpal 输出流
pub fn start_playback(
mss: MediaSourceStream,
device: &cpal::Device,
current_volume: f32,
seek_time: Option<f64>,
) -> Result<OutputContext, String> {
let default_config = device
.default_output_config()
.map_err(|e| format!("获取设备配置失败: {}", e))?;
let sr = default_config.sample_rate().0;
let ch = default_config.channels();
let sample_format = default_config.sample_format();
let (producer, consumer) = create_ring_buffer(RING_BUFFER_SAMPLES);
let playing = Arc::new(AtomicBool::new(true));
let cancelled = Arc::new(AtomicBool::new(false));
let decode_done = Arc::new(AtomicBool::new(false));
let buffer_exhausted = Arc::new(AtomicBool::new(false));
let volume = Arc::new(Mutex::new(current_volume));
let samples_played = Arc::new(AtomicU64::new(0));
let start_time = seek_time.unwrap_or(0.0);
let playing_clone = playing.clone();
let cancelled_clone = cancelled.clone();
let decode_done_clone = decode_done.clone();
let decode_handle = thread::spawn(move || {
decode_to_ring(
mss,
producer,
playing_clone,
cancelled_clone,
decode_done_clone,
seek_time,
sr,
ch,
);
});
let stream = build_cpal_stream(
device,
sr,
ch,
sample_format,
consumer,
volume.clone(),
playing.clone(),
samples_played.clone(),
decode_done.clone(),
buffer_exhausted.clone(),
)?;
stream.play().map_err(|e| format!("播放流失败: {}", e))?;
Ok(OutputContext {
_stream: stream,
_decode_thread: decode_handle,
playback: PlaybackState {
playing,
cancelled,
decode_done,
buffer_exhausted,
volume,
sample_rate: sr,
channels: ch,
samples_played,
start_time,
},
})
}
/// 构建 cpal 音频输出流,支持 f32、i16、u16 三种采样格式
#[allow(clippy::too_many_arguments)]
fn build_cpal_stream(
device: &cpal::Device,
sample_rate: u32,
channels: u16,
sample_format: cpal::SampleFormat,
mut consumer: RingConsumer,
volume: Arc<Mutex<f32>>,
playing: Arc<AtomicBool>,
samples_played: Arc<AtomicU64>,
decode_done: Arc<AtomicBool>,
buffer_exhausted: Arc<AtomicBool>,
) -> Result<Stream, String> {
let config = StreamConfig {
channels,
sample_rate: SampleRate(sample_rate),
buffer_size: cpal::BufferSize::Default,
};
let err_fn = |err: cpal::StreamError| eprintln!("[audio] 输出错误: {}", err);
match sample_format {
cpal::SampleFormat::F32 => {
let sp = samples_played;
let dd = decode_done;
let be = buffer_exhausted;
device
.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
if !playing.load(Ordering::Relaxed) {
data.fill(0.0);
return;
}
let vol = *volume.lock().unwrap();
let read = consumer.pop_slice(data);
for (i, s) in data.iter_mut().enumerate() {
if i < read {
*s *= vol;
} else {
*s = 0.0;
}
}
sp.fetch_add(read as u64, Ordering::Relaxed);
if read == 0 && dd.load(Ordering::Relaxed) {
be.store(true, Ordering::Relaxed);
}
},
err_fn,
None,
)
.map_err(|e| format!("创建输出流失败: {}", e))
}
cpal::SampleFormat::I16 => {
let mut f32_buf: Vec<f32> = Vec::new();
let sp = samples_played;
let dd = decode_done;
let be = buffer_exhausted;
device
.build_output_stream(
&config,
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
if !playing.load(Ordering::Relaxed) {
data.fill(0);
return;
}
let vol = *volume.lock().unwrap();
if f32_buf.len() != data.len() {
f32_buf.resize(data.len(), 0.0);
}
let read = consumer.pop_slice(&mut f32_buf);
for (i, s) in data.iter_mut().enumerate() {
if i < read {
*s = (f32_buf[i] * vol * 32767.0)
.clamp(-32768.0, 32767.0) as i16;
} else {
*s = 0;
}
}
sp.fetch_add(read as u64, Ordering::Relaxed);
if read == 0 && dd.load(Ordering::Relaxed) {
be.store(true, Ordering::Relaxed);
}
},
err_fn,
None,
)
.map_err(|e| format!("创建输出流失败: {}", e))
}
cpal::SampleFormat::U16 => {
let mut f32_buf: Vec<f32> = Vec::new();
let sp = samples_played;
let dd = decode_done;
let be = buffer_exhausted;
device
.build_output_stream(
&config,
move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
if !playing.load(Ordering::Relaxed) {
data.fill(32768);
return;
}
let vol = *volume.lock().unwrap();
if f32_buf.len() != data.len() {
f32_buf.resize(data.len(), 0.0);
}
let read = consumer.pop_slice(&mut f32_buf);
for (i, s) in data.iter_mut().enumerate() {
if i < read {
*s = ((f32_buf[i] * vol + 1.0) * 32767.5)
.clamp(0.0, 65535.0) as u16;
} else {
*s = 32768;
}
}
sp.fetch_add(read as u64, Ordering::Relaxed);
if read == 0 && dd.load(Ordering::Relaxed) {
be.store(true, Ordering::Relaxed);
}
},
err_fn,
None,
)
.map_err(|e| format!("创建输出流失败: {}", e))
}
_ => Err(format!("不支持的采样格式: {:?}", sample_format)),
}
}
+126 -33
View File
@@ -1,60 +1,84 @@
use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
menu::{MenuBuilder, MenuItemBuilder},
Manager, LogicalSize, Emitter,
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
Manager, Emitter,
};
use std::sync::atomic::{AtomicBool, Ordering};
mod api;
mod audio;
mod media_controls;
use api::ApiController;
use audio::AppAudio;
static ALLOW_EXIT: AtomicBool = AtomicBool::new(false);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let window = app.get_webview_window("main").unwrap();
// 窗口最小尺寸
window.set_min_size(Some(LogicalSize::new(1280.0, 700.0)))?;
// 注入控制器
let api_controller = ApiController::new();
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
let api_controller = ApiController::new(app_data_dir);
app.manage(api_controller);
let audio_controller = audio::AudioController::new(app.handle().clone());
let app_audio = AppAudio(std::sync::Mutex::new(audio_controller));
app.manage(app_audio);
// 托盘菜单
#[cfg(target_os = "windows")]
{
use raw_window_handle::HasWindowHandle;
use raw_window_handle::RawWindowHandle;
let hwnd = if let Some(win) = app.get_webview_window("main") {
win.window_handle().ok().and_then(|h| {
if let RawWindowHandle::Win32(h) = h.as_raw() {
Some(h.hwnd.get() as *mut std::ffi::c_void)
} else {
None
}
})
} else {
None
};
media_controls::start_media_controls(app.handle().clone(), hwnd);
}
#[cfg(not(target_os = "windows"))]
media_controls::start_media_controls(app.handle().clone(), None);
let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?;
let _sep1 = PredefinedMenuItem::separator(app)?;
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
let play_pause = MenuItemBuilder::with_id("play_pause", "播放/暂停").build(app)?;
let next = MenuItemBuilder::with_id("next", "下一首").build(app)?;
let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?;
let _sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "退出").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.separator()
.item(&play_pause)
.item(&next)
.item(&prev)
.items(&[&prev, &play_pause, &next])
.separator()
.item(&quit)
.build()?;
// 托盘图标(使用应用默认图标)
let icon = app.default_window_icon().cloned().unwrap();
let _tray = TrayIconBuilder::with_id("main-tray")
.tooltip("Nekosonic")
.tooltip("Nekosonic Music")
.icon(icon)
.show_menu_on_left_click(false)
.menu(&menu)
.on_menu_event(|app, event| {
let window = app.get_webview_window("main").unwrap();
match event.id().as_ref() {
"show" => {
window.show().unwrap();
window.set_focus().unwrap();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
let _ = app.emit("window-shown", ());
}
}
"play_pause" => {
let _ = app.emit("tray-play-pause", ());
@@ -66,7 +90,10 @@ pub fn run() {
let _ = app.emit("tray-prev", ());
}
"quit" => {
app.exit(0);
ALLOW_EXIT.store(true, Ordering::SeqCst);
if let Some(w) = app.get_webview_window("main") {
let _ = w.close();
}
}
_ => {}
}
@@ -79,19 +106,27 @@ pub fn run() {
} = event
{
let app = tray.app_handle();
let window = app.get_webview_window("main").unwrap();
window.show().unwrap();
window.set_focus().unwrap();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
let _ = app.emit("window-shown", ());
}
}
})
.build(app)?;
// 点击关闭按钮时隐藏到托盘
let window = app.get_webview_window("main").unwrap();
let window_clone = window.clone();
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
close_api.prevent_close(); // 阻止窗口关闭
let _ = window_clone.hide(); // 隐藏到托盘
if ALLOW_EXIT.load(Ordering::SeqCst) {
return;
}
close_api.prevent_close();
let _ = window_clone.hide();
let _ = app_handle.emit("window-hidden", ());
}
});
@@ -102,6 +137,8 @@ pub fn run() {
api::logout,
api::search_songs,
api::cloudsearch,
api::search_suggest,
api::get_song_url,
api::get_hot_search,
api::get_playlist_detail,
@@ -109,22 +146,78 @@ pub fn run() {
api::user_playlist,
api::recommend_resource,
api::recommend_songs,
api::personalized,
api::personalized_newsong,
api::top_artists,
api::top_song,
api::album_newest,
api::personal_fm,
api::personal_fm_mode,
api::fm_trash,
api::scrobble,
api::get_song_detail,
api::get_qr_key,
api::create_qr,
api::check_qr_status,
api::get_login_status,
api::likelist,
api::user_record,
api::like_song,
api::record_recent_song,
api::playlist_subscribe,
api::playlist_track_all,
api::exit_app,
audio::play_audio,
audio::pause_audio,
audio::resume_audio,
audio::stop_audio,
audio::get_output_devices,
audio::set_output_device,
audio::seek_audio,
audio::set_volume
audio::commands::play_audio,
audio::commands::play_local_audio,
audio::commands::pause_audio,
audio::commands::resume_audio,
audio::commands::stop_audio,
audio::commands::get_output_devices,
audio::commands::set_output_device,
audio::commands::seek_audio,
audio::commands::get_audio_position,
audio::commands::set_volume,
audio::commands::is_audio_playing,
api::download_song,
api::list_local_songs,
api::scan_local_folders,
api::delete_local_song,
api::check_local_song,
api::get_default_download_path,
api::artist_detail,
api::artist_songs,
api::artist_album,
api::artist_desc,
api::artist_sub,
api::artist_sublist,
api::album_detail,
api::comment_new,
api::comment_hot,
api::comment_floor,
api::comment_like,
api::user_cloud,
api::user_cloud_detail,
api::user_cloud_del,
api::cloud_upload,
api::read_image_as_data_url,
api::show_item_in_folder,
])
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
let _ = app.emit("window-shown", ());
}
}))
.run(tauri::generate_context!())
.expect("error while running Nekosonic");
}
}
+127
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);
}
+23 -7
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Nekosonic",
"version": "0.1.0",
"version": "0.8.0",
"identifier": "com.atdunbg.Nekosonic",
"build": {
"beforeDevCommand": "npm run dev",
@@ -13,10 +13,10 @@
"windows": [
{
"title": "Nekosonic",
"width": 1200,
"height": 700,
"minWidth": 1200,
"minHeight": 700,
"width": 1100,
"height": 680,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"decorations": false
}
@@ -27,6 +27,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -34,6 +35,21 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
}
},
"plugins": {
"updater": {
"active": true,
"endpoints": [
"https://github.com/atdunbg/Nekosonic-Music/releases/latest/download/latest.json"
],
"dialog": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1MDdCMTJCRTE3MUI4N0QKUldSOXVISGhLN0VITmM3ZkJlbjF3UGJrK3h6ellWZ2xSUG03b3d1RWlDeldSWk1nc0pic2J2MVkK"
}
}
}
}
+352 -229
View File
@@ -1,259 +1,309 @@
<template>
<div class="flex flex-col h-screen bg-gray-950 text-white overflow-hidden">
<!-- ========= 自定义标题栏可拖拽无边框 ========= -->
<!-- 壁纸层fixed 全屏最底层 -->
<div
v-if="settings.currentWallpaper.path"
class="fixed inset-0 z-0 pointer-events-none overflow-hidden"
>
<div
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 bg-gray-900/90 backdrop-blur select-none flex-shrink-0"
>
<span class="text-xs text-gray-400 font-medium ml-2">Nekosonic Music</span>
<div class="flex items-center gap-1.5">
<!-- 最小化 -->
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<!-- 最大化 / 还原 -->
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<!-- 关闭 -->
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
class="absolute inset-[-20px] bg-cover bg-center bg-no-repeat"
:style="wallpaperStyle"
></div>
</div>
<!-- 主题色遮罩层半透明主题色覆盖壁纸保证文字可读 -->
<div
v-if="settings.currentWallpaper.path"
class="fixed inset-0 z-[1] pointer-events-none"
:style="overlayStyle"
></div>
<!-- 主容器 -->
<div class="flex flex-col h-screen text-content overflow-hidden relative z-[2]" :style="rootBgStyle">
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
<Sidebar :mode="ui.sidebarMode" />
<div class="flex flex-col flex-1 overflow-hidden">
<TopBar @close="closeWindow" />
<main class="flex-1 overflow-hidden flex flex-col" :style="{ paddingBottom: player.currentSong ? '70px' : '0px' }">
<div ref="mainScrollRef" class="flex-1 overflow-y-auto min-h-0 relative">
<router-view v-slot="{ Component }">
<keep-alive :max="10" :include="keepAliveInclude">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</main>
</div>
</div>
<!-- 主体内容区 -->
<div class="flex flex-1 overflow-hidden">
<!-- 左侧导航无边框 -->
<nav class="w-56 flex-shrink-0 flex flex-col bg-gray-900/80 backdrop-blur">
<div class="flex-1 p-4 overflow-y-auto pb-24 flex flex-col">
<!-- 推荐 & 发现 -->
<div class="space-y-0.5">
<router-link to="/"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🏠</span> 推荐
</router-link>
<router-link to="/discover"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🔍</span> 发现
</router-link>
<button
@click="openRoamFromSidebar"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5 w-full text-left"
>
<span>🌀</span> 漫游
</button>
</div>
<RoamDrawer :visible="windowVisible && ui.showRoamDrawer" />
<!-- 我的 -->
<div class="mt-4 mb-1 pt-2">
<p class="text-xs text-gray-500 px-3 mb-1">我的</p>
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span></span> 我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span>🕐</span> 最近播放
</router-link>
</div>
<PlayerBar v-if="player.currentSong" />
<ToastContainer />
<!-- 创建的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-gray-500">我的歌单</p>
<span class="text-xs text-gray-500 transition-transform"
:class="{ 'rotate-90': showCreatedPlaylists }"></span>
</div>
<div v-show="showCreatedPlaylists" class="space-y-0.5">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
{{ pl.name }}
</div>
</div>
</div>
<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 || '')"
/>
<!-- 收藏的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showSubPlaylists = !showSubPlaylists">
<p class="text-xs text-gray-500">收藏的歌单</p>
<span class="text-xs text-gray-500 transition-transform" :class="{ 'rotate-90': showSubPlaylists }"></span>
</div>
<div v-show="showSubPlaylists" class="space-y-0.5">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
{{ pl.name }}
</div>
</div>
</div>
<!-- 用户区域 -->
<div class="mt-auto pt-4">
<div v-if="!userStore.isLoggedIn" class="px-2 space-y-2">
<p class="text-xs text-gray-500">登录后享受个人歌单</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 transition text-sm font-medium text-green-400">
<span>🔑</span> 立即登录
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-green-400/50" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
<button @click="userStore.logout()"
class="text-xs text-gray-500 hover:text-red-400 transition">退出登录</button>
</div>
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<main class="flex-1 overflow-y-auto pb-24">
<router-view v-slot="{ Component }">
<keep-alive :max="3" include="HomeView,DiscoverView">
<component :is="Component" />
</keep-alive>
</router-view>
</main>
</div>
<!-- 全屏漫游抽屉 -->
<Transition name="drawer">
<div
v-if="player.showRoamDrawer"
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
>
<div class="h-16 flex items-center px-6 flex-shrink-0">
<button @click="player.closeRoamDrawer()" class="text-white/80 hover:text-white transition">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 min-h-0 flex px-8 pb-8">
<div class="flex-shrink-0 mr-12 flex flex-col items-center self-center">
<img
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
/>
<h1 class="text-2xl font-bold text-white">{{ roamSong?.name }}</h1>
<p class="text-gray-400 mt-2">{{ roamArtists }}</p>
</div>
<div ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center space-y-3 py-8">
<p
v-for="(line, idx) in lyrics"
:key="idx"
:class="idx === currentLyricIdx ? 'text-green-400 font-medium text-lg transition' : 'text-gray-400 text-base'"
>
{{ line.text }}
</p>
</div>
<div v-else class="text-gray-500 text-center mt-8">暂无歌词</div>
</div>
</div>
</div>
</Transition>
<!-- 底部播放栏 -->
<PlayerBar />
<CloseModal
:visible="showCloseModal"
@confirm="handleCloseAction"
@cancel="showCloseModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useUserStore } from './stores/user';
import PlayerBar from './components/PlayerBar.vue';
import { useSettingsStore, type CloseAction } from './stores/settings';
import { usePlayerStore } from './stores/player';
import { useLyric } from './composables/UserLyric';
import { useUiStore } from './stores/ui';
import TopBar from './components/TopBar.vue';
import Sidebar from './components/Sidebar.vue';
import RoamDrawer from './components/RoamDrawer.vue';
import PlayerBar from './components/PlayerBar.vue';
import ToastContainer from './components/ToastContainer.vue';
import CloseModal from './components/CloseModal.vue';
import UpdateDialog from './components/UpdateDialog.vue';
import { useOnlineStatus } from './composables/useOnlineStatus';
import { showToast } from './composables/useToast';
import { useUpdater } from './composables/useUpdater';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen } from '@tauri-apps/api/event';
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
import { MusicApi, AudioApi, DeviceApi, AppApi } from './api';
import { hexToRgba } from './utils/color';
const router = useRouter();
const userStore = useUserStore();
const player = usePlayerStore();
const ui = useUiStore();
const settings = useSettingsStore();
const updater = useUpdater();
const { isOnline } = useOnlineStatus();
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
//
const { lyrics, currentLyricIdx } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamSong = computed(() => player.currentSong);
const roamArtists = computed(() => {
if (!roamSong.value) return '';
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
watch(isOnline, (val, old) => {
if (val && !old) showToast('网络已恢复', 'success');
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
});
watch(currentLyricIdx, () => {
if (player.showRoamDrawer && lyricScrollContainer.value) {
nextTick(() => {
const active = lyricScrollContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
const showCloseModal = ref(false);
const windowVisible = ref(true);
// --- Keep-alive ---
// 30访FavoriteSongs
const route = useRoute();
// ref
const mainScrollRef = ref<HTMLElement | null>(null);
//
// keep-alive
watch(() => route.path, () => {
// nextTick + DOM
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (mainScrollRef.value) {
mainScrollRef.value.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
});
}
});
});
async function openRoamFromSidebar() {
if (player.isFmMode) {
player.openRoamDrawer();
const ROUTE_COMPONENT: Record<string, string> = {
home: 'HomeView', discover: 'DiscoverView', search: 'DiscoverView',
favorites: 'FavoriteSongsView', daily: 'DailySongsView',
'local-music': 'LocalMusicView', 'downloaded-music': 'DownloadedMusicView',
'cloud-music': 'CloudMusicView',
playlist: 'PlaylistDetailView', artist: 'ArtistDetailView', album: 'AlbumDetailView',
};
const ALL_CACHEABLE = [...new Set(Object.values(ROUTE_COMPONENT))];
const PERMANENT = new Set(['FavoriteSongsView']);
const CACHE_TTL = 300_000;
const lastActivatedAt: Record<string, number> = {};
const navStack = ref<string[]>([]);
const currentComp = ref('');
for (const name of ALL_CACHEABLE) lastActivatedAt[name] = Date.now();
watch(() => route.name, (newName, oldName) => {
// 30s
const oldComp = ROUTE_COMPONENT[oldName as string];
if (oldComp) lastActivatedAt[oldComp] = Date.now();
const comp = ROUTE_COMPONENT[newName as string];
if (!comp) return;
currentComp.value = comp;
lastActivatedAt[comp] = Date.now();
const idx = navStack.value.indexOf(comp);
if (idx !== -1) {
//
navStack.value = navStack.value.slice(0, idx + 1);
} else {
await player.loadFm();
navStack.value.push(comp);
}
}, { immediate: true });
function computeInclude(): string[] {
const now = Date.now();
const include = new Set<string>(PERMANENT);
if (currentComp.value) include.add(currentComp.value);
for (const name of navStack.value) include.add(name);
for (const name of ALL_CACHEABLE) {
if (lastActivatedAt[name] && now - lastActivatedAt[name] < CACHE_TTL) include.add(name);
}
return [...include];
}
const keepAliveInclude = ref<string[]>(computeInclude());
let cleanupTimer: ReturnType<typeof setInterval>;
function startCleanup() { cleanupTimer = setInterval(() => { keepAliveInclude.value = computeInclude(); }, 10_000); }
function stopCleanup() { clearInterval(cleanupTimer); }
watch(() => settings.skin, () => {
settings.applySkin();
}, { immediate: true });
// Rust base64 data URL
const wallpaperDataUrl = ref('');
const wallpaperStyle = computed(() => {
if (!wallpaperDataUrl.value) return {};
const wp = settings.currentWallpaper;
return {
backgroundImage: `url(${wallpaperDataUrl.value})`,
filter: `blur(${wp.blur}px)`,
opacity: wp.opacity,
};
});
//
watch(() => settings.currentWallpaper.path, async (path) => {
if (!path) {
wallpaperDataUrl.value = '';
return;
}
try {
wallpaperDataUrl.value = await AppApi.readImageAsDataUrl(path);
} catch (e) {
console.error('加载壁纸失败:', e);
wallpaperDataUrl.value = '';
}
}, { immediate: true });
// --- ---
const DRAWER_BREAKPOINT = 768; // px
let savedSidebarMode: 'expanded' | 'collapsed' = 'expanded';
function applyResponsiveSidebar() {
const width = window.innerWidth;
if (width < DRAWER_BREAKPOINT) {
//
if (ui.sidebarMode !== 'drawer') {
savedSidebarMode = ui.sidebarMode as 'expanded' | 'collapsed';
ui.setSidebarMode('drawer');
}
} else {
//
if (ui.sidebarMode === 'drawer') {
ui.setSidebarMode(savedSidebarMode);
}
}
}
async function loadPlaylists() {
if (!userStore.isLoggedIn || !userStore.user) return;
try {
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
const data = JSON.parse(jsonStr);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch (e) { /* 忽略 */ }
}
//
const rootBgStyle = computed(() => {
const wp = settings.currentWallpaper;
if (wp.path) {
return {}; //
}
return {
backgroundColor: 'var(--c-bg)',
};
});
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
// --c-bg
//
const overlayStyle = computed(() => {
const bgColor = settings.currentColors.bg;
const rgba = hexToRgba(bgColor, 0.82);
return {
backgroundColor: rgba,
};
});
watch(() => userStore.isLoggedIn, (val) => {
if (val) loadPlaylists();
if (val) {
player.loadLikedIds();
}
});
onMounted(async () => {
if (userStore.isLoggedIn) loadPlaylists();
try { await invoke('stop_audio'); } catch {}
try {
const jsonStr: string = await invoke('get_login_status');
const data = JSON.parse(jsonStr);
if (data.account || data.profile) {
const profile = data.profile || data.account;
userStore.setUser({
userId: profile.userId,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
});
}
} catch {}
onMounted(() => {
document.addEventListener('contextmenu', (e) => e.preventDefault());
startCleanup();
//
applyResponsiveSidebar();
window.addEventListener('resize', applyResponsiveSidebar);
AudioApi.stopAudio().catch(() => {});
if (userStore.isLoggedIn) {
player.loadLikedIds();
MusicApi.getLoginStatus().then(jsonStr => {
if (!jsonStr) return;
const data = JSON.parse(jsonStr);
if (data.account || data.profile) {
const profile = data.profile || data.account;
userStore.setUser({
userId: profile.userId,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
});
}
}).catch(() => {});
}
updater.checkForUpdate(true);
if (settings.outputDevice) {
DeviceApi.setOutputDevice(settings.outputDevice).catch(() => {});
}
});
// ---------- ----------
const currentWindow = getCurrentWindow();
function minimizeWindow() { currentWindow.minimize(); }
async function toggleMaximize() {
const isMaximized = await currentWindow.isMaximized();
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
function closeWindow() {
if (settings.closeAction === 'ask') {
showCloseModal.value = true;
} else if (settings.closeAction === 'minimize') {
currentWindow.hide();
} else {
AppApi.exitApp();
}
}
function closeWindow() { currentWindow.close(); }
import { listen } from '@tauri-apps/api/event';
function handleCloseAction(action: CloseAction, remember: boolean) {
if (remember) {
settings.setCloseAction(action);
}
showCloseModal.value = false;
if (action === 'minimize') {
currentWindow.hide();
} else {
AppApi.exitApp();
}
}
onMounted(() => {
const unlisten1 = listen('tray-play-pause', () => {
player.toggle(); // player usePlayerStore
player.toggle();
});
const unlisten2 = listen('tray-next', () => {
player.next();
@@ -261,25 +311,98 @@ onMounted(() => {
const unlisten3 = listen('tray-prev', () => {
player.prev();
});
const unlisten4 = listen('window-hidden', () => {
windowVisible.value = false;
keepAliveInclude.value = [];
stopCleanup();
});
const unlisten5 = listen('window-shown', () => {
windowVisible.value = true;
keepAliveInclude.value = computeInclude();
startCleanup();
});
//
onBeforeUnmount(() => {
onBeforeUnmount(() => {
unlisten1.then(fn => fn());
unlisten2.then(fn => fn());
unlisten3.then(fn => fn());
unlisten4.then(fn => fn());
unlisten5.then(fn => fn());
stopCleanup();
});
});
</script>
<style>
.drawer-enter-active,
.drawer-leave-active { transition: transform 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { transform: translateY(100%); }
.custom-scroll::-webkit-scrollbar { width: 4px; }
.custom-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
async function registerGlobalShortcuts() {
const globalActions: Record<string, () => void> = {
globalPlayPause: () => player.toggle(),
globalPrev: () => player.prev(),
globalNext: () => player.next(),
globalVolUp: () => player.adjustVolume(5),
globalVolDown: () => player.adjustVolume(-5),
};
for (const [id, action] of Object.entries(globalActions)) {
const key = settings.shortcuts[id]?.key;
if (!key) continue;
try { await unregister(key); } catch { /* 忽略 */ }
try {
await register(key, (event) => {
if (event.state === 'Pressed') action();
});
} catch { /* 忽略 */ }
}
}
</style>
watch(() => settings.shortcuts, () => {
registerGlobalShortcuts();
}, { deep: true });
onMounted(() => {
registerGlobalShortcuts();
});
function parseShortcutKey(combo: string): { ctrl: boolean; alt: boolean; shift: boolean; code: string } {
const parts = combo.split('+');
return {
ctrl: parts.includes('Control'),
alt: parts.includes('Alt'),
shift: parts.includes('Shift'),
code: parts.find(p => !['Control', 'Alt', 'Shift'].includes(p)) || '',
};
}
onMounted(() => {
function onKeydown(e: KeyboardEvent) {
const el = e.target as HTMLElement;
const isEditable = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
if (e.code === 'Space' && !isEditable) {
e.preventDefault();
player.toggle();
}
const localActions: Record<string, () => void> = {
playPause: () => player.toggle(),
prev: () => player.prev(),
next: () => player.next(),
volUp: () => player.adjustVolume(5),
volDown: () => player.adjustVolume(-5),
};
for (const [id, action] of Object.entries(localActions)) {
const key = settings.shortcuts[id]?.key;
if (!key) continue;
const parsed = parseShortcutKey(key);
const ctrlMatch = parsed.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
const altMatch = parsed.alt ? e.altKey : !e.altKey;
const shiftMatch = parsed.shift ? e.shiftKey : !e.shiftKey;
if (ctrlMatch && altMatch && shiftMatch && e.code === parsed.code) {
e.preventDefault();
action();
return;
}
}
}
window.addEventListener('keydown', onKeydown);
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
});
});
</script>
+11
View File
@@ -0,0 +1,11 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const AlbumApi = {
/** 获取专辑详情 */
async albumDetail(id: number): Promise<string> {
return invoke('album_detail', { id });
},
};
+21
View File
@@ -0,0 +1,21 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const AppApi = {
/** 退出应用 */
exitApp(): Promise<void> {
return invoke('exit_app');
},
/** 读取图片为 data URL */
async readImageAsDataUrl(path: string): Promise<string> {
return invoke('read_image_as_data_url', { path });
},
/** 在文件管理器中显示文件 */
async showItemInFolder(path: string): Promise<void> {
return invoke('show_item_in_folder', { path });
},
};
+36
View File
@@ -0,0 +1,36 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const ArtistApi = {
/** 获取歌手详情 */
async artistDetail(id: number): Promise<string> {
return invoke('artist_detail', { id });
},
/** 获取歌手歌曲 */
async artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise<string> {
return invoke('artist_songs', { query });
},
/** 获取歌手专辑 */
async artistAlbum(id: number, limit: number, offset: number): Promise<string> {
return invoke('artist_album', { id, limit, offset });
},
/** 获取歌手描述 */
async artistDesc(id: number): Promise<string> {
return invoke('artist_desc', { id });
},
/** 关注/取消关注歌手 */
async artistSub(id: number, sub: boolean): Promise<string> {
return invoke('artist_sub', { query: { id, sub } });
},
/** 已关注的歌手列表 */
async artistSublist(limit = 100, offset = 0): Promise<string> {
return invoke('artist_sublist', { query: { limit, offset } });
},
};
+51
View File
@@ -0,0 +1,51 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const AudioApi = {
/** 播放网络音频 */
async playAudio(url: string): Promise<void> {
return invoke('play_audio', { url });
},
/** 播放本地音频文件 */
async playLocalAudio(path: string): Promise<void> {
return invoke('play_local_audio', { path });
},
/** 暂停播放 */
async pauseAudio(): Promise<void> {
return invoke('pause_audio');
},
/** 恢复播放 */
async resumeAudio(): Promise<void> {
return invoke('resume_audio');
},
/** 停止播放 */
async stopAudio(): Promise<void> {
return invoke('stop_audio');
},
/** 跳转到指定位置(秒) */
async seekAudio(time: number): Promise<void> {
return invoke('seek_audio', { time });
},
/** 设置音量(0.0 - 1.0 */
async setVolume(vol: number): Promise<void> {
return invoke('set_volume', { vol });
},
/** 获取当前播放位置(秒) */
async getAudioPosition(): Promise<number> {
return invoke('get_audio_position');
},
/** 是否正在播放 */
async isAudioPlaying(): Promise<boolean> {
return invoke('is_audio_playing');
},
};
+21
View File
@@ -0,0 +1,21 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const CloudApi = {
/** 获取云盘歌曲列表 */
async userCloud(limit = 30, offset = 0): Promise<string> {
return invoke('user_cloud', { limit, offset });
},
/** 删除云盘歌曲 */
async userCloudDel(id: number): Promise<string> {
return invoke('user_cloud_del', { id });
},
/** 上传歌曲到云盘 */
async cloudUpload(filePath: string): Promise<string> {
return invoke('cloud_upload', { filePath });
},
};
+16
View File
@@ -0,0 +1,16 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const CommentApi = {
/** 获取热门评论 */
async commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise<string> {
return invoke('comment_hot', { query });
},
/** 点赞/取消点赞评论 */
async commentLike(query: { t: number; type: number; id: number; cid: number }): Promise<void> {
return invoke('comment_like', { query });
},
};
+16
View File
@@ -0,0 +1,16 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const DeviceApi = {
/** 获取所有输出设备 */
async getOutputDevices(): Promise<string[]> {
return invoke('get_output_devices');
},
/** 设置输出设备(null 表示默认) */
async setOutputDevice(device: string | null): Promise<void> {
return invoke('set_output_device', { device });
},
};
+40
View File
@@ -0,0 +1,40 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const DownloadApi = {
/** 下载歌曲 */
async 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 });
},
/** 列出已下载的歌曲 */
async listLocalSongs(downloadPath: string | null): Promise<any[]> {
return invoke('list_local_songs', { downloadPath });
},
/** 扫描本地文件夹 */
async scanLocalFolders(paths: string[]): Promise<any[]> {
return invoke('scan_local_folders', { paths });
},
/** 删除本地歌曲文件 */
async deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise<void> {
return invoke('delete_local_song', { query });
},
/** 获取默认下载路径 */
async getDefaultDownloadPath(): Promise<string> {
return invoke('get_default_download_path');
},
};
+21
View File
@@ -0,0 +1,21 @@
import { invoke } from '@tauri-apps/api/core';
/**
* FM API
*/
export const FmApi = {
/** 获取私人 FM 歌曲 */
async personalFm(): Promise<string> {
return invoke('personal_fm');
},
/** 切换私人 FM 模式 */
async personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise<string> {
return invoke('personal_fm_mode', { query });
},
/** 私人 FM 不喜欢(扔进垃圾桶) */
async fmTrash(id: number, time: number): Promise<void> {
return invoke('fm_trash', { query: { id, time } });
},
};
+88
View File
@@ -0,0 +1,88 @@
/**
* API
*
*
*
* import { SongApi } from '../api/song';
*/
import { LoginApi } from './login';
import { SongApi } from './song';
import { PlaylistApi } from './playlist';
import { SearchApi } from './search';
import { AlbumApi } from './album';
import { ArtistApi } from './artist';
import { CommentApi } from './comment';
import { FmApi } from './fm';
import { CloudApi } from './cloud';
import { AudioApi } from './audio';
import { DeviceApi } from './device';
import { DownloadApi } from './download';
import { AppApi } from './app';
import { RecApi } from './rec';
export { LoginApi, SongApi, PlaylistApi, SearchApi, AlbumApi, ArtistApi, CommentApi, FmApi, CloudApi, AudioApi, DeviceApi, DownloadApi, AppApi, RecApi };
/**
* API
* @deprecated 使 Api `SongApi``PlaylistApi`
*/
export const MusicApi = {
// 登录
getLoginStatus: LoginApi.getLoginStatus,
logout: LoginApi.logout,
getQrKey: LoginApi.getQrKey,
checkQrStatus: LoginApi.checkQrStatus,
// 歌曲
getSongDetail: SongApi.getSongDetail,
getSongUrl: SongApi.getSongUrl,
getLyric: SongApi.getLyric,
likelist: SongApi.likelist,
likeSong: SongApi.likeSong,
scrobble: SongApi.scrobble,
// 歌单
userPlaylist: PlaylistApi.userPlaylist,
getPlaylistDetail: PlaylistApi.getPlaylistDetail,
playlistTrackAll: PlaylistApi.playlistTrackAll,
playlistSubscribe: PlaylistApi.playlistSubscribe,
recommendResource: PlaylistApi.recommendResource,
recommendSongs: PlaylistApi.recommendSongs,
// 搜索
searchSuggest: SearchApi.searchSuggest,
getHotSearch: SearchApi.getHotSearch,
cloudsearch: SearchApi.cloudsearch,
// 专辑
albumDetail: AlbumApi.albumDetail,
// 歌手
artistDetail: ArtistApi.artistDetail,
artistSongs: ArtistApi.artistSongs,
artistAlbum: ArtistApi.artistAlbum,
artistDesc: ArtistApi.artistDesc,
artistSub: ArtistApi.artistSub,
artistSublist: ArtistApi.artistSublist,
// 评论
commentHot: CommentApi.commentHot,
commentLike: CommentApi.commentLike,
// 私人 FM
personalFm: FmApi.personalFm,
personalFmMode: FmApi.personalFmMode,
fmTrash: FmApi.fmTrash,
// 云盘
userCloud: CloudApi.userCloud,
userCloudDel: CloudApi.userCloudDel,
cloudUpload: CloudApi.cloudUpload,
// 推荐
personalized: RecApi.personalized,
personalizedNewsong: RecApi.personalizedNewsong,
topArtists: RecApi.topArtists,
topSong: RecApi.topSong,
albumNewest: RecApi.albumNewest,
};
+26
View File
@@ -0,0 +1,26 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const LoginApi = {
/** 获取登录状态 */
async getLoginStatus(): Promise<string> {
return invoke('get_login_status');
},
/** 退出登录 */
async logout(): Promise<void> {
return invoke('logout');
},
/** 获取二维码登录 key */
async getQrKey(): Promise<string> {
return invoke('get_qr_key');
},
/** 检查二维码扫描状态 */
async checkQrStatus(key: string): Promise<string> {
return invoke('check_qr_status', { query: { key } });
},
};
+36
View File
@@ -0,0 +1,36 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const PlaylistApi = {
/** 获取用户歌单列表 */
async userPlaylist(uid: number): Promise<string> {
return invoke('user_playlist', { uid });
},
/** 获取歌单详情 */
async getPlaylistDetail(id: number): Promise<string> {
return invoke('get_playlist_detail', { id });
},
/** 获取歌单全部曲目 */
async playlistTrackAll(id: number): Promise<string> {
return invoke('playlist_track_all', { query: { id } });
},
/** 订阅/取消订阅歌单 */
async playlistSubscribe(id: number, subscribe: boolean): Promise<void> {
return invoke('playlist_subscribe', { query: { id, subscribe } });
},
/** 每日推荐歌单 */
async recommendResource(): Promise<string> {
return invoke('recommend_resource');
},
/** 每日推荐歌曲 */
async recommendSongs(): Promise<string> {
return invoke('recommend_songs');
},
};
+31
View File
@@ -0,0 +1,31 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const RecApi = {
/** 个性化推荐歌单(无需登录) */
async personalized(limit: number = 30): Promise<string> {
return invoke('personalized', { limit });
},
/** 推荐新歌 */
async personalizedNewsong(limit: number = 10): Promise<string> {
return invoke('personalized_newsong', { limit });
},
/** 热门歌手 */
async topArtists(limit: number = 30, offset: number = 0): Promise<string> {
return invoke('top_artists', { limit, offset });
},
/** 新歌速递,type: 全部:0 / 华语:7 / 欧美:96 / 韩国:16 / 日本:8 */
async topSong(areaType: number = 0): Promise<string> {
return invoke('top_song', { areaType });
},
/** 最新专辑(新碟上架) */
async albumNewest(): Promise<string> {
return invoke('album_newest');
},
};
+21
View File
@@ -0,0 +1,21 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const SearchApi = {
/** 搜索建议 */
async searchSuggest(keyword: string): Promise<string> {
return invoke('search_suggest', { query: { keyword } });
},
/** 热门搜索 */
async getHotSearch(): Promise<string> {
return invoke('get_hot_search');
},
/** 云搜索 */
async cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise<string> {
return invoke('cloudsearch', { query });
},
};
+36
View File
@@ -0,0 +1,36 @@
import { invoke } from '@tauri-apps/api/core';
/**
* API
*/
export const SongApi = {
/** 获取歌曲详情 */
async getSongDetail(id: string): Promise<string> {
return invoke('get_song_detail', { id });
},
/** 获取歌曲播放 URL */
async getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise<string> {
return invoke('get_song_url', { query });
},
/** 获取歌词 */
async getLyric(id: number): Promise<string> {
return invoke('get_lyric', { id });
},
/** 获取喜欢列表 */
async likelist(uid: number): Promise<string> {
return invoke('likelist', { uid });
},
/** 喜欢/取消喜欢歌曲 */
async likeSong(id: number, like: boolean): Promise<void> {
return invoke('like_song', { query: { id, like: like ? 'true' : 'false' } });
},
/** 上报听歌记录(scrobble */
async scrobble(query: { id: number; sourceid: string; time: number; alg?: string; source?: string; bitrate?: number }): Promise<void> {
return invoke('scrobble', { query });
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+82
View File
@@ -0,0 +1,82 @@
<template>
<div
class="group relative rounded-xl overflow-hidden bg-subtle hover:bg-muted transition cursor-pointer"
@click="$emit('click', album)"
>
<!-- 封面 -->
<div class="relative aspect-square overflow-hidden">
<img
v-if="coverUrl"
:src="coverUrl"
:alt="album.name"
class="w-full h-full object-cover transition duration-300 group-hover:scale-105 group-hover:brightness-90"
loading="lazy"
/>
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
<IconDisc class="w-8 h-8 text-content-4" />
</div>
<!-- 悬浮播放按钮 -->
<button
v-if="showPlayBtn"
@click.stop="$emit('play', album)"
class="absolute bottom-2 right-2 w-9 h-9 flex items-center justify-center rounded-full bg-white/20 backdrop-blur-md text-white opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 hover:bg-white/30 transition"
:title="isCurrentPlaying ? '暂停' : '播放'"
>
<IconPause v-if="isCurrentPlaying" class="w-4 h-4 fill-current" />
<IconPlay v-else class="w-4 h-4 fill-current ml-0.5" />
</button>
</div>
<!-- 信息 -->
<div class="p-3">
<p class="text-sm font-medium truncate text-content">{{ album.name }}</p>
<p v-if="subtitle" class="text-xs text-content-2 mt-1 truncate">{{ subtitle }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import IconDisc from '~icons/lucide/disc-3';
import IconPlay from '~icons/lucide/play';
import IconPause from '~icons/lucide/pause';
import { formatDate } from '../../utils/format';
export interface AlbumCardData {
id: number;
name: string;
picUrl?: string;
blurPicUrl?: string;
artist?: { name?: string } | string;
artists?: { name?: string }[];
publishTime?: number;
size?: number;
}
const props = withDefaults(defineProps<{
album: AlbumCardData;
showPlayBtn?: boolean;
isCurrentPlaying?: boolean;
}>(), {
showPlayBtn: true,
isCurrentPlaying: false,
});
defineEmits<{
(e: 'click', album: AlbumCardData): void;
(e: 'play', album: AlbumCardData): void;
}>();
const coverUrl = computed(() => props.album.picUrl || props.album.blurPicUrl || '');
const subtitle = computed(() => {
const a = props.album;
const artistName = typeof a.artist === 'string'
? a.artist
: a.artist?.name || a.artists?.map(ar => ar.name).filter(Boolean).join(' / ') || '';
const date = a.publishTime ? formatDate(a.publishTime) : '';
const count = a.size ? `${a.size}` : '';
const parts = [artistName, date, count].filter(Boolean);
return parts.join(' · ');
});
</script>
+60
View File
@@ -0,0 +1,60 @@
<template>
<div
class="group flex flex-col items-center cursor-pointer transition"
@click="$emit('click', artist)"
>
<!-- 圆形封面 -->
<div class="relative w-full aspect-square rounded-full overflow-hidden mb-3">
<img
v-if="coverUrl"
:src="coverUrl"
:alt="artist.name"
class="w-full h-full object-cover transition duration-300 group-hover:scale-110 group-hover:brightness-90"
loading="lazy"
/>
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
<IconUser class="w-8 h-8 text-content-4" />
</div>
<!-- 悬浮遮罩 + 图标 -->
<div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<IconArtist class="w-7 h-7 text-white" />
</div>
</div>
<!-- 信息 -->
<p class="text-sm font-medium truncate text-content text-center w-full">{{ artist.name }}</p>
<p v-if="subtitle" class="text-xs text-content-3 mt-0.5 text-center truncate w-full">{{ subtitle }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import IconUser from '~icons/lucide/user';
import IconArtist from '~icons/lucide/mic';
export interface ArtistCardData {
id: number;
name: string;
picUrl?: string;
img1v1Url?: string;
musicSize?: number;
albumSize?: number;
}
const props = defineProps<{
artist: ArtistCardData;
}>();
defineEmits<{
(e: 'click', artist: ArtistCardData): void;
}>();
const coverUrl = computed(() => props.artist.picUrl || props.artist.img1v1Url || '');
const subtitle = computed(() => {
const a = props.artist;
const parts: string[] = [];
if (a.musicSize) parts.push(`${a.musicSize}`);
if (a.albumSize) parts.push(`${a.albumSize}张专辑`);
return parts.join(' · ');
});
</script>
+59
View File
@@ -0,0 +1,59 @@
<template>
<div class="grid gap-4" :class="gridClass">
<template v-if="loading && !items.length">
<div
v-for="i in skeletonCount"
:key="'skel-' + i"
class="rounded-xl overflow-hidden bg-subtle animate-pulse"
>
<div class="w-full aspect-square bg-muted"></div>
<div class="p-3 space-y-2">
<div class="h-4 bg-muted rounded w-3/4"></div>
<div class="h-3 bg-muted rounded w-1/2"></div>
</div>
</div>
</template>
<template v-else-if="error && !items.length">
<div class="col-span-full flex flex-col items-center justify-center py-12 gap-3">
<p class="text-content-2 text-sm">{{ errorText }}</p>
<button
@click="$emit('retry')"
class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
重试
</button>
</div>
</template>
<template v-else>
<slot />
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
items: unknown[];
loading?: boolean;
error?: boolean;
errorText?: string;
skeletonCount?: number;
/** 自定义栅格列数类名,默认响应式 */
gridClass?: string;
}>(), {
loading: false,
error: false,
errorText: '加载失败',
skeletonCount: 6,
gridClass: '',
});
const gridClass = computed(() => props.gridClass || 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6');
defineEmits<{
(e: 'retry'): void;
}>();
</script>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div
class="group relative rounded-xl overflow-hidden bg-subtle hover:bg-muted transition cursor-pointer"
@click="$emit('click', playlist)"
>
<!-- 封面 -->
<div class="relative aspect-square overflow-hidden">
<img
v-if="coverUrl"
:src="coverUrl"
:alt="playlist.name"
class="w-full h-full object-cover transition duration-300 group-hover:scale-105 group-hover:brightness-90"
loading="lazy"
/>
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
<IconMusic class="w-8 h-8 text-content-4" />
</div>
<!-- 顶部渐变 + 播放量 -->
<div
v-if="playCount"
class="absolute top-0 left-0 right-0 h-1/3 bg-gradient-to-b from-black/40 to-transparent flex items-start justify-end p-2"
>
<div class="flex items-center gap-1 text-white text-xs">
<IconPlay class="w-3 h-3 fill-current" />
<span>{{ formattedPlayCount }}</span>
</div>
</div>
<!-- 悬浮播放按钮 -->
<button
v-if="showPlayBtn"
@click.stop="$emit('play', playlist)"
class="absolute bottom-2 right-2 w-9 h-9 flex items-center justify-center rounded-full bg-white/20 backdrop-blur-md text-white opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 hover:bg-white/30 transition"
:title="isCurrentPlaying ? '暂停' : '播放'"
>
<IconPause v-if="isCurrentPlaying" class="w-4 h-4 fill-current" />
<IconPlay v-else class="w-4 h-4 fill-current ml-0.5" />
</button>
</div>
<!-- 信息 -->
<div class="p-3">
<p class="text-sm font-medium truncate text-content">{{ playlist.name }}</p>
<p v-if="subtitle" class="text-xs text-content-2 mt-1 truncate">{{ subtitle }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import IconMusic from '~icons/lucide/music';
import IconPlay from '~icons/lucide/play';
import IconPause from '~icons/lucide/pause';
import { formatPlayCount } from '../../utils/format';
export interface PlaylistCardData {
id: number;
name: string;
picUrl?: string;
coverImgUrl?: string;
playCount?: number;
copywriter?: string;
description?: string;
creator?: { name?: string } | string;
}
const props = withDefaults(defineProps<{
playlist: PlaylistCardData;
showPlayBtn?: boolean;
coverSize?: string;
isCurrentPlaying?: boolean;
}>(), {
showPlayBtn: true,
isCurrentPlaying: false,
});
defineEmits<{
(e: 'click', playlist: PlaylistCardData): void;
(e: 'play', playlist: PlaylistCardData): void;
}>();
const coverUrl = computed(() => props.playlist.picUrl || props.playlist.coverImgUrl || '');
const playCount = computed(() => props.playlist.playCount || 0);
const formattedPlayCount = computed(() => formatPlayCount(playCount.value));
const subtitle = computed(() => {
const c = props.playlist;
if (c.copywriter) return c.copywriter;
if (c.description) return c.description;
const creator = c.creator;
if (typeof creator === 'string') return creator;
if (creator?.name) return `by ${creator.name}`;
return '';
});
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="flex items-center justify-between mb-4 mt-2">
<div class="flex items-center gap-2 min-w-0">
<component :is="icon" v-if="icon" class="w-5 h-5 text-accent-text flex-shrink-0" />
<h2 class="text-xl font-semibold text-content truncate">{{ title }}</h2>
<span v-if="subtitle" class="text-xs text-content-3 truncate hidden sm:inline">· {{ subtitle }}</span>
</div>
<button
v-if="showMore"
@click="$emit('more')"
class="flex items-center gap-1 text-sm text-content-2 hover:text-content transition flex-shrink-0"
>
<span>更多</span>
<IconChevronRight class="w-4 h-4" />
</button>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue';
import IconChevronRight from '~icons/lucide/chevron-right';
defineProps<{
title: string;
subtitle?: string;
icon?: Component;
showMore?: boolean;
}>();
defineEmits<{
(e: 'more'): void;
}>();
</script>
+69
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>
+157
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>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="relative" ref="container">
<button
@click="toggle"
class="flex items-center justify-between bg-subtle border border-line rounded-lg px-3 py-1.5 text-sm text-content outline-none transition min-w-[140px] max-w-[320px] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]"
:class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
>
<span class="truncate">{{ currentLabel }}</span>
<IconChevronDown style="font-size: 12px" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }" />
</button>
<Transition name="dropdown">
<div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full max-w-[360px] overflow-hidden">
<button
v-for="(label, key) in options"
:key="key"
@click="select(key)"
class="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between gap-2"
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
>
<span class="truncate">{{ label }}</span>
<IconCheck v-if="modelValue === key" style="font-size: 14px" />
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import IconChevronDown from '~icons/lucide/chevron-down';
import IconCheck from '~icons/lucide/check';
const props = defineProps<{
modelValue: string;
options: Record<string, string>;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const isOpen = ref(false);
const container = ref<HTMLElement | null>(null);
const currentLabel = computed(() => props.options[props.modelValue] || '');
function toggle() {
isOpen.value = !isOpen.value;
}
function select(key: string) {
emit('update:modelValue', key);
isOpen.value = false;
}
function onClickOutside(e: MouseEvent) {
if (container.value && !container.value.contains(e.target as Node)) {
isOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside);
});
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<template>
<!-- 详情页统一布局参考 SPlayer list-detail 架构
absolute; inset:0 脱离 mainScrollRef 滚动流钉死覆盖视口
内部 flex 列布局header flex-shrink:0 常驻顶部content flex-1 min-h:0 独立滚动
父级 mainScrollRef 已加 position:relative 作定位上下文
滚动 content 超过阈值时切换 is-compact参考 SPlayer useListScroll通过插槽 prop 透出 -->
<div class="detail-layout" :class="{ 'is-compact': isCompact }">
<header class="detail-header">
<slot name="header" :compact="isCompact" />
</header>
<div ref="contentRef" class="detail-content" data-scroll @scroll="onScroll">
<slot name="content" :compact="isCompact" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, nextTick } from 'vue';
defineOptions({ name: 'DetailLayout' });
const contentRef = ref<HTMLElement | null>(null);
// SPlayer useListScrollscrollTop > 10
const isCompact = ref(false);
const COMPACT_THRESHOLD = 10;
//
const MIN_SCROLL_RANGE = 150;
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
const el = contentRef.value;
if (!el) return;
//
if (el.scrollHeight - el.clientHeight < MIN_SCROLL_RANGE) {
if (isCompact.value) isCompact.value = false;
return;
}
isCompact.value = el.scrollTop > COMPACT_THRESHOLD;
});
}
// & /
function resetScroll() {
isCompact.value = false;
nextTick(() => {
if (contentRef.value) contentRef.value.scrollTop = 0;
});
}
defineExpose({ contentRef, isCompact, resetScroll });
onBeforeUnmount(() => {
isCompact.value = false;
});
</script>
<style scoped>
/* absolute mainScrollRef + flex
absolute 元素不撑高父级 mainScrollRef 不滚动 header 不被带走 */
.detail-layout {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 头部:flex-shrink:0 常驻顶部,不随内容滚动 */
.detail-header {
flex-shrink: 0;
/* 头部内部所有过渡元素统一动画曲线(参考 SPlayer transition: height/font-size 0.3s */
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 内容区:flex-1 min-h:0 占满剩余空间,独立滚动 */
.detail-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<!-- 普通头部随内容滚动标题和按钮一行 -->
<div class="-mx-8 px-8 pt-3 pb-2">
<div class="flex items-center gap-4">
<slot />
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
</script>
+342 -150
View File
@@ -1,167 +1,281 @@
<template>
<div v-if="player.currentSong"
class="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-white/10 z-50 select-none">
<!-- 歌词精简条仅在非漫游全屏时显示 -->
<div v-if="currentLyricText && !player.showRoamDrawer" @click="showFullLyric = !showFullLyric"
class="px-6 py-1 text-center text-xs text-green-200/80 cursor-pointer hover:bg-white/5 transition truncate">
{{ currentLyricText }}
<div
class="fixed bottom-0 left-0 right-0 z-50 select-none backdrop-blur-xl"
:style="playerBarBgStyle"
>
<div v-if="ui.dominantColor"
class="absolute inset-0 pointer-events-none transition-opacity duration-300"
:class="drawerActive ? 'opacity-100' : 'opacity-0'"
:style="{ backgroundColor: ui.dominantColor }"
>
<div class="absolute inset-0 bg-black/60"></div>
</div>
<!-- 进度条 -->
<div ref="progressBar" class="w-full h-1.5 bg-white/10 rounded-full relative group cursor-pointer overflow-visible"
<div ref="progressBar" class="w-full h-1.5 relative group cursor-pointer overflow-visible"
:class="drawerActive ? 'bg-white/10' : 'bg-muted'"
@mousedown.prevent="startSeek">
<!-- 缓存进度灰白 -->
<div class="absolute left-0 top-0 h-full bg-white/20 rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<!-- 播放进度绿色渐变 -->
<div class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-emerald-500 rounded-full"
<div class="absolute left-0 top-0 h-full rounded-full" :class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" :style="{ width: cacheProgress + '%' }"></div>
<div class="absolute left-0 top-0 h-full rounded-full bg-accent"
:style="{ width: displayProgress + '%' }"></div>
<!-- 拖动圆点基于容器定位left 百分比 -->
<div
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg border border-line opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
</div>
<!-- 主控制区 -->
<div class="flex items-center px-6 h-16">
<!-- 左侧歌曲信息 -->
<div class="flex items-center px-6 h-16 relative z-10">
<div class="flex items-center gap-3 w-56 min-w-0">
<img :src="player.currentSong.al?.picUrl"
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
@click="player.openRoamDrawer()" title="全屏展示" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ player.currentSong.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{player.currentSong.ar?.map((a: any) => a.name).join('/')}}
<div v-if="getCoverUrl(player.currentSong)" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="ui.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="ui.toggleRoamDrawer()" title="全屏展示"
:class="drawerActive ? 'bg-white/10' : 'bg-muted'">
<IconMusic class="w-[18px] h-[18px]" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate" :class="drawerActive ? 'text-white' : ''">{{ player.currentSong?.name }}</p>
<p class="text-xs truncate" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
<template v-for="(a, i) in player.currentSong?.ar || []" :key="a.id || i">
<span v-if="i > 0" :class="drawerActive ? 'text-white/40' : 'text-content-3'">/</span>
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
</template>
<template v-if="player.currentSong?.al?.name">
<span :class="drawerActive ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="player.currentSong!.al.id && router.push({ name: 'album', params: { id: player.currentSong!.al.id } })">{{ player.currentSong.al.name }}</span>
</template>
</p>
</div>
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : (drawerActive ? 'text-white/50 hover:text-danger' : 'text-content-3 hover:text-danger')">
<IconHeart v-if="player.currentSong && player.isLiked(player.currentSong.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
<IconHeart v-else class="w-4 h-4" />
</button>
<button v-if="player.currentSong" @click="shareSong(player.currentSong.id)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="分享">
<IconShare2 class="w-4 h-4" />
</button>
<button v-if="player.currentSong && !download.isDownloaded(player.currentSong!.id) && !download.isDownloading(player.currentSong!.id)" @click="download.downloadSong(player.currentSong)" class="flex-shrink-0 transition" :class="drawerActive ? 'text-white/50 hover:text-white' : 'text-content-3 hover:text-accent-text'" title="下载">
<IconDownload class="w-4 h-4" />
</button>
<IconLoader2 v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" class="w-4 h-4 flex-shrink-0 animate-spin" :class="drawerActive ? 'text-white/50' : 'text-content-3'" />
</div>
<!-- 中间控制按钮 + 时间 -->
<div class="flex-1 flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-5">
<div class="flex items-center gap-5 relative">
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
'text-xl transition',
player.isFmMode ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 hover:text-white',
'transition',
player.isFmMode ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
]">
<IconSkipBack class="w-5 h-5" />
</button>
<button @click="player.toggle()"
class="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20">
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
class="w-9 h-9 flex items-center justify-center rounded-full transition"
:class="drawerActive ? 'bg-white/15 hover:bg-white/25 border border-white/20' : 'bg-muted hover:bg-emphasis border border-emphasis'">
<IconPause v-if="player.playing" class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
<IconPlay v-else class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
</button>
<button @click="player.next()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
<IconSkipForward class="w-5 h-5" />
</button>
<button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" class="absolute left-full ml-5" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐">
<IconHeartOff class="w-[18px] h-[18px]" />
</button>
<button @click="player.next()" class="text-xl text-gray-400 hover:text-white transition"></button>
</div>
<div class="flex items-center gap-2 text-xs text-gray-400">
<div class="flex items-center gap-2 text-xs" :class="drawerActive ? 'text-white/70' : 'text-content-2'">
<span>{{ formatTime(player.currentTime) }}</span>
<span>/</span>
<span>{{ formatTime(player.duration) }}</span>
</div>
</div>
<!-- 右侧音量模式播放列表 -->
<div class="w-56 flex justify-end items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-sm text-gray-400">🔊</span>
<div class="relative w-24 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
<button @click="toggleMute" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
<IconVolumeX v-if="player.volume === 0" class="w-[18px] h-[18px]" />
<IconVolume2 v-else class="w-[18px] h-[18px]" />
</button>
<div class="relative w-20 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="player.volume"
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-white/20 outline-none" />
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer outline-none"
:class="drawerActive ? 'bg-white/20' : 'bg-emphasis'" />
</div>
</div>
<button @click="togglePlayMode" class="text-gray-400 hover:text-white transition text-lg" :title="modeTitle">
{{ modeIcon }}
<button @click="togglePlayMode" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" :title="modeTitle">
<IconRepeat v-if="player.playMode === 'loop'" class="w-[18px] h-[18px]" />
<IconShuffle v-else-if="player.playMode === 'shuffle'" class="w-[18px] h-[18px]" />
<IconRepeat1 v-else class="w-[18px] h-[18px]" />
</button>
<button @click="showQueuePanel = !showQueuePanel"
class="text-gray-400 hover:text-white transition text-xl relative" title="播放列表">
📋
<span v-if="player.queue.length > 0"
class="absolute -top-1 -right-1 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{{ player.queue.length }}
</span>
:class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'" title="播放列表">
<IconListMusic class="w-[18px] h-[18px]" />
</button>
</div>
</div>
<!-- 队列面板 -->
<Transition name="slide-up">
<div v-if="showQueuePanel"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
<button @click="player.clearQueue()" class="text-xs text-red-400 hover:text-red-300 transition">清空</button>
</div>
<div class="space-y-1">
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
idx === player.currentIndex ? 'bg-green-500/20 text-white' : 'hover:bg-white/5 text-gray-300',
]">
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-500 truncate">
{{song.ar?.map((a: any) => a.name).join('/')}}
</p>
<Teleport to="body">
<Transition name="queue-fade">
<div v-if="showDislikeModal && player.currentSong" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDislikeModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-5 select-auto">
<h2 class="text-base font-semibold text-content mb-1">减少推荐</h2>
<p class="text-xs text-content-3 mb-4">选择要减少的推荐类型</p>
<div class="flex flex-col gap-2 mb-4">
<button @click="dislikeSong"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
<IconMusic class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
<div>
<p class="text-sm font-medium">不推荐这首歌曲</p>
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ player.currentSong.name }}</p>
</div>
</button>
<button v-if="dislikeArtistName" @click="dislikeArtist"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-muted hover:bg-emphasis transition text-left">
<IconUserRound class="w-[18px] h-[18px] text-content-2 flex-shrink-0" />
<div>
<p class="text-sm font-medium">减少含此歌手的推荐</p>
<p class="text-xs text-content-3 truncate max-w-[200px]">{{ dislikeArtistName }}</p>
</div>
</button>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="text-gray-500 hover:text-red-400 transition text-sm">
<button @click="showDislikeModal = false"
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
</div>
</div>
</div>
</Transition>
</Transition>
<Transition name="queue-fade">
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
</Transition>
<Transition name="queue-slide">
<div v-if="showQueuePanel"
class="fixed right-0 top-0 bottom-0 z-[56] w-[340px] bg-base/95 backdrop-blur border-l border-line flex flex-col shadow-2xl shadow-black/40">
<!-- 全屏歌词浮层 -->
<Transition name="slide-up">
<div v-if="showFullLyric && lyrics.length > 0 && !player.showRoamDrawer"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-72 overflow-hidden flex flex-col"
@click.self="showFullLyric = false">
<div class="flex justify-between mb-2">
<h3 class="text-xs font-semibold">歌词</h3>
<button @click="showFullLyric = false" class="text-gray-400 hover:text-white">收起</button>
<div class="px-5 pt-5 pb-3">
<div class="flex items-center justify-between">
<div>
<h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
<p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
</div>
<div class="flex items-center gap-1">
<button @click="player.clearQueue()"
class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
清空
</button>
<button @click="showQueuePanel = false"
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
<IconX class="w-4 h-4" />
</button>
</div>
</div>
</div>
<div class="h-px mx-5 bg-line"></div>
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
<IconMusic class="w-10 h-10 opacity-40" />
<p class="text-sm">播放列表为空</p>
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
</div>
<SongListItem
v-for="(song, idx) in player.queue" :key="song.id + '-' + idx"
:id="'queue-item-' + idx"
:song="song"
:index="idx"
:is-current="idx === player.currentIndex"
show-playing-overlay
cover-size="w-9 h-9"
cover-size-param="?param=80y80"
:container-class="idx === player.currentIndex ? 'bg-muted hover:bg-muted gap-3 px-3 py-2 rounded-lg' : 'hover:bg-subtle gap-3 px-3 py-2 rounded-lg'"
@click="playFromQueue(idx)"
>
<template #actions>
<button @click.stop="player.removeFromQueue(idx)"
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
<IconX class="w-3 h-3" />
</button>
</template>
</SongListItem>
<div class="h-2"></div>
<button v-if="player.currentIndex >= 0 && player.queue.length > 0" v-show="!currentSongVisible"
@click="scrollToCurrent"
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
title="定位到正在播放">
<IconCrosshair class="w-[18px] h-[18px]" />
</button>
</div>
</div>
<div ref="lyricContainer"
class="flex-1 overflow-y-auto overflow-x-hidden whitespace-normal break-words space-y-1 text-sm text-center">
<p v-for="(line, idx) in lyrics" :key="idx" :class="idx === currentLyricIdx
? 'text-green-400 font-medium scale-105 transition'
: 'text-gray-400'
" class="px-4 py-0.5">
{{ line.text }}
</p>
</div>
</div>
</Transition>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch, onMounted } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
import { useLyric } from '../composables/UserLyric';
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
import { usePlayerStore } from '../stores/player';
import type { PlayMode } from '../types/song';
import { useUiStore } from '../stores/ui';
import { useSettingsStore } from '../stores/settings';
import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format';
import { getCoverUrl } from '../utils/song';
import { AudioApi } from '../api';
import { showToast } from '../composables/useToast';
import { listen } from '@tauri-apps/api/event';
import { useRouter } from 'vue-router';
import SongListItem from './SongListItem.vue';
import IconSkipBack from '~icons/lucide/skip-back';
import IconPlay from '~icons/lucide/play';
import IconPause from '~icons/lucide/pause';
import IconSkipForward from '~icons/lucide/skip-forward';
import IconHeartOff from '~icons/lucide/heart-off';
import IconVolumeX from '~icons/lucide/volume-x';
import IconVolume2 from '~icons/lucide/volume-2';
import IconRepeat from '~icons/lucide/repeat';
import IconShuffle from '~icons/lucide/shuffle';
import IconRepeat1 from '~icons/lucide/repeat-1';
import IconListMusic from '~icons/lucide/list-music';
import 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';
import IconShare2 from '~icons/lucide/share-2';
import { hexToRgba } from '../utils/color';
const router = useRouter();
const player = usePlayerStore();
const ui = useUiStore();
const settings = useSettingsStore();
const download = useDownload();
const drawerActive = computed(() => ui.showRoamDrawer && !!ui.dominantColor);
// PlayerBar --c-bg
// surface
const playerBarBgStyle = computed(() => {
if (settings.currentWallpaper.path) {
const bgColor = settings.currentColors.bg;
const rgba = hexToRgba(bgColor, 0.82);
return { backgroundColor: rgba };
}
return { backgroundColor: settings.currentColors.surface };
});
const showQueuePanel = ref(false);
const { lyrics, currentLyricIdx, currentLyricText } = useLyric();
const showFullLyric = ref(false);
const lyricContainer = ref<HTMLElement | null>(null);
const showDislikeModal = ref(false);
const queueListEl = ref<HTMLElement | null>(null);
const currentSongVisible = ref(true);
const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false);
const previewTime = ref(0);
const cacheProgress = ref(0);
const volume = ref(100);
const prevVolume = ref(100);
let unlistenCache: (() => void) | null = null;
//
onMounted(async () => {
const fn = await listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
@@ -172,10 +286,17 @@ onBeforeUnmount(() => {
if (unlistenCache) unlistenCache();
});
//
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeIcons = { loop: '🔁', shuffle: '🔀', 'repeat-one': '🔂' };
const modeIcon = computed(() => modeIcons[player.playMode] || '🔁');
watch(() => player.currentSong, (song) => {
if (!song) {
cacheProgress.value = 0;
} else if (song.localPath) {
cacheProgress.value = 100;
} else {
cacheProgress.value = 0;
}
});
const modeTexts: Record<PlayMode, string> = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
function togglePlayMode() {
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
@@ -183,7 +304,16 @@ function togglePlayMode() {
player.setPlayMode(next);
}
//
function toggleMute() {
if (player.volume > 0) {
prevVolume.value = player.volume;
player.volume = 0;
} else {
player.volume = prevVolume.value || 100;
}
AudioApi.setVolume(player.volume / 100);
}
let onDocMove: ((e: MouseEvent) => void) | null = null;
let onDocUp: (() => void) | null = null;
@@ -234,57 +364,100 @@ const displayProgress = computed(() => {
return isSeeking.value ? previewPercent.value : progressPercent.value;
});
function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function playFromQueue(index: number) {
player.currentIndex = index;
player.playCurrent();
}
const dislikeArtistName = computed(() => {
const song = player.currentSong;
if (!song?.ar?.length) return '';
return song.ar.map(a => a.name).join(' / ');
});
async function dislikeSong() {
if (!player.currentSong) return;
showDislikeModal.value = false;
await player.fmTrash(player.currentSong.id);
showToast('已减少该歌曲推荐', 'success');
}
async function dislikeArtist() {
if (!player.currentSong) return;
showDislikeModal.value = false;
await player.fmTrash(player.currentSong.id);
showToast('已减少含该歌手的推荐', 'success');
}
async function shareSong(id: number) {
const url = `https://music.163.com/song?id=${id}`;
try {
await navigator.clipboard.writeText(url);
showToast('链接已复制', 'success');
} catch {
showToast('复制失败', 'error');
}
}
function scrollToCurrent() {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
let currentSongObserver: IntersectionObserver | null = null;
function setupCurrentSongObserver() {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
nextTick(() => {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (!el || !queueListEl.value) return;
currentSongObserver = new IntersectionObserver(
([entry]) => { currentSongVisible.value = entry.isIntersecting; },
{ root: queueListEl.value, threshold: 0.5 }
);
currentSongObserver.observe(el);
});
}
watch(() => [showQueuePanel.value, player.currentIndex, player.queue.length], () => {
if (showQueuePanel.value) {
setupCurrentSongObserver();
} else {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
currentSongVisible.value = true;
}
});
onBeforeUnmount(() => {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
});
async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10);
volume.value = val;
await invoke('set_volume', { vol: val / 100 });
player.volume = val;
await AudioApi.setVolume(val / 100);
}
const volumeBarBg = computed(() => {
const pct = volume.value;
return `linear-gradient(to right, #34d399 0%, #10b981 ${pct}%, rgba(255,255,255,0.15) ${pct}%)`;
const pct = player.volume;
const track = drawerActive.value ? 'rgba(255,255,255,0.2)' : 'var(--c-muted)';
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, ${track} ${pct}%)`;
});
//
watch(
() => currentLyricIdx.value,
() => {
if (showFullLyric.value && lyricContainer.value) {
nextTick(() => {
const active = lyricContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
);
</script>
<style scoped>
/* 样式保持不变(原有歌词浮层过渡、滑块样式等) */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(10px);
}
.vol-slider {
-webkit-appearance: none;
appearance: none;
@@ -300,9 +473,9 @@ watch(
height: 6px;
border-radius: 3px;
background: linear-gradient(to right,
#34d399 0%,
#10b981 var(--vol-fill),
rgba(255, 255, 255, 0.15) var(--vol-fill));
var(--c-accent) 0%,
var(--c-accent) var(--vol-fill),
var(--c-muted) var(--vol-fill));
}
.vol-slider::-webkit-slider-thumb {
@@ -321,4 +494,23 @@ watch(
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
</style>
.queue-fade-enter-active,
.queue-fade-leave-active {
transition: opacity 0.2s ease;
}
.queue-fade-enter-from,
.queue-fade-leave-to {
opacity: 0;
}
.queue-slide-enter-active,
.queue-slide-leave-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.queue-slide-enter-from,
.queue-slide-leave-to {
transform: translateX(100%);
}
</style>
+275
View File
@@ -0,0 +1,275 @@
<template>
<Transition name="drawer">
<div v-if="visible" class="fixed inset-0 z-50 flex flex-col">
<!-- 背景层fade in 覆盖全屏 -->
<div
class="absolute inset-0 backdrop-blur-xl"
:class="!ui.dominantColor && 'bg-surface/95'"
:style="ui.dominantColor ? { backgroundColor: ui.dominantColor } : {}"
></div>
<div v-if="ui.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
<!-- 内容层slide up/down -->
<div class="relative z-10 flex flex-col flex-1 min-h-0 drawer-content">
<TitleBar :dark-mode="!!ui.dominantColor" transparent @close="ui.closeRoamDrawer()">
<template #left>
<button @click="ui.closeRoamDrawer()" :class="dc ? '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">
<!-- 左侧封面 + 歌曲信息 -->
<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="dc ? 'bg-white/10' : 'bg-muted'"
>
<IconMusic class="w-16 h-16" :class="dc ? 'text-white/30' : 'text-content-4'" />
</div>
<h1 class="text-2xl font-bold text-center" :class="dc ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
<p class="mt-2 text-center" :class="dc ? 'text-white/70' : 'text-content-2'">
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
<span v-if="i > 0" :class="dc ? '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="dc ? '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="ui.roamTab = 'lyric'"
class="px-3 py-1 rounded-full text-sm transition"
:class="tabClass(ui.roamTab === 'lyric')">
歌词
</button>
<button @click="ui.roamTab = 'comment'"
class="px-3 py-1 rounded-full text-sm transition"
:class="tabClass(ui.roamTab === 'comment')">
评论
</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="tabClass(showTranslation)">
<IconLanguages class="w-3 h-3" />
</button>
</div>
<div v-show="ui.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 transition-[font-size,opacity] 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="dc ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
</div>
<div v-show="ui.roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
<CommentSection v-if="roamSong" :type="0" :id="ui.commentSongId || roamSong.id" :key="ui.commentSongId || roamSong.id" :dark-mode="!!ui.dominantColor" />
</div>
</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 { useUiStore } from '../stores/ui';
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 ui = useUiStore();
// dominantColor 使
const dc = computed(() => !!ui.dominantColor);
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamLyricHovering = ref(false);
const roamLyricPadPx = ref(0);
const roamSong = computed(() => player.currentSong);
const roamCoverError = ref(false);
const roamCoverUrl = computed(() => {
if (!roamSong.value) return '';
return getCoverUrl(roamSong.value) || '';
});
watch(roamCoverUrl, async (url) => {
roamCoverError.value = false;
if (url) {
const color = await extractDominantColor(url);
ui.dominantColor = color;
} else {
ui.dominantColor = '';
}
});
let roamResizeObserver: ResizeObserver | null = null;
function updateRoamLyricPad() {
if (lyricScrollContainer.value) {
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
}
}
watch(() => ui.showRoamDrawer, (val) => {
if (val) {
nextTick(() => {
updateRoamLyricPad();
if (roamResizeObserver) roamResizeObserver.disconnect();
if (lyricScrollContainer.value) {
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
roamResizeObserver.observe(lyricScrollContainer.value);
}
scrollToRoamActiveLyric();
});
} else {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
}
});
onBeforeUnmount(() => {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
});
watch(currentLyricIdx, () => {
if (ui.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
watch(showTranslation, () => {
if (ui.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);
}
// Tab
function tabClass(active: boolean): string {
if (dc.value) {
return active ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80';
}
return active ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content';
}
function getTranslationClass(idx: number): string {
const diff = Math.abs(idx - currentLyricIdx.value);
if (idx === currentLyricIdx.value) return dc.value ? 'text-[var(--c-accent)]' : 'text-accent-text';
if (diff === 1) return dc.value ? 'text-white/70' : 'text-content/70';
if (diff === 2) return dc.value ? 'text-white/50' : 'text-content-2/50';
return dc.value ? 'text-white/35' : 'text-content-3/35';
}
function getRoamLyricClass(idx: number): string {
const diff = Math.abs(idx - currentLyricIdx.value);
if (idx === currentLyricIdx.value) {
return dc.value
? '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 dc.value ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
if (diff === 2) return dc.value ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
return dc.value ? '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 }) {
ui.closeRoamDrawer();
router.push(routeLocation);
}
</script>
<style scoped>
/* 外层容器:fade in/out */
.drawer-enter-active,
.drawer-leave-active { transition: opacity 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { opacity: 0; }
/* 内容层:slide up/down */
.drawer-enter-active .drawer-content,
.drawer-leave-active .drawer-content { transition: transform 0.3s ease; }
.drawer-enter-from .drawer-content,
.drawer-leave-to .drawer-content { transform: translateY(100%); }
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
/*
锁定行高让字号在 active/diff 切换时盒模型高度不变避免整列抖动乱跳
行高取 active 字号(text-xl=1.25rem20px)对应舒适值 1.5×30px
其余小字号在同一行高内垂直居中显示 */
.roam-lyric-line {
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
}
</style>
+183
View File
@@ -0,0 +1,183 @@
<template>
<!-- 抽屉模式遮罩 + 滑出面板 -->
<template v-if="mode === 'drawer'">
<Transition name="fade">
<div v-if="ui.drawerOpen" class="fixed inset-0 bg-black/50 z-40" @click="ui.closeDrawer()"></div>
</Transition>
<Transition name="slide">
<nav v-if="ui.drawerOpen" class="fixed left-0 top-0 bottom-0 w-64 z-50 flex flex-col shadow-2xl backdrop-blur-md" :style="sidebarBgStyle">
<!-- Logo 区域 -->
<div class="h-12 flex items-center flex-shrink-0 px-4 gap-2" data-tauri-drag-region>
<IconMusic class="w-7 h-7 text-accent flex-shrink-0" />
<span class="text-base font-bold text-content truncate">Nekosonic</span>
</div>
<SidebarContent :collapsed="false" @navigate="ui.closeDrawer()" />
</nav>
</Transition>
</template>
<!-- 展开/折叠模式常驻侧栏透明背景靠右边框分隔 -->
<nav
v-else
class="relative flex-shrink-0 flex flex-col transition-all duration-300 border-r border-line-2/40"
:class="mode === 'collapsed' ? 'w-16' : 'w-56'"
>
<!-- Logo 区域高度与 TopBar 一致用于对齐 -->
<div
class="h-12 flex items-center flex-shrink-0"
:class="mode === 'collapsed' ? 'justify-center' : 'px-4 gap-2'"
data-tauri-drag-region
>
<IconMusic class="w-7 h-7 text-accent flex-shrink-0 cursor-pointer" @click="router.push('/')" />
<span v-if="mode === 'expanded'" class="text-base font-bold text-content truncate cursor-pointer" @click="router.push('/')">Nekosonic</span>
</div>
<!-- 导航内容 -->
<SidebarContent :collapsed="mode === 'collapsed'" />
<!-- 折叠/展开触发器复刻 naive-ui n-layout-toggle-barSPlayer 同款
两根竖条中间 4px 重叠无可见间隙默认含蓄低饱和不抢聚焦
hover 时两根竖条各自旋转在中间分开形成 < > 箭头
展开态 <指向左折叠方向折叠态 >指向右展开方向
命中区 44×88加大 hover 容错无背景色仅靠竖条亮度变化反馈 -->
<button
@click="ui.toggleSidebar()"
class="sider-trigger"
:class="mode === 'expanded' ? 'is-expanded' : 'is-collapsed'"
:title="mode === 'expanded' ? '折叠侧边栏' : '展开侧边栏'"
aria-label="切换侧边栏"
>
<span class="toggle-bar">
<span class="bar bar-top"></span>
<span class="bar bar-bottom"></span>
</span>
</button>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useUiStore, type SidebarMode } from '../stores/ui';
import { useSettingsStore } from '../stores/settings';
import SidebarContent from './SidebarContent.vue';
import IconMusic from '~icons/lucide/music';
const ui = useUiStore();
const settings = useSettingsStore();
const router = useRouter();
defineProps<{
mode: SidebarMode;
}>();
//
// +
const sidebarBgStyle = computed(() => {
if (settings.currentWallpaper.path) {
return { backgroundColor: 'rgba(0, 0, 0, 0.2)' };
}
return {};
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.25s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(-100%);
}
/* naive-ui n-layout-toggle-barSPlayer
参考源码naive-ui es/layout/src/styles/layout-sider.cssr.mjs
关键点
1. 两根竖条 width:4 height:38 left:14top=0 / top=34 中间 4px 重叠无可见间隙
2. hover 时各自旋转 ±12deg + scale(1.15) + translateY(±2px)
两根在中间分开两端反向旋转拼出 < > 箭头
3. 展开态 sider 未折叠 <指向左折叠方向
折叠态 >指向右展开方向
4. 默认低饱和--c-content-3 + opacityhover 变亮无背景色
5. 命中区 44×88加大 hover 容错 */
.sider-trigger {
position: absolute;
top: 50%;
right: -22px;
transform: translateY(-50%);
z-index: 30;
width: 44px;
height: 88px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
/* 沿 naive-ui 32×72 44×88
透明度加在容器上而非每根竖条上两根实色竖条重叠时颜色不会叠加变深
整体随容器统一淡入淡出参考 SPlayer .n-layout-toggle-bar { opacity:.3; &:hover{opacity:1} } */
.toggle-bar {
position: relative;
width: 44px;
height: 88px;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.sider-trigger:hover .toggle-bar {
opacity: 1;
}
/* 4×38 44 left:20
实色背景无自身 opacity两根垂直排列中间 4px 重叠无可见间隙
总高度 = 38 + 38 - 4 = 72 88 容器中垂直居中 起始 top = (88-72)/2 = 8
bar-top: top 8 8..46bar-bottom: top 42 42..80重叠区 42..46 */
.bar {
position: absolute;
width: 4px;
height: 38px;
left: 20px;
border-radius: 2px;
background-color: var(--c-content-3);
transition:
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s ease;
}
.bar-top { top: 8px; }
.bar-bottom { top: 42px; }
/* hover 时两根竖条变亮(透明度由容器统一处理) */
.sider-trigger:hover .bar {
background-color: var(--c-content);
}
/* hover <
naive-ui 公式top = rotate(12deg) scale(1.15) translateY(-2px)
bottom = rotate(-12deg) scale(1.15) translateY(2px)
两根中间端反向旋转分开远端继续向外形成 < */
.is-expanded:hover .bar-top {
transform: rotate(12deg) scale(1.15) translateY(-2px);
}
.is-expanded:hover .bar-bottom {
transform: rotate(-12deg) scale(1.15) translateY(2px);
}
/* hover >
naive-ui 公式top = rotate(-12deg) scale(1.15) translateY(-2px)
bottom = rotate(12deg) scale(1.15) translateY(2px) */
.is-collapsed:hover .bar-top {
transform: rotate(-12deg) scale(1.15) translateY(-2px);
}
.is-collapsed:hover .bar-bottom {
transform: rotate(12deg) scale(1.15) translateY(2px);
}
</style>
+390
View File
@@ -0,0 +1,390 @@
<template>
<div class="flex-1 overflow-y-auto min-h-0" :class="collapsed ? 'p-2' : 'p-4'">
<div class="flex flex-col min-h-full">
<!-- 主导航 -->
<div class="space-y-0.5">
<router-link to="/"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '推荐' : undefined">
<IconHome class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">推荐</span>
</router-link>
<router-link to="/discover"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '发现' : undefined">
<IconSearch class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">发现</span>
</router-link>
<button
@click="openRoamFromSidebar"
:class="navItemClass"
:title="collapsed ? '漫游' : undefined">
<IconRadio class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">漫游</span>
</button>
</div>
<!-- 我的 -->
<div class="mt-4 mb-1 pt-2">
<p v-if="!collapsed" class="text-xs text-content-3 px-3 mb-1">我的</p>
<div v-else class="border-t border-line-2/50 my-2"></div>
<div class="space-y-0.5">
<router-link to="/favorites"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '我喜欢的音乐' : undefined">
<IconHeart class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">我喜欢的音乐</span>
</router-link>
<router-link to="/recent"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '最近播放' : undefined">
<IconClock class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">最近播放</span>
</router-link>
<router-link to="/local-music"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '本地音乐' : undefined">
<IconMusic class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">本地音乐</span>
</router-link>
<router-link to="/downloaded-music"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '下载音乐' : undefined">
<IconDownload class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">下载音乐</span>
</router-link>
<router-link v-if="userStore.isLoggedIn" to="/cloud-music"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '音乐云盘' : undefined">
<IconCloud class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">音乐云盘</span>
</router-link>
</div>
</div>
<!-- 我的歌单 -->
<div v-if="userStore.isLoggedIn" class="mt-4 mb-1 pt-2">
<!-- 展开模式内联歌单列表 -->
<template v-if="!collapsed">
<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>
</template>
<!-- 折叠模式图标 + Teleport popover 浮层 -->
<template v-else>
<div class="border-t border-line-2/50 my-2"></div>
<div
ref="createdTriggerRef"
class="relative"
@mouseenter="openPopover('created')"
@mouseleave="scheduleClose('created')"
>
<div :class="navItemClass" class="cursor-pointer" title="我的歌单">
<IconListMusic class="w-[18px] h-[18px] flex-shrink-0" />
</div>
</div>
</template>
</div>
<!-- 收藏的歌单 -->
<div v-if="userStore.isLoggedIn" class="mt-4 mb-1 pt-2">
<template v-if="!collapsed">
<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>
</template>
<template v-else>
<div
ref="subTriggerRef"
class="relative"
@mouseenter="openPopover('sub')"
@mouseleave="scheduleClose('sub')"
>
<div :class="navItemClass" class="cursor-pointer" title="收藏的歌单">
<IconStar class="w-[18px] h-[18px] flex-shrink-0" />
</div>
</div>
</template>
</div>
<!-- 折叠模式歌单浮层Teleport body避免被 overflow 裁剪 -->
<Teleport to="body">
<div
v-if="popover.open && popover.type === 'created'"
class="playlist-popover-portal flex flex-col"
:style="popoverStyle"
@mouseenter="cancelClose()"
@mouseleave="scheduleClose('created')"
>
<p class="text-xs text-content-3 px-3 py-2 border-b border-line-2/40 flex-shrink-0">我的歌单</p>
<div class="flex-1 min-h-0 overflow-y-auto py-1">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-3 py-1.5 hover:bg-muted cursor-pointer transition">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate text-content-2">{{ pl.name }}</span>
</div>
<div v-if="!createdPlaylists.length" class="px-3 py-2 text-xs text-content-3">暂无歌单</div>
</div>
</div>
<div
v-if="popover.open && popover.type === 'sub'"
class="playlist-popover-portal flex flex-col"
:style="popoverStyle"
@mouseenter="cancelClose()"
@mouseleave="scheduleClose('sub')"
>
<p class="text-xs text-content-3 px-3 py-2 border-b border-line-2/40 flex-shrink-0">收藏的歌单</p>
<div class="flex-1 min-h-0 overflow-y-auto py-1">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-3 py-1.5 hover:bg-muted cursor-pointer transition">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate text-content-2">{{ pl.name }}</span>
</div>
<div v-if="!subPlaylists.length" class="px-3 py-2 text-xs text-content-3">暂无歌单</div>
</div>
</div>
</Teleport>
<!-- 底部设置 + 用户 -->
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
<div :class="collapsed ? 'px-0' : 'px-1'">
<router-link to="/settings"
:class="navItemClass"
active-class="!text-content !bg-muted"
:title="collapsed ? '设置' : undefined">
<IconSettings class="w-[18px] h-[18px] flex-shrink-0" />
<span class="nav-label" :class="labelClass">设置</span>
</router-link>
</div>
<!-- 未登录 -->
<div v-if="!userStore.isLoggedIn && !collapsed" 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-if="!userStore.isLoggedIn && collapsed" class="mt-3 flex justify-center">
<router-link to="/login"
class="w-9 h-9 flex items-center justify-center rounded-lg bg-accent hover:bg-accent-hover transition text-white"
title="登录">
<IconLogIn class="w-4 h-4" />
</router-link>
</div>
<!-- 已登录 -->
<div v-else-if="!collapsed" 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 v-else class="mt-3 flex justify-center">
<img :src="userStore.user?.avatarUrl"
class="w-8 h-8 rounded-full ring-2 ring-accent/50 cursor-pointer"
:title="userStore.user?.nickname"
@click="router.push('/settings')" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed, reactive } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
import { useUiStore } from '../stores/ui';
import { MusicApi } from '../api';
import IconHome from '~icons/lucide/home';
import IconSearch from '~icons/lucide/search';
import IconRadio from '~icons/lucide/radio';
import IconHeart from '~icons/lucide/heart';
import IconSettings from '~icons/lucide/settings';
import IconLogIn from '~icons/lucide/log-in';
import IconChevronRight from '~icons/lucide/chevron-right';
import IconClock from '~icons/lucide/clock';
import IconMusic from '~icons/lucide/music';
import IconCloud from '~icons/lucide/cloud';
import IconDownload from '~icons/lucide/download';
import IconListMusic from '~icons/lucide/list-music';
import IconStar from '~icons/lucide/star';
const props = defineProps<{
collapsed: boolean;
}>();
const emit = defineEmits<{
navigate: [];
}>();
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const player = usePlayerStore();
const ui = useUiStore();
// flex
const navItemClass = computed(() => [
'flex items-center rounded-lg transition-colors duration-200 text-content-2 hover:text-content hover:bg-subtle',
props.collapsed
? 'w-9 h-9 mx-auto justify-center'
: 'gap-3 px-3 py-2 w-full',
]);
// max-width + overflow-hidden
const labelClass = computed(() => [
'nav-label whitespace-nowrap overflow-hidden transition-all duration-300',
props.collapsed ? 'max-w-0 opacity-0' : 'max-w-[160px] opacity-100',
]);
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
// popover Teleport body overflow
const createdTriggerRef = ref<HTMLElement | null>(null);
const subTriggerRef = ref<HTMLElement | null>(null);
const popover = reactive({ open: false, type: 'created' as 'created' | 'sub' });
let closeTimer: ReturnType<typeof setTimeout> | null = null;
const popoverStyle = computed(() => {
const el = popover.type === 'created' ? createdTriggerRef.value : subTriggerRef.value;
if (!el) return { display: 'none' };
const rect = el.getBoundingClientRect();
// playerBar
// playerBar 64px (h-16) + 8px 8px
// playerBar
const playerBarReserved = player.currentSong ? 72 : 8;
const maxH = Math.max(120, window.innerHeight - rect.top - playerBarReserved);
return {
position: 'fixed' as const,
left: `${rect.right + 8}px`,
top: `${rect.top}px`,
maxHeight: `${maxH}px`,
};
});
function openPopover(type: 'created' | 'sub') {
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
popover.type = type;
popover.open = true;
}
function scheduleClose(_type: 'created' | 'sub') {
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => { popover.open = false; }, 200);
}
function cancelClose() {
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
}
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) {
popover.open = false;
router.push({ name: 'playlist', params: { id } });
emit('navigate');
}
function isPlaylistActive(id: number): boolean {
return route.name === 'playlist' && Number(route.params.id) === id;
}
async function openRoamFromSidebar() {
if (!userStore.isLoggedIn) {
router.push('/login');
emit('navigate');
return;
}
if (player.isFmMode) {
ui.openRoamDrawer();
} else {
await player.loadFm();
}
emit('navigate');
}
//
watch(() => route.fullPath, () => {
emit('navigate');
});
watch(() => userStore.isLoggedIn, (val) => {
if (val) {
loadPlaylists();
player.loadLikedIds();
}
});
onMounted(() => {
if (userStore.isLoggedIn) {
loadPlaylists();
}
});
</script>
<style scoped>
/* 折叠模式歌单浮层样式(Teleport 到 body,需用全局类名) */
</style>
<style>
.playlist-popover-portal {
min-width: 200px;
background-color: var(--color-surface, #fff);
border: 1px solid var(--color-line-2, rgba(0,0,0,0.1));
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 9999;
overflow: hidden;
animation: popoverFadeIn 0.2s ease;
}
@keyframes popoverFadeIn {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<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>
<button @click.stop="handleShare" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition">
<IconShare2 style="font-size: 14px" />
分享
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, onMounted } from 'vue';
import { useUiStore } from '../stores/ui';
import { showToast } from '../composables/useToast';
import IconEllipsis from '~icons/lucide/ellipsis';
import IconMessageSquare from '~icons/lucide/message-square';
import IconShare2 from '~icons/lucide/share-2';
const ui = useUiStore();
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;
ui.openCommentForSong(props.songId);
}
async function handleShare() {
open.value = false;
const url = `https://music.163.com/song?id=${props.songId}`;
try {
await navigator.clipboard.writeText(url);
showToast('链接已复制', 'success');
} catch {
showToast('复制失败', 'error');
}
}
function onClickOutside(e: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
open.value = false;
}
}
onMounted(() => document.addEventListener('click', onClickOutside));
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', isCurrent ? 'bg-accent-dim hover:bg-accent-dim' : 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>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 flex-shrink-0 select-none relative z-10"
:style="titleBarBgStyle"
>
<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 { computed } from 'vue';
import { useSettingsStore } from '../stores/settings';
import { minimizeWindow, toggleMaximize } from '../composables/useWindowControls';
const props = defineProps<{
darkMode?: boolean;
transparent?: boolean;
}>();
defineEmits<{
close: [];
}>();
const settings = useSettingsStore();
const titleBarBgStyle = computed(() => {
if (props.transparent) return {};
if (settings.currentWallpaper.path) return {}; //
if (props.darkMode) return {};
return { backgroundColor: settings.currentColors.surface };
});
</script>
+60
View File
@@ -0,0 +1,60 @@
<template>
<TransitionGroup name="toast" tag="div" class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<div
v-for="toast in toasts"
:key="toast.id"
@click="dismiss(toast.id)"
class="pointer-events-auto min-w-[280px] max-w-[400px] px-4 py-3 rounded-lg shadow-lg bg-surface/95 backdrop-blur cursor-pointer border-l-4"
:class="borderClass(toast.type)"
>
<p class="text-sm font-medium" :class="textClass(toast.type)">{{ toast.message }}</p>
</div>
</TransitionGroup>
</template>
<script setup lang="ts">
import { useToast, type Toast } from '../composables/useToast';
const { toasts } = useToast();
function borderClass(type: Toast['type']) {
return {
success: 'border-accent',
error: 'border-danger',
info: 'border-info',
}[type];
}
function textClass(type: Toast['type']) {
return {
success: 'text-accent-text',
error: 'text-danger',
info: 'text-info',
}[type];
}
function dismiss(id: number) {
const idx = toasts.value.findIndex(t => t.id === id);
if (idx !== -1) toasts.value.splice(idx, 1);
}
</script>
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
}
.toast-leave-active {
transition: all 0.2s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>
+328
View File
@@ -0,0 +1,328 @@
<template>
<div
class="h-12 flex items-center px-4 gap-3 flex-shrink-0 select-none relative z-20 border-b border-line-2/40"
:style="barBgStyle"
>
<!-- 抽屉模式菜单触发按钮 -->
<button
v-if="ui.sidebarMode === 'drawer'"
@click="ui.toggleDrawer()"
class="w-8 h-8 flex items-center justify-center rounded-lg text-content-2 hover:text-content hover:bg-muted transition flex-shrink-0"
title="打开菜单"
>
<IconMenu class="w-5 h-5" />
</button>
<!-- 前进/后退 -->
<div class="flex items-center gap-1 flex-shrink-0">
<button
@click="goBack"
:disabled="!canGoBack"
:class="[
'w-7 h-7 flex items-center justify-center rounded-full transition',
canGoBack ? 'text-content-2 hover:text-content hover:bg-muted' : 'text-content-3/40 cursor-not-allowed'
]"
title="后退"
>
<IconArrowLeft class="w-[18px] h-[18px]" />
</button>
<button
@click="goForward"
:disabled="!canGoForward"
:class="[
'w-7 h-7 flex items-center justify-center rounded-full transition',
canGoForward ? 'text-content-2 hover:text-content hover:bg-muted' : 'text-content-3/40 cursor-not-allowed'
]"
title="前进"
>
<IconArrowRight class="w-[18px] h-[18px]" />
</button>
</div>
<!-- 可拖拽空白区窗口拖动 -->
<div class="flex-1 h-full" data-tauri-drag-region></div>
<!-- 搜索框 -->
<div class="relative w-80 flex-shrink-0" @click.stop>
<IconSearch class="absolute left-3 top-1/2 -translate-y-1/2 text-content-3 w-4 h-4 pointer-events-none" />
<input
ref="searchInputRef"
v-model="keyword"
@input="onInputChange"
@keydown.enter="handleSearch"
@focus="onInputFocus"
placeholder="搜索歌曲、歌手、专辑..."
class="w-full h-8 rounded-full bg-muted/70 pl-9 pr-8 text-sm text-content placeholder-content-3 outline-none focus:bg-muted focus:ring-1 focus:ring-accent/40 transition"
/>
<button
v-if="keyword"
@click="clearKeyword"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition"
>
<IconX class="w-3.5 h-3.5" />
</button>
<!-- 下拉面板 -->
<Transition name="fade">
<div
v-if="showPanel"
class="absolute z-30 left-0 right-0 top-full mt-1.5 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-1.5">
<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 class="w-3.5 h-3.5 text-content-3 flex-shrink-0" />
<span>{{ s }}</span>
</button>
</div>
<!-- 搜索历史 -->
<div v-if="!suggestions.length && searchHistory.length" class="p-1.5">
<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 class="w-3.5 h-3.5 text-content-3 flex-shrink-0" />
<span>{{ h }}</span>
</button>
</div>
<!-- 热门搜索 -->
<div v-if="!suggestions.length && !searchHistory.length && hotTags.length" class="p-1.5">
<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"
>
<IconFlame class="w-3.5 h-3.5 text-content-3 flex-shrink-0" />
<span>{{ tag.searchWord }}</span>
</button>
</div>
</div>
</Transition>
</div>
<!-- 可拖拽空白区窗口拖动 -->
<div class="flex-1 h-full" data-tauri-drag-region></div>
<!-- 右侧窗口控制 -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<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 { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { MusicApi } from '../api';
import { useUiStore } from '../stores/ui';
import { pageCacheGet, pageCacheSet, pageCacheIsStale, pageCacheInvalidate } from '../composables/usePageCache';
import { minimizeWindow, toggleMaximize } from '../composables/useWindowControls';
import IconArrowLeft from '~icons/lucide/arrow-left';
import IconArrowRight from '~icons/lucide/arrow-right';
import IconSearch from '~icons/lucide/search';
import IconX from '~icons/lucide/x';
import IconHistory from '~icons/lucide/history';
import IconFlame from '~icons/lucide/flame';
import IconMenu from '~icons/lucide/menu';
defineEmits<{ close: [] }>();
const router = useRouter();
const route = useRoute();
const ui = useUiStore();
// --- ---
const canGoBack = ref(false);
const canGoForward = ref(false);
function updateNavState() {
// window.history.length SPA
canGoBack.value = navStack.value.length > 1;
canGoForward.value = navStack.value.length > 0 && navStackIdx.value < navStack.value.length - 1;
}
const navStack = ref<string[]>([]);
const navStackIdx = ref(-1);
function goBack() {
if (!canGoBack.value) return;
router.go(-1);
}
function goForward() {
if (!canGoForward.value) return;
router.go(1);
}
// --- ---
const searchInputRef = ref<HTMLInputElement | null>(null);
const keyword = ref('');
const suggestions = ref<string[]>([]);
const showPanel = ref(false);
const hotTags = ref<any[]>([]);
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 = [];
showPanel.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);
showPanel.value = true;
} catch {
suggestions.value = [];
}
}, 300);
}
function onInputFocus() {
showPanel.value = true;
}
function clearKeyword() {
keyword.value = '';
suggestions.value = [];
showPanel.value = true;
searchInputRef.value?.focus();
}
function searchTag(q: string) {
keyword.value = q;
handleSearch();
}
async function handleSearch() {
const q = keyword.value.trim();
if (!q) return;
showPanel.value = false;
saveSearchHistory(q);
searchHistory.value = loadSearchHistory();
// Discover query
router.push({ name: 'discover', query: { q } });
}
async function loadHotTags() {
const cached = pageCacheGet('topbar_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, 8);
pageCacheSet('topbar_hotTags', hotTags.value);
} catch { /* 忽略 */ }
}
}
// --- composable ---
// --- ---
//
const barBgStyle = computed(() => {
return {};
});
// --- + ---
function onRouteChange() {
const path = route.fullPath;
if (navStackIdx.value >= 0 && navStack.value[navStackIdx.value] === path) return;
// /退
const existingIdx = navStack.value.indexOf(path);
if (existingIdx !== -1) {
navStackIdx.value = existingIdx;
} else {
//
navStack.value = navStack.value.slice(0, navStackIdx.value + 1);
navStack.value.push(path);
navStackIdx.value = navStack.value.length - 1;
}
updateNavState();
}
function onDocClick() {
showPanel.value = false;
}
onMounted(() => {
loadHotTags();
onRouteChange();
router.afterEach(onRouteChange);
document.addEventListener('click', onDocClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick);
});
//
onMounted(() => {
if (pageCacheIsStale('topbar_hotTags')) {
pageCacheInvalidate('topbar_hotTags');
loadHotTags();
}
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+88
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>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="flex flex-col">
<SongListItem
v-for="(song, index) in songs"
:key="song.id"
:song="song"
:index="index"
:is-current="song.id === currentSongId"
:show-index="showIndex"
:show-like="showLike"
:show-download="showDownload"
:show-menu="showMenu"
:show-duration="showDuration"
:show-playing-overlay="showPlayingOverlay"
:cover-size="coverSize"
:cover-size-param="coverSizeParam"
@click="$emit('song-click', song, index)"
/>
</div>
</template>
<script setup lang="ts">
import SongListItem from './SongListItem.vue';
import type { Song } from '../types/song';
withDefaults(defineProps<{
songs: Song[];
currentSongId?: number;
showIndex?: boolean;
showLike?: boolean;
showDownload?: boolean;
showMenu?: boolean;
showDuration?: boolean;
showPlayingOverlay?: boolean;
coverSize?: string;
coverSizeParam?: string;
}>(), {
showIndex: false,
showLike: false,
showDownload: false,
showMenu: false,
showDuration: false,
showPlayingOverlay: false,
coverSize: 'w-10 h-10',
coverSizeParam: '',
});
defineEmits<{
(e: 'song-click', song: Song, index: number): void;
}>();
</script>
+24 -35
View File
@@ -1,48 +1,37 @@
import { ref, computed, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { watch } from 'vue';
import { usePlayerStore } from '../stores/player';
import { useLyricManager } from './useLyricManager';
/**
* composable
*
* useLyricManager player store currentSong/currentTime
* 使 useLyricManager
*/
export function useLyric() {
const player = usePlayerStore();
const manager = useLyricManager();
const lyrics = ref<LyricLine[]>([]);
const currentLyricIdx = ref(-1);
const currentLyricText = computed(() => {
if (lyrics.value.length === 0) return '';
const idx = currentLyricIdx.value;
return idx >= 0 && idx < lyrics.value.length ? lyrics.value[idx].text : '';
});
watch(() => player.currentSong, async (song) => {
if (!song) {
lyrics.value = [];
currentLyricIdx.value = -1;
return;
}
try {
const jsonStr: string = await invoke('get_lyric', { id: song.id });
const data = JSON.parse(jsonStr);
const lrc = data?.lrc?.lyric || '';
lyrics.value = lrc ? parseLrc(lrc) : [];
currentLyricIdx.value = -1;
} catch {
lyrics.value = [];
// 跟随当前歌曲加载歌词
watch(() => player.currentSong, (song) => {
manager.loadLyric(song);
// 预加载队列中下一首的歌词
const next = player.queue[player.currentIndex + 1];
if (next && next.id !== song?.id) {
manager.preloadLyric(next);
}
}, { immediate: true });
// 跟随播放时间更新当前歌词行
watch(() => player.currentTime, (t) => {
if (lyrics.value.length === 0) return;
const idx = getCurrentLyricIndex(lyrics.value, t);
if (idx !== currentLyricIdx.value) {
currentLyricIdx.value = idx;
}
manager.updateCurrentIndex(t);
});
return {
lyrics,
currentLyricIdx,
currentLyricText,
lyrics: manager.lyrics,
currentLyricIdx: manager.currentLyricIdx,
hasTranslation: manager.hasTranslation,
showTranslation: manager.showTranslation,
toggleTranslation: manager.toggleTranslation,
};
}
}
+134
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,
};
}
+52
View File
@@ -0,0 +1,52 @@
import { MusicApi } from '../api';
import type { Song } from '../utils/song';
export interface LocalSong {
id: number;
name: string;
artist: string;
album: string;
duration: number;
cover: string | null;
filename: string;
fileSize: number;
path: string;
local: boolean;
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
export function localSongToSong(local: LocalSong): Song {
return {
id: local.id,
name: local.name,
ar: local.artist ? [{ name: local.artist }] : [],
al: { picUrl: local.cover || '', name: local.album || undefined },
dt: local.duration || undefined,
localPath: local.path,
};
}
export async function fetchMissingCovers(songs: LocalSong[]): Promise<void> {
const missing = songs.filter(s => !s.cover && s.id > 0 && s.id < 1e12);
if (missing.length === 0) return;
const ids = [...new Set(missing.map(s => s.id))];
try {
const jsonStr: string = await MusicApi.getSongDetail(JSON.stringify(ids));
const data = JSON.parse(jsonStr);
const detailMap = new Map<number, string>();
for (const s of data.songs || []) {
const url = s.al?.picUrl;
if (url && s.id) detailMap.set(s.id, url + '?param=100y100');
}
for (const song of missing) {
const url = detailMap.get(song.id);
if (url) song.cover = url;
}
} catch { /* 忽略 */ }
}
+185
View File
@@ -0,0 +1,185 @@
import { ref } from 'vue';
import { SongApi } from '../api/song';
import { parseLrc, mergeTranslation, getCurrentLyricIndex, type LyricLine } from '../utils/lyric';
import type { Song } from '../types/song';
/** 已解析的歌词数据 */
export interface LyricData {
lines: LyricLine[];
hasTranslation: boolean;
}
const MAX_CACHE = 100;
/** 歌词持久化 TTL:7 天(歌词很少变动) */
const LYRIC_TTL = 7 * 24 * 60 * 60 * 1000;
const LYRIC_LS_PREFIX = 'lyric:';
/** L1 内存缓存(按 songId 索引) */
const lyricCache = new Map<number, LyricData>();
/** 从 L2localStorage)读取歌词并校验 TTL */
function lyricLsGet(songId: number): LyricData | null {
try {
const raw = localStorage.getItem(LYRIC_LS_PREFIX + songId);
if (!raw) return null;
const entry = JSON.parse(raw);
if (Date.now() - entry.ts > LYRIC_TTL) {
localStorage.removeItem(LYRIC_LS_PREFIX + songId);
return null;
}
return entry.data as LyricData;
} catch {
return null;
}
}
/** 写入 L2localStorage),配额不足时静默失败 */
function lyricLsSet(songId: number, data: LyricData) {
try {
localStorage.setItem(LYRIC_LS_PREFIX + songId, JSON.stringify({ data, ts: Date.now() }));
} catch { /* 配额不足:仅保留内存缓存 */ }
}
/** 当前歌词请求序列号(防竞态) */
let lyricReqSeq = 0;
/** 正在加载中的请求(去重) */
const pendingRequests = new Map<number, Promise<LyricData>>();
/**
* JSON LyricData
*/
function parseLyricJson(jsonStr: string): LyricData {
const data = JSON.parse(jsonStr);
const lrc = data?.lrc?.lyric || '';
const tLrc = data?.tlyric?.lyric || '';
let parsed = lrc ? parseLrc(lrc) : [];
let hasTranslation = false;
if (tLrc && parsed.length > 0) {
parsed = mergeTranslation(parsed, tLrc);
hasTranslation = parsed.some(l => l.translation);
}
return { lines: parsed, hasTranslation };
}
/**
* +
*
* @param songId ID
* @param force
*/
async function fetchLyric(songId: number, force = false): Promise<LyricData> {
// L1 内存
if (!force && lyricCache.has(songId)) {
return lyricCache.get(songId)!;
}
// L2 localStorage(回填 L1
if (!force) {
const l2 = lyricLsGet(songId);
if (l2) {
if (lyricCache.size >= MAX_CACHE) {
const firstKey = lyricCache.keys().next().value;
if (firstKey !== undefined) lyricCache.delete(firstKey);
}
lyricCache.set(songId, l2);
return l2;
}
}
if (pendingRequests.has(songId)) {
return pendingRequests.get(songId)!;
}
const promise = (async () => {
const jsonStr = await SongApi.getLyric(songId);
const data = parseLyricJson(jsonStr);
// 写入 L1 + L2(LRU 策略:超限时删除最早条目)
if (lyricCache.size >= MAX_CACHE) {
const firstKey = lyricCache.keys().next().value;
if (firstKey !== undefined) lyricCache.delete(firstKey);
}
lyricCache.set(songId, data);
lyricLsSet(songId, data);
return data;
})().finally(() => {
pendingRequests.delete(songId);
});
pendingRequests.set(songId, promise);
return promise;
}
/**
*
*
*
* - currentSong
* - currentTime
* -
* -
*/
export function useLyricManager() {
const lyrics = ref<LyricLine[]>([]);
const currentLyricIdx = ref(-1);
const showTranslation = ref(true);
const hasTranslation = ref(false);
/** 当前加载的 songId(用于防竞态) */
let currentSongId: number | null = null;
/** 加载指定歌曲的歌词 */
async function loadLyric(song: Song | null) {
if (!song) {
lyrics.value = [];
currentLyricIdx.value = -1;
hasTranslation.value = false;
currentSongId = null;
return;
}
const seq = ++lyricReqSeq;
currentSongId = song.id;
try {
const data = await fetchLyric(song.id);
// 防竞态:如果期间又切了歌,丢弃本次结果
if (seq !== lyricReqSeq || currentSongId !== song.id) return;
lyrics.value = data.lines;
hasTranslation.value = data.hasTranslation;
currentLyricIdx.value = -1;
} catch {
if (seq !== lyricReqSeq) return;
lyrics.value = [];
hasTranslation.value = false;
}
}
/** 预加载指定歌曲的歌词(不更新当前显示) */
function preloadLyric(song: Song | null) {
if (!song) return;
fetchLyric(song.id).catch(() => { /* 预加载失败静默 */ });
}
/** 根据当前播放时间更新当前歌词行 */
function updateCurrentIndex(time: number) {
if (lyrics.value.length === 0) return;
const idx = getCurrentLyricIndex(lyrics.value, time);
if (idx !== currentLyricIdx.value) {
currentLyricIdx.value = idx;
}
}
function toggleTranslation() {
showTranslation.value = !showTranslation.value;
}
return {
lyrics,
currentLyricIdx,
showTranslation,
hasTranslation,
loadLyric,
preloadLyric,
updateCurrentIndex,
toggleTranslation,
};
}
+30
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 };
}
+112
View File
@@ -0,0 +1,112 @@
/**
* L1 + L2 localStorage
*
*
* - L1 Map访
* - L2localStorage
*
* L1 L2 missL2 L1
* L1 + L2L2
*/
const TTL = 30 * 60 * 1000;
const MAX_ENTRIES = 30;
const LS_PREFIX = 'pc:';
// L1 内存缓存
const cache = new Map<string, { data: any; ts: number }>();
/** 从 localStorage 读取并校验 TTL */
function lsGet(key: string): { data: any; ts: number } | null {
try {
const raw = localStorage.getItem(LS_PREFIX + key);
if (!raw) return null;
const entry = JSON.parse(raw);
if (Date.now() - entry.ts > TTL) {
localStorage.removeItem(LS_PREFIX + key);
return null;
}
return entry;
} catch {
return null;
}
}
/** 写入 localStorage,配额不足时按 FIFO 淘汰旧条目后重试 */
function lsSet(key: string, entry: { data: any; ts: number }) {
try {
localStorage.setItem(LS_PREFIX + key, JSON.stringify(entry));
} catch {
// 配额不足:扫描淘汰最旧的条目后重试一次
const keys: { k: string; ts: number }[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(LS_PREFIX)) {
try {
const e = JSON.parse(localStorage.getItem(k) || '{}');
keys.push({ k, ts: e.ts || 0 });
} catch { /* 忽略损坏条目 */ }
}
}
keys.sort((a, b) => a.ts - b.ts);
// 淘汰最旧的 1/3
const removeCount = Math.max(1, Math.floor(keys.length / 3));
for (let i = 0; i < removeCount && i < keys.length; i++) {
localStorage.removeItem(keys[i].k);
}
try {
localStorage.setItem(LS_PREFIX + key, JSON.stringify(entry));
} catch { /* 仍然失败则放弃持久化,仅保留内存缓存 */ }
}
}
function lsDelete(key: string) {
localStorage.removeItem(LS_PREFIX + key);
}
/** L1 淘汰最旧条目 */
function evictL1IfNeeded() {
if (cache.size >= MAX_ENTRIES) {
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) cache.delete(firstKey);
}
}
export function pageCacheGet(key: string): any | null {
// L1
const l1 = cache.get(key);
if (l1) {
if (Date.now() - l1.ts > TTL) {
cache.delete(key);
lsDelete(key);
return null;
}
return l1.data;
}
// L2
const l2 = lsGet(key);
if (l2) {
// 回填 L1
evictL1IfNeeded();
cache.set(key, l2);
return l2.data;
}
return null;
}
export function pageCacheSet(key: string, data: any) {
const entry = { data, ts: Date.now() };
evictL1IfNeeded();
cache.set(key, entry);
lsSet(key, entry);
}
export function pageCacheInvalidate(key: string) {
cache.delete(key);
lsDelete(key);
}
export function pageCacheIsStale(key: string): boolean {
const entry = cache.get(key) || lsGet(key);
if (!entry) return true;
return Date.now() - entry.ts > TTL;
}
+37
View File
@@ -0,0 +1,37 @@
import { ref } from 'vue';
export interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
const toasts = ref<Toast[]>([]);
let nextId = 0;
const MAX_TOASTS = 3;
const DEDUP_WINDOW = 3000;
const recentMessages = new Map<string, number>();
export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
const key = `${type}:${message}`;
const now = Date.now();
const lastShown = recentMessages.get(key);
if (lastShown && now - lastShown < DEDUP_WINDOW) return;
recentMessages.set(key, now);
setTimeout(() => { recentMessages.delete(key); }, DEDUP_WINDOW);
if (toasts.value.length >= MAX_TOASTS) {
toasts.value.shift();
}
const id = nextId++;
toasts.value.push({ id, message, type });
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id);
}, duration);
}
export function useToast() {
return { toasts, showToast };
}
+142
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,
}
}
+18
View File
@@ -0,0 +1,18 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
// 窗口控制:最小化 / 最大化切换
// TitleBar 与 TopBar 共用,避免重复实现
const currentWindow = getCurrentWindow();
export function minimizeWindow() {
currentWindow.minimize();
}
export async function toggleMaximize() {
const isMaximized = await currentWindow.isMaximized();
if (isMaximized) {
currentWindow.unmaximize();
} else {
currentWindow.maximize();
}
}
-7
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);
+30 -19
View File
@@ -1,26 +1,37 @@
import { createRouter, createWebHistory } from 'vue-router';
import Home from '@/views/Home.vue';
import Discover from '@/views/Discover.vue';
import PlaylistDetail from '@/views/PlaylistDetail.vue';
import Login from '@/views/Login.vue';
import FavoriteSongs from '@/views/FavoriteSongs.vue';
import RecentPlays from '@/views/RecentPlays.vue';
import DailySongs from '@/views/DailySongs.vue';
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/discover', name: 'discover', component: Discover },
{ path: '/search', name: 'search', component: Discover }, // 同样指向Discover,保留兼容
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') }, // 漫游页面
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
{ path: '/recent', name: 'recent', component: RecentPlays },
{ path: '/daily', name: 'daily', component: DailySongs }, // 每日推荐
{ path: '/login', name: 'login', component: Login },
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
{ path: '/', name: 'home', component: () => import('@/views/Home.vue') },
{ path: '/discover', name: 'discover', component: () => import('@/views/Discover.vue') },
{ path: '/search', name: 'search', component: () => import('@/views/Discover.vue') },
{ path: '/favorites', name: 'favorites', component: () => import('@/views/FavoriteSongs.vue') },
{ path: '/recent', name: 'recent', component: () => import('@/views/RecentPlays.vue') },
{ path: '/daily', name: 'daily', component: () => import('@/views/DailySongs.vue') },
{ path: '/local-music', name: 'local-music', component: () => import('@/views/LocalMusic.vue') },
{ path: '/downloaded-music', name: 'downloaded-music', component: () => import('@/views/DownloadedMusic.vue') },
{ path: '/cloud-music', name: 'cloud-music', component: () => import('@/views/CloudMusic.vue') },
{ path: '/login', name: 'login', component: () => import('@/views/Login.vue'), meta: { guest: true } },
{ path: '/playlist/:id', name: 'playlist', component: () => import('@/views/PlaylistDetail.vue') },
{ path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') },
{ path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') },
{ path: '/settings', name: 'settings', component: () => import('@/views/Settings.vue') },
];
export default createRouter({
const router = createRouter({
history: createWebHistory(),
routes,
});
});
router.beforeEach((to) => {
if (to.meta.guest) {
const raw = localStorage.getItem('user_profile');
if (raw) {
try {
const data = JSON.parse(raw);
if (data?.userId) return { name: 'home' };
} catch { /* 忽略 */ }
}
}
});
export default router;
+5
View File
@@ -0,0 +1,5 @@
declare module '~icons/lucide/*' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+152
View File
@@ -0,0 +1,152 @@
export interface SkinColors {
bg: string;
surface: string;
subtle: string;
muted: string;
emphasis: string;
content: string;
content2: string;
content3: string;
content4: string;
line: string;
line2: string;
accent: string;
accentHover: string;
accentText: string;
accentDim: string;
danger: string;
dangerDim: string;
warning: string;
info: string;
}
interface PresetSkin {
id: string;
name: string;
preview: string;
colors: SkinColors;
}
function darkSkin(accent: string, accentHover: string, accentText: string, name: string, id: string): PresetSkin {
return {
id,
name,
preview: accent,
colors: {
bg: '#02060c',
surface: '#0a101a',
subtle: `rgba(${hexToRgb(accent)}, 0.06)`,
muted: `rgba(${hexToRgb(accent)}, 0.10)`,
emphasis: `rgba(${hexToRgb(accent)}, 0.18)`,
content: '#ffffff',
content2: '#9ca3af',
content3: '#6b7280',
content4: '#4b5563',
line: 'rgba(255, 255, 255, 0.08)',
line2: 'rgba(255, 255, 255, 0.04)',
accent,
accentHover,
accentText,
accentDim: `rgba(${hexToRgb(accent)}, 0.20)`,
danger: '#ef4444',
dangerDim: 'rgba(239, 68, 68, 0.20)',
warning: '#eab308',
info: '#8b5cf6',
},
};
}
function lightSkin(accent: string, accentHover: string, accentText: string, name: string, id: string): PresetSkin {
return {
id,
name,
preview: accent,
colors: {
bg: '#f8fafc',
surface: '#ffffff',
subtle: `rgba(${hexToRgb(accent)}, 0.06)`,
muted: `rgba(${hexToRgb(accent)}, 0.10)`,
emphasis: `rgba(${hexToRgb(accent)}, 0.18)`,
content: '#0f172a',
content2: '#475569',
content3: '#94a3b8',
content4: '#cbd5e1',
line: 'rgba(0, 0, 0, 0.08)',
line2: 'rgba(0, 0, 0, 0.04)',
accent,
accentHover,
accentText,
accentDim: `rgba(${hexToRgb(accent)}, 0.15)`,
danger: '#ef4444',
dangerDim: 'rgba(239, 68, 68, 0.15)',
warning: '#eab308',
info: '#8b5cf6',
},
};
}
function hexToRgb(hex: string): string {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return `${r}, ${g}, ${b}`;
}
export const presetSkins: PresetSkin[] = [
// 深色
darkSkin('#3b82f6', '#2563eb', '#60a5fa', '深蓝', 'dark-blue'),
darkSkin('#22c55e', '#16a34a', '#4ade80', '深翠', 'dark-green'),
darkSkin('#f43f5e', '#e11d48', '#fb7185', '深红', 'dark-rose'),
darkSkin('#8b5cf6', '#7c3aed', '#a78bfa', '深紫', 'dark-violet'),
darkSkin('#f97316', '#ea580c', '#fb923c', '深橙', 'dark-orange'),
darkSkin('#06b6d4', '#0891b2', '#22d3ee', '深青', 'dark-cyan'),
darkSkin('#ec4899', '#db2777', '#f472b6', '深粉', 'dark-pink'),
// 浅色
lightSkin('#3b82f6', '#2563eb', '#2563eb', '浅蓝', 'light-blue'),
lightSkin('#22c55e', '#16a34a', '#16a34a', '浅翠', 'light-green'),
lightSkin('#f43f5e', '#e11d48', '#e11d48', '浅红', 'light-rose'),
lightSkin('#8b5cf6', '#7c3aed', '#7c3aed', '浅紫', 'light-violet'),
lightSkin('#f97316', '#ea580c', '#ea580c', '浅橙', 'light-orange'),
lightSkin('#06b6d4', '#0891b2', '#0891b2', '浅青', 'light-cyan'),
lightSkin('#ec4899', '#db2777', '#db2777', '浅粉', 'light-pink'),
];
const presetIdSet = new Set(presetSkins.map(s => s.id));
export function isPresetSkinId(id: string): boolean {
return presetIdSet.has(id);
}
export function getPresetSkin(id: string): PresetSkin | undefined {
return presetSkins.find(s => s.id === id);
}
/** 将皮肤颜色应用到 DOM CSS 变量 */
export function applySkinColors(colors: SkinColors) {
const root = document.documentElement;
const map: Record<keyof SkinColors, string> = {
bg: '--c-bg',
surface: '--c-surface',
subtle: '--c-subtle',
muted: '--c-muted',
emphasis: '--c-emphasis',
content: '--c-content',
content2: '--c-content-2',
content3: '--c-content-3',
content4: '--c-content-4',
line: '--c-line',
line2: '--c-line-2',
accent: '--c-accent',
accentHover: '--c-accent-hover',
accentText: '--c-accent-text',
accentDim: '--c-accent-dim',
danger: '--c-danger',
dangerDim: '--c-danger-dim',
warning: '--c-warning',
info: '--c-info',
};
for (const [key, cssVar] of Object.entries(map)) {
root.style.setProperty(cssVar, colors[key as keyof SkinColors]);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useUserStore } from './user';
import { MusicApi } from '../api';
/**
* store
*
* ID
*/
export const useLikedStore = defineStore('liked', () => {
/** 已喜欢的歌曲 ID 集合 */
const likedIds = ref<Set<number>>(new Set());
/** 判断指定歌曲是否已喜欢 */
function isLiked(songId: number): boolean {
return likedIds.value.has(songId);
}
/** 加载当前用户的喜欢列表 */
async function loadLikedIds() {
const userStore = useUserStore();
if (!userStore.isLoggedIn) return;
try {
const json = await MusicApi.likelist(userStore.user!.userId);
const data = JSON.parse(json);
const ids: number[] = data.ids || data.data?.ids || [];
likedIds.value = new Set(ids);
} catch (e) {
console.error('加载喜欢列表失败', e);
}
}
/** 切换指定歌曲的喜欢状态 */
async function toggleLike(songId: number) {
const wasLiked = likedIds.value.has(songId);
const newLike = !wasLiked;
try {
await MusicApi.likeSong(songId, newLike);
if (newLike) {
likedIds.value.add(songId);
} else {
likedIds.value.delete(songId);
}
likedIds.value = new Set(likedIds.value);
} catch { /* 忽略 */ }
}
return {
likedIds,
isLiked,
loadLikedIds,
toggleLike,
};
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Schema
*
* CURRENT_SCHEMA_VERSION
* v0 v1 v2 ... CURRENT
*
* (data: any) => any
* -
* -
* -
*/
/** 当前 Schema 版本号 */
export const CURRENT_SCHEMA_VERSION = 3;
/** 迁移函数按目标版本号索引(data 升级到此版本后即为 vN) */
const migrations: Record<number, (data: any) => any> = {
/**
* v1: theme + appearance skin
*
* themeblue/green/... appearancedark/light
* skin "dark-blue"
*/
1: (data) => {
if (!data.skin && (data.theme || data.appearance)) {
const appearance = data.appearance || 'dark';
const theme = data.theme || 'blue';
const validThemes = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink'];
const t = validThemes.includes(theme) ? theme : 'blue';
data.skin = appearance === 'light' ? `light-${t}` : `dark-${t}`;
}
if (!data.skin) data.skin = 'dark-blue';
delete data.theme;
delete data.appearance;
return data;
},
/**
* v2: 全局 wallpaper
*
* wallpaper/wallpaperBlur/wallpaperOpacity
*
*/
2: (data) => {
if (data.wallpaper && Array.isArray(data.customSkins) && data.customSkins.length > 0) {
data.customSkins = data.customSkins.map((s: any) => {
if (!s.wallpaper) {
return {
...s,
wallpaper: data.wallpaper,
wallpaperBlur: s.wallpaperBlur ?? data.wallpaperBlur ?? 10,
wallpaperOpacity: s.wallpaperOpacity ?? data.wallpaperOpacity ?? 0.3,
};
}
return s;
});
}
delete data.wallpaper;
delete data.wallpaperBlur;
delete data.wallpaperOpacity;
return data;
},
/**
* v3: localMusicPaths (string[]) localMusicFolders ({path, enabled}[])
*
* enabled
*/
3: (data) => {
if (!data.localMusicFolders && Array.isArray(data.localMusicPaths) && data.localMusicPaths.length) {
data.localMusicFolders = data.localMusicPaths.map((p: string) => ({ path: p, enabled: true }));
}
if (!Array.isArray(data.localMusicFolders)) {
data.localMusicFolders = [];
}
// 兼容 localMusicFolders 内仍是字符串的情况
data.localMusicFolders = data.localMusicFolders.map((f: any) =>
typeof f === 'string' ? { path: f, enabled: true } : f
);
delete data.localMusicPaths;
return data;
},
};
/**
* CURRENT_SCHEMA_VERSION
*
* @param raw localStorage schemaVersion
* @returns schemaVersion = CURRENT_SCHEMA_VERSION
*/
export function migrateSettings(raw: any): any {
let data = { ...raw };
let version = typeof data.schemaVersion === 'number' ? data.schemaVersion : 0;
while (version < CURRENT_SCHEMA_VERSION) {
const next = version + 1;
const migrator = migrations[next];
if (migrator) {
data = migrator(data);
}
version = next;
}
data.schemaVersion = CURRENT_SCHEMA_VERSION;
return data;
}
+550 -122
View File
@@ -1,37 +1,15 @@
import { defineStore } from 'pinia';
import { ref , watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { ref, watch } from 'vue';
import { normalizeSong } from '../utils/song';
import type { Song, PlayMode } from '../types/song';
import { useSettingsStore } from './settings';
import { useLikedStore } from './liked';
import { useRecentStore } from './recent';
import { showToast } from '../composables/useToast';
import { MusicApi, AudioApi } from '../api';
// 设置播放模式,目前只有顺序循环,后续可扩展
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
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';
export const usePlayerStore = defineStore('player', () => {
const currentSong = ref<Song | null>(null);
@@ -42,11 +20,90 @@ export const usePlayerStore = defineStore('player', () => {
const queue = ref<Song[]>([]);
const currentIndex = ref(-1);
/** 当前播放来源(用于卡片显示播放/暂停状态) */
const currentSource = ref<{ type: 'playlist' | 'album' | 'songs' | 'fm' | null; id: number | null }>({
type: null,
id: null,
});
const settings = useSettingsStore();
const volume = ref(settings.volume);
watch(volume, (val) => { settings.volume = val; });
const recentStore = useRecentStore();
const likedStore = useLikedStore();
const { addRecent } = recentStore;
let tickInterval: ReturnType<typeof setInterval> | null = null;
function clearTick() {
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
}
function setTick(v: ReturnType<typeof setInterval>) {
tickInterval = v;
}
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,
});
}
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;
let lastScrobbleAlg: string | undefined;
let lastScrobbleSource: string | undefined;
let lastScrobbleBitrate: number | undefined;
/// 上报上一首歌的听歌记录(scrobble),然后记录当前歌的开始时间
function reportScrobble() {
// 先上报:如果有正在记录的歌曲且播放超过 5 秒,发送 scrobble
if (lastScrobbleId != null && lastScrobbleStartTime > 0) {
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
if (playedSec > 5 && navigator.onLine) {
MusicApi.scrobble({
id: lastScrobbleId,
sourceid: isFmMode.value ? String(lastScrobbleId) : '',
time: playedSec,
alg: lastScrobbleAlg || '',
source: lastScrobbleSource || 'list',
bitrate: lastScrobbleBitrate || 0,
}).catch(() => {});
}
}
// 再记录:当前歌曲作为新的 scrobble 起点
const song = currentSong.value;
if (!song || song.localPath || song.id == null) {
lastScrobbleId = null;
lastScrobbleStartTime = 0;
lastScrobbleAlg = undefined;
lastScrobbleSource = undefined;
lastScrobbleBitrate = undefined;
} else {
lastScrobbleId = song.id;
lastScrobbleStartTime = Date.now();
lastScrobbleAlg = song.alg;
lastScrobbleSource = isFmMode.value ? 'personal_fm' : 'list';
lastScrobbleBitrate = song.br;
}
}
function enableFmMode(onNext: () => void) {
isFmMode.value = true;
fmNextCallback = onNext;
@@ -55,14 +112,53 @@ export const usePlayerStore = defineStore('player', () => {
function disableFmMode() {
isFmMode.value = false;
fmNextCallback = null;
fmQueue.length = 0;
fmMode.value = 'DEFAULT';
fmSubMode.value = '';
fmSong.value = null;
fmPlaying.value = false;
}
// 播放私人漫游歌曲(清空队列,只播放这一首)
async function playFmSong(song: any) {
// 如果缺少时长,尝试从详情接口获取
function clearFmQueue() {
fmQueue.length = 0;
}
async function fmTrash(songId: number) {
try {
await MusicApi.fmTrash(songId, 25);
} catch (e) {
console.error('fm_trash 失败', e);
showToast('减少推荐失败', 'error');
}
await nextFm();
}
async function fetchFmBatch(): Promise<Song[]> {
const isDefault = fmMode.value === 'DEFAULT' && !fmSubMode.value;
const jsonStr: string = isDefault
? await MusicApi.personalFm()
: await MusicApi.personalFmMode({
mode: fmMode.value,
subMode: fmSubMode.value,
limit: 3,
});
const data = JSON.parse(jsonStr);
const raw = data.data || data;
if (!Array.isArray(raw) || raw.length === 0) return [];
return raw.map((s: any) => normalizeSong(s));
}
let fmVipSkipCount = 0;
const MAX_FM_VIP_SKIP = 10;
async function playFmSong(song: Song) {
const seq = ++_playSeq;
_switchingSong = true;
clearTick();
reportScrobble();
if (!song.dt || song.dt === 0) {
try {
const jsonStr: string = await invoke('get_song_detail', { id: Number(song.id) });
const jsonStr = await MusicApi.getSongDetail(String(song.id));
const data = JSON.parse(jsonStr);
const full = data.songs?.[0];
if (full) {
@@ -70,36 +166,112 @@ export const usePlayerStore = defineStore('player', () => {
song.al = full.al || song.al;
song.ar = full.ar || song.ar;
}
} catch (e) { /* 忽略 */ }
} catch { /* 忽略 */ }
}
await invoke('stop_audio');
await AudioApi.stopAudio();
queue.value = [];
currentIndex.value = -1;
playing.value = false;
fmSong.value = song;
currentSong.value = song;
try {
const url: string = await invoke('get_song_url', { id: Number(song.id) });
if (!url) throw new Error('无播放源');
await invoke('play_audio', { url });
playing.value = true;
duration.value = (song.dt || 0) / 1000;
currentTime.value = 0;
startTick();
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality, fm_mode: true });
if (seq !== _playSeq) return;
const data = JSON.parse(jsonStr);
const url: string | undefined = data.url;
if (!url) {
const fee = data.fee;
if (fee === 4) {
showToast(`${song.name} 为数字专辑,已跳过`, 'info');
} else if (fee === 1) {
showToast(`${song.name} 为 VIP 专属歌曲,已跳过`, 'info');
} else {
showToast(`${song.name} 暂无播放源`, 'info');
}
fmVipSkipCount++;
if (fmVipSkipCount >= MAX_FM_VIP_SKIP) {
fmVipSkipCount = 0;
disableFmMode();
return;
}
_switchingSong = false;
if (fmNextCallback) {
fmNextCallback();
} else {
disableFmMode();
}
return;
}
if (data.freeTrialInfo) {
console.warn('FM VIP 试听歌曲,自动跳过', song.name);
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
fmVipSkipCount++;
if (fmVipSkipCount >= MAX_FM_VIP_SKIP) {
console.warn('FM 连续跳过 VIP 歌曲过多,停止');
fmVipSkipCount = 0;
disableFmMode();
return;
}
_switchingSong = false;
if (fmNextCallback) {
fmNextCallback();
} else {
disableFmMode();
}
return;
}
fmVipSkipCount = 0;
await AudioApi.playAudio(url);
if (seq !== _playSeq) return;
const started = await waitForPlaybackStart();
if (seq !== _playSeq) return;
if (started) {
playing.value = true;
duration.value = (song.dt || 0) / 1000;
currentTime.value = 0;
startTick();
addRecent(song);
emitPlaybackState();
} else {
playing.value = false;
showToast('FM 播放启动超时,仍在尝试加载…', 'info');
watchForLatePlayback(seq, song);
}
} catch (e) {
if (seq !== _playSeq) return;
console.error('FM播放失败', e);
playing.value = false;
showToast('FM 播放失败', 'error');
if (fmNextCallback) {
fmNextCallback();
} else {
disableFmMode();
}
} finally {
if (seq === _playSeq) {
_switchingSong = false;
}
}
}
// 播放指定歌曲(如果不在队列中则加入并切换)
async function play(song: Song) {
disableFmMode();
const idx = queue.value.findIndex(s => s.id === song.id);
if (idx !== -1 && idx === currentIndex.value && currentSong.value?.id === song.id) {
if (!playing.value) {
await AudioApi.resumeAudio();
playing.value = true;
startTick();
}
return;
}
if (idx === -1) {
// 未在队列中,添加到队列并播放该位置
queue.value.push(song);
currentIndex.value = queue.value.length - 1;
} else {
@@ -108,81 +280,283 @@ export const usePlayerStore = defineStore('player', () => {
await playCurrent();
}
async function playFromList(songs: Song[], startIndex: number, source?: { type: 'playlist' | 'album' | 'songs'; id?: 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.pauseAudio();
playing.value = false;
clearTick();
} else {
await AudioApi.resumeAudio();
playing.value = true;
startTick();
}
return;
}
}
queue.value = [...songs];
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
currentSource.value = { type: source?.type ?? null, id: source?.id ?? null };
await playCurrent();
}
let vipSkipCount = 0;
const MAX_VIP_SKIP = 10;
let _playSeq = 0;
let _switchingSong = false;
async function waitForPlaybackStart(timeoutMs: number = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 100));
try {
if (await AudioApi.isAudioPlaying()) return true;
} catch { /* 忽略 */ }
}
try {
return await AudioApi.isAudioPlaying();
} catch { return false; }
}
async function playCurrent() {
const seq = ++_playSeq;
_switchingSong = true;
clearTick();
reportScrobble();
const song = queue.value[currentIndex.value];
if (!song?.id) {
console.error('无效的歌曲数据', song);
_switchingSong = false;
return;
}
try {
// 重置状态
currentSong.value = song;
playing.value = false;
currentTime.value = 0;
duration.value = (song.dt || 0) / 1000;
// 获取 URL 并播放
const url: string = await invoke('get_song_url', { id: Number(song.id) });
if (!url) {
console.error('未获取到有效播放地址', song);
if (song.localPath) {
await AudioApi.playLocalAudio(song.localPath);
if (seq !== _playSeq) return;
const started = await waitForPlaybackStart();
if (seq !== _playSeq) return;
if (started) {
playing.value = true;
startTick();
addRecent(song);
emitPlaybackState();
} else {
showToast('播放启动超时,仍在尝试加载…', 'info');
watchForLatePlayback(seq, song);
}
return;
}
await invoke('play_audio', { url });
playing.value = true;
startTick();
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality });
if (seq !== _playSeq) return;
const data = JSON.parse(jsonStr);
const url: string | undefined = data.url;
if (!url) {
// url 为空:可能是数字专辑/付费歌曲,根据 fee 字段判断
const fee = data.fee;
if (fee === 4) {
showToast(`${song.name} 为数字专辑,需购买后播放`, 'info');
} else if (fee === 1) {
showToast(`${song.name} 为 VIP 专属歌曲`, 'info');
} else {
showToast(`${song.name} 暂无播放源`, 'info');
}
vipSkipCount++;
if (vipSkipCount >= MAX_VIP_SKIP) {
vipSkipCount = 0;
return;
}
_switchingSong = false;
next();
return;
}
if (data.freeTrialInfo) {
console.warn('VIP 试听歌曲,自动跳过', song.name);
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
vipSkipCount++;
if (vipSkipCount >= MAX_VIP_SKIP) {
console.warn('连续跳过 VIP 歌曲过多,停止跳过');
vipSkipCount = 0;
return;
}
_switchingSong = false;
next();
return;
}
await AudioApi.playAudio(url);
if (seq !== _playSeq) return;
const started = await waitForPlaybackStart();
if (seq !== _playSeq) return;
if (started) {
playing.value = true;
startTick();
addRecent(song);
vipSkipCount = 0;
emitPlaybackState();
} else {
playing.value = false;
showToast('播放启动超时,仍在尝试加载…', 'info');
watchForLatePlayback(seq, song);
}
} catch (e) {
if (seq !== _playSeq) return;
console.error('播放失败', e);
playing.value = false;
showToast('播放失败,请稍后重试', 'error');
} finally {
if (seq === _playSeq) {
_switchingSong = false;
}
}
}
/// 超时后继续监听后端播放状态,如果后端实际开始播放则恢复状态
function watchForLatePlayback(seq: number, song: Song) {
let attempts = 0;
const maxAttempts = 15;
const check = async () => {
if (seq !== _playSeq) return;
if (playing.value) return;
attempts++;
if (attempts > maxAttempts) return;
try {
const backendPlaying = await AudioApi.isAudioPlaying();
if (seq !== _playSeq) return;
if (backendPlaying) {
playing.value = true;
startTick();
addRecent(song);
vipSkipCount = 0;
emitPlaybackState();
return;
}
} catch { /* 忽略 */ }
if (seq === _playSeq && !playing.value) {
setTimeout(check, 1000);
}
};
setTimeout(check, 1000);
}
let onSeekStart: (() => void) | null = null;
function startTick() {
if (tickInterval) clearInterval(tickInterval);
tickInterval = setInterval(() => {
clearTick();
let seekGuard = false;
onSeekStart = () => { seekGuard = true; };
let syncCounter = 1;
let lastSyncPos = -1;
let backendFrozen = false;
let stateSyncCounter = 0;
setTick(setInterval(async () => {
if (playing.value && duration.value > 0) {
currentTime.value += 0.25;
if (currentTime.value >= duration.value) {
if (seekGuard) return;
syncCounter++;
stateSyncCounter++;
if (syncCounter >= 2) {
syncCounter = 0;
try {
const pos = await AudioApi.getAudioPosition();
if (pos >= currentTime.value - 0.5) {
currentTime.value = pos;
}
if (lastSyncPos < 0) {
lastSyncPos = pos;
} else if (pos <= lastSyncPos + 0.05) {
backendFrozen = true;
lastSyncPos = pos;
} else {
backendFrozen = false;
lastSyncPos = pos;
}
} catch { /* 忽略 */ }
if (stateSyncCounter >= 4) {
stateSyncCounter = 0;
try {
const backendPlaying = await AudioApi.isAudioPlaying();
if (backendPlaying !== playing.value) {
playing.value = backendPlaying;
}
} catch { /* 忽略 */ }
}
} else {
if (!backendFrozen) {
const next = currentTime.value + 0.5;
if (next <= duration.value) {
currentTime.value = next;
}
}
}
if (currentTime.value > duration.value) {
currentTime.value = duration.value;
next(); // 自动下一首
}
}
}, 250);
}, 500));
}
async function toggle() {
if (_switchingSong) return;
try {
const backendPlaying = await AudioApi.isAudioPlaying();
if (backendPlaying !== playing.value) {
playing.value = backendPlaying;
}
} catch { /* 忽略查询失败 */ }
if (playing.value) {
await invoke('pause_audio');
await AudioApi.pauseAudio();
playing.value = false;
} else {
await invoke('resume_audio');
await AudioApi.resumeAudio();
playing.value = true;
}
emitPlaybackState();
}
async function stop() {
await invoke('stop_audio');
await AudioApi.stopAudio();
playing.value = false;
currentSong.value = null;
currentTime.value = 0;
if (tickInterval) clearInterval(tickInterval);
disableFmMode(); // 停止时退出漫游
clearTick();
disableFmMode();
emitPlaybackState();
}
function prev() {
if (isFmMode.value) return;
if (_switchingSong) return;
if (queue.value.length === 0) return;
currentIndex.value = (currentIndex.value - 1 + queue.value.length) % queue.value.length;
playCurrent();
}
// 批量添加歌曲到队列并播放第一首(用于“播放全部”)
async function playAll(songs: Song[]) {
async function playAll(songs: Song[], source?: { type: 'playlist' | 'album' | 'songs'; id?: number }) {
if (songs.length === 0) return;
queue.value = [...songs];
currentIndex.value = 0;
currentSource.value = { type: source?.type ?? null, id: source?.id ?? null };
await playCurrent();
}
@@ -190,22 +564,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;
}
@@ -219,38 +588,46 @@ export const usePlayerStore = defineStore('player', () => {
}
async function seek(time: number) {
if (_switchingSong) return;
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();
return;
}
if (_switchingSong) return;
if (queue.value.length === 0) return;
let nextIndex: number;
switch (playMode.value) {
case 'repeat-one':
// 单曲循环,不改变索引,只重新播放当前
playCurrent();
return;
case 'shuffle':
// 随机下一首,且不与当前重复(除非只剩一首)
if (queue.value.length === 1) {
nextIndex = 0;
} else {
@@ -261,7 +638,6 @@ export const usePlayerStore = defineStore('player', () => {
break;
case 'loop':
default:
// 顺序循环
nextIndex = (currentIndex.value + 1) % queue.value.length;
break;
}
@@ -269,70 +645,57 @@ export const usePlayerStore = defineStore('player', () => {
playCurrent();
}
const showRoamDrawer = ref(false);
function openRoamDrawer() {
showRoamDrawer.value = true;
}
function closeRoamDrawer() {
showRoamDrawer.value = false;
}
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);
console.error('FM 加载下一首失败', e);
showToast('FM 加载失败', 'error');
}
return false;
}
// -------- FM 专属状态 --------
const fmSong = ref<any>(null);
const fmSong = ref<Song | null>(null);
const fmPlaying = ref(false);
async function loadFm() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
fmSong.value = song;
enableFmMode(nextFm); // 设置下一首回调为 store 内的 nextFm
await playFmSong(song); // 使用 FM 专用播放方法
fmPlaying.value = true;
// showRoamDrawer.value = true; // 自动打开全屏抽屉
if (fmQueue.length === 0) {
const batch = await fetchFmBatch();
if (batch.length === 0) return;
fmQueue.push(...batch);
}
const song = fmQueue.shift()!;
fmSong.value = song;
enableFmMode(nextFm);
await playFmSong(song);
fmPlaying.value = true;
if (fmQueue.length <= 1) {
fetchFmBatch().then(batch => { fmQueue.push(...batch); }).catch(() => {});
}
} catch (e) {
console.error('FM加载失败', e);
showToast('FM 加载失败', 'error');
}
}
async function toggleFm() {
if (!fmSong.value) return;
if (fmPlaying.value) {
// 当前 FM 正在播放,切换暂停/恢复
await toggle(); // 全局暂停/播放
await toggle();
fmPlaying.value = playing.value;
} else {
// FM 处于暂停状态,或者当前被其他歌曲打断
if (currentSong.value?.id === fmSong.value.id) {
// FM 歌曲还是当前歌曲,直接恢复
await toggle();
fmPlaying.value = playing.value;
} else {
// 当前播放的是其他歌曲,重新以 FM 模式播放 FM 歌曲
enableFmMode(nextFm);
await playFmSong(fmSong.value);
fmPlaying.value = true;
@@ -341,20 +704,66 @@ async function toggleFm() {
}
async function nextFm() {
await loadFm(); // 加载下一首 FM 歌曲
await loadFm();
}
// 监听全局播放变化,若用户选择了非 FM 歌曲,自动退出 FM 状态
listen('audio-ended', () => {
if (_switchingSong) return;
const player = usePlayerStore();
player.clearTick();
player.reportScrobble();
player.next();
});
listen<string>('mpris-command', (event) => {
const cmd = event.payload;
const player = usePlayerStore();
if (cmd === 'Next') {
player.next();
} else if (cmd === 'Previous') {
player.prev();
} else if (cmd === 'PlayPause') {
player.toggle();
} else if (cmd === 'Play') {
if (!player.playing) player.toggle();
} else if (cmd === 'Pause') {
if (player.playing) player.toggle();
} else if (cmd === 'Stop') {
player.stop();
} else if (cmd.startsWith('SetVolume:')) {
const vol = parseFloat(cmd.slice(10));
if (!isNaN(vol)) {
player.volume = Math.round(vol * 100);
AudioApi.setVolume(vol).catch(() => {});
}
} else if (cmd.startsWith('Seek:')) {
const offsetUs = parseInt(cmd.slice(5), 10);
const offsetSec = offsetUs / 1_000_000;
const newPos = Math.max(0, Math.min(player.currentTime + offsetSec, player.duration));
player.seek(newPos);
} else if (cmd.startsWith('SetPosition:')) {
const posUs = parseInt(cmd.slice(13), 10);
const posSec = posUs / 1_000_000;
if (posSec < 1 && player.currentTime > 5) {
return;
}
player.seek(posSec);
} else if (cmd === 'Raise') {
getCurrentWindow().show().catch(() => {});
getCurrentWindow().setFocus().catch(() => {});
} else if (cmd === 'Quit') {
getCurrentWindow().close().catch(() => {});
}
});
watch(currentSong, (newSong) => {
if (isFmMode.value && newSong?.id !== fmSong.value?.id) {
fmPlaying.value = false;
// 注意:不调用 disableFmMode,因为可能只是临时切歌,但卡片需要知道 FM 已停止
disableFmMode(); // 退出 FM 模式,让上一首按钮恢复
disableFmMode();
}
});
watch(playing, (val) => {
// 只有当前正在播放的是 FM 歌曲时,才同步 fmPlaying
if (currentSong.value?.id === fmSong.value?.id) {
fmPlaying.value = val;
} else {
@@ -370,6 +779,7 @@ watch(playing, (val) => {
duration,
queue,
currentIndex,
currentSource,
playMode,
isFmMode,
enableFmMode,
@@ -377,6 +787,7 @@ watch(playing, (val) => {
playFmSong,
setPlayMode,
play,
playFromList,
playAll,
toggle,
stop,
@@ -384,19 +795,36 @@ watch(playing, (val) => {
next,
seek,
playCurrent,
volume,
adjustVolume,
removeFromQueue,
clearQueue,
showRoamDrawer,
openRoamDrawer,
closeRoamDrawer,
// 喜欢列表(委托给 liked store
get likedIds() { return likedStore.likedIds; },
isLiked: likedStore.isLiked,
loadLikedIds: likedStore.loadLikedIds,
toggleLike: likedStore.toggleLike,
// 最近播放(委托给 recent store
get recentLocal() { return recentStore.recentLocal; },
addRecent,
loadFirstFmSong,
fetchFmBatch,
clearFmQueue,
fmTrash,
reportScrobble,
clearTick,
fmSong,
fmPlaying,
fmMode,
fmSubMode,
loadFm,
toggleFm,
nextFm,
};
});
});
+47
View File
@@ -0,0 +1,47 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import type { Song } from '../types/song';
const RECENT_KEY = 'recent_local';
const MAX_RECENT = 200;
function loadRecentLocal(): Song[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (raw) return JSON.parse(raw);
} catch { /* 忽略 */ }
return [];
}
/**
* store
*
* localStorage
*/
export const useRecentStore = defineStore('recent', () => {
/** 最近播放的歌曲列表(按时间倒序) */
const recentLocal = ref<Song[]>(loadRecentLocal());
let persistTimer: ReturnType<typeof setTimeout> | undefined;
/** 添加一首歌到最近播放列表(去重并放到最前) */
function addRecent(song: Song) {
recentLocal.value = recentLocal.value.filter(s => s.id !== song.id);
recentLocal.value.unshift(song);
if (recentLocal.value.length > MAX_RECENT) {
recentLocal.value = recentLocal.value.slice(0, MAX_RECENT);
}
}
watch(recentLocal, (val) => {
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
localStorage.setItem(RECENT_KEY, JSON.stringify(val));
}, 2000);
}, { deep: true });
return {
recentLocal,
addRecent,
};
});
+313
View File
@@ -0,0 +1,313 @@
import { defineStore } from 'pinia';
import { ref, watch, computed } from 'vue';
import { getPresetSkin, isPresetSkinId, applySkinColors, type SkinColors } from '../skins';
import { migrateSettings, CURRENT_SCHEMA_VERSION } from './migrations/settingsMigrations';
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
export type CloseAction = 'ask' | 'minimize' | 'exit';
export const qualityLabels: Record<AudioQuality, string> = {
standard: '标准',
higher: '较高',
exhigh: '极高 (HQ)',
lossless: '无损 (SQ)',
hires: 'Hi-Res',
};
export const closeActionLabels: Record<CloseAction, string> = {
ask: '每次询问',
minimize: '最小化到托盘',
exit: '直接退出',
};
export interface ShortcutBinding {
key: string;
label: string;
}
export const defaultShortcuts: Record<string, ShortcutBinding> = {
playPause: { key: 'Control+KeyP', label: '播放/暂停' },
prev: { key: 'Control+ArrowLeft', label: '上一首' },
next: { key: 'Control+ArrowRight', label: '下一首' },
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
globalPlayPause: { key: 'Control+Alt+KeyP', label: '播放/暂停(全局)' },
globalPrev: { key: 'Control+Alt+ArrowLeft', label: '上一首(全局)' },
globalNext: { key: 'Control+Alt+ArrowRight', label: '下一首(全局)' },
globalVolUp: { key: 'Control+Alt+ArrowUp', label: '音量增加(全局)' },
globalVolDown: { key: 'Control+Alt+ArrowDown', label: '音量减小(全局)' },
};
export interface CustomSkin {
id: string;
name: string;
preview: string;
colors: SkinColors;
/** 壁纸图片路径,为空则使用纯色背景 */
wallpaper: string;
/** 壁纸模糊度 0-30 */
wallpaperBlur: number;
/** 壁纸透明度 0-1 */
wallpaperOpacity: number;
}
export interface MusicFolder {
path: string;
enabled: boolean;
}
interface SettingsData {
audioQuality: AudioQuality;
downloadPath: string;
localMusicFolders: MusicFolder[];
skin: string; // 预设皮肤 id 或 custom-xxx
customSkins: CustomSkin[];
closeAction: CloseAction;
shortcuts: Record<string, ShortcutBinding>;
outputDevice: string | null;
volume: number;
schemaVersion: number;
}
function loadSettings(): SettingsData {
const defaults: SettingsData = {
audioQuality: 'standard',
downloadPath: '',
localMusicFolders: [],
skin: 'dark-blue',
customSkins: [],
closeAction: 'ask',
shortcuts: { ...defaultShortcuts },
outputDevice: null,
volume: 100,
schemaVersion: CURRENT_SCHEMA_VERSION,
};
try {
const raw = localStorage.getItem('app_settings');
if (!raw) return defaults;
const parsed = JSON.parse(raw);
// 执行迁移(theme→skin、wallpaper→customSkin、localMusicPaths→localMusicFolders
const migrated = migrateSettings(parsed);
return {
audioQuality: migrated.audioQuality || defaults.audioQuality,
downloadPath: migrated.downloadPath || defaults.downloadPath,
localMusicFolders: Array.isArray(migrated.localMusicFolders) ? migrated.localMusicFolders : defaults.localMusicFolders,
skin: migrated.skin || defaults.skin,
customSkins: Array.isArray(migrated.customSkins) ? migrated.customSkins : defaults.customSkins,
closeAction: migrated.closeAction || defaults.closeAction,
shortcuts: { ...defaultShortcuts, ...(migrated.shortcuts || {}) },
outputDevice: migrated.outputDevice ?? null,
volume: typeof migrated.volume === 'number' ? migrated.volume : defaults.volume,
schemaVersion: CURRENT_SCHEMA_VERSION,
};
} catch { /* 忽略 */ }
return defaults;
}
export const useSettingsStore = defineStore('settings', () => {
const saved = loadSettings();
const audioQuality = ref<AudioQuality>(saved.audioQuality);
const downloadPath = ref<string>(saved.downloadPath);
const localMusicFolders = ref<MusicFolder[]>(saved.localMusicFolders);
const skin = ref<string>(saved.skin);
const customSkins = ref<CustomSkin[]>(saved.customSkins);
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 isPreset = computed(() => isPresetSkinId(skin.value));
/** 获取当前自定义皮肤 */
const currentCustomSkin = computed(() => {
if (isPreset.value) return null;
return customSkins.value.find(s => s.id === skin.value) || null;
});
/** 获取当前皮肤的预览色 */
const skinPreview = computed(() => {
if (isPreset.value) {
return getPresetSkin(skin.value)?.preview || '#3b82f6';
}
return currentCustomSkin.value?.preview || '#3b82f6';
});
/** 获取当前皮肤的完整颜色集(响应式) */
const currentColors = computed<SkinColors>(() => {
if (isPreset.value) {
return getPresetSkin(skin.value)!.colors;
}
const custom = currentCustomSkin.value;
if (!custom) {
return getPresetSkin('dark-blue')!.colors;
}
return custom.colors;
});
/** 获取当前皮肤的壁纸信息 */
const currentWallpaper = computed(() => {
if (isPreset.value) return { path: '', blur: 10, opacity: 0.3 };
const custom = currentCustomSkin.value;
return {
path: custom?.wallpaper || '',
blur: custom?.wallpaperBlur ?? 10,
opacity: custom?.wallpaperOpacity ?? 0.3,
};
});
function setSkin(id: string) {
skin.value = id;
}
function addCustomSkin(s: CustomSkin) {
customSkins.value = [...customSkins.value, s];
skin.value = s.id;
}
function updateCustomSkin(id: string, updates: Partial<CustomSkin>) {
customSkins.value = customSkins.value.map(s =>
s.id === id ? { ...s, ...updates } : s
);
// 如果正在使用该皮肤,立即刷新 CSS 变量
if (skin.value === id) {
applySkin();
}
}
function removeCustomSkin(id: string) {
customSkins.value = customSkins.value.filter(s => s.id !== id);
if (skin.value === id) {
skin.value = 'dark-blue';
}
}
/** 应用当前皮肤到 DOM(统一通过 JS 设置 CSS 变量) */
function applySkin() {
let colors: SkinColors;
if (isPreset.value) {
const preset = getPresetSkin(skin.value);
colors = preset!.colors;
} else {
const custom = currentCustomSkin.value;
if (!custom) {
// 找不到自定义皮肤,回退到默认
skin.value = 'dark-blue';
colors = getPresetSkin('dark-blue')!.colors;
} else {
colors = custom.colors;
}
}
applySkinColors(colors);
}
function setAudioQuality(q: AudioQuality) {
audioQuality.value = q;
}
function setDownloadPath(p: string) {
downloadPath.value = p;
}
function addLocalMusicPath(p: string) {
if (!localMusicFolders.value.some(f => f.path === p)) {
localMusicFolders.value = [...localMusicFolders.value, { path: p, enabled: true }];
}
}
function removeLocalMusicPath(p: string) {
localMusicFolders.value = localMusicFolders.value.filter(f => f.path !== p);
}
function toggleLocalMusicFolder(p: string) {
localMusicFolders.value = localMusicFolders.value.map(f =>
f.path === p ? { ...f, enabled: !f.enabled } : f
);
}
/** 已启用的扫描路径 */
const enabledMusicPaths = computed(() =>
localMusicFolders.value.filter(f => f.enabled).map(f => f.path)
);
function setCloseAction(a: CloseAction) {
closeAction.value = a;
}
function setShortcut(id: string, key: string) {
shortcuts.value = { ...shortcuts.value, [id]: { ...shortcuts.value[id], key } };
}
function resetShortcuts() {
shortcuts.value = { ...defaultShortcuts };
}
function setOutputDevice(device: string | null) {
outputDevice.value = device;
}
function resetAll() {
audioQuality.value = 'standard';
downloadPath.value = '';
localMusicFolders.value = [];
skin.value = 'dark-blue';
customSkins.value = [];
closeAction.value = 'ask';
shortcuts.value = { ...defaultShortcuts };
outputDevice.value = null;
volume.value = 100;
}
watch([audioQuality, downloadPath, localMusicFolders, skin, customSkins, closeAction, shortcuts, outputDevice, volume], () => {
const data: SettingsData = {
audioQuality: audioQuality.value,
downloadPath: downloadPath.value,
localMusicFolders: localMusicFolders.value,
skin: skin.value,
customSkins: customSkins.value,
closeAction: closeAction.value,
shortcuts: shortcuts.value,
outputDevice: outputDevice.value,
volume: volume.value,
schemaVersion: CURRENT_SCHEMA_VERSION,
};
localStorage.setItem('app_settings', JSON.stringify(data));
}, { deep: true });
return {
audioQuality,
downloadPath,
localMusicFolders,
enabledMusicPaths,
skin,
customSkins,
isPreset,
currentCustomSkin,
currentColors,
skinPreview,
currentWallpaper,
closeAction,
shortcuts,
outputDevice,
volume,
setSkin,
addCustomSkin,
updateCustomSkin,
removeCustomSkin,
applySkin,
setAudioQuality,
setDownloadPath,
addLocalMusicPath,
removeLocalMusicPath,
toggleLocalMusicFolder,
setCloseAction,
setOutputDevice,
setShortcut,
resetShortcuts,
resetAll,
};
});
+103
View File
@@ -0,0 +1,103 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
/**
*
* - expanded224px+
* - collapsed64px
* - drawer
*/
export type SidebarMode = 'expanded' | 'collapsed' | 'drawer';
/**
* UI store
*
* Roam // UI
*/
export const useUiStore = defineStore('ui', () => {
/** Roam 抽屉是否展开 */
const showRoamDrawer = ref(false);
/** 当前 Roam 抽屉显示的标签页 */
const roamTab = ref<'lyric' | 'comment'>('lyric');
/** Roam 抽屉评论区对应的歌曲 IDnull 表示使用 currentSong.id */
const commentSongId = ref<number | null>(null);
/** 当前歌曲封面提取的主色调(用于沉浸式背景) */
const dominantColor = ref('');
/** 侧边栏模式(持久化到 localStorage */
const sidebarMode = ref<SidebarMode>(loadSidebarMode());
/** 抽屉模式下侧边栏是否展开 */
const drawerOpen = ref(false);
function loadSidebarMode(): SidebarMode {
const saved = localStorage.getItem('sidebar_mode');
if (saved === 'expanded' || saved === 'collapsed') return saved;
return 'expanded';
}
function setSidebarMode(mode: SidebarMode) {
sidebarMode.value = mode;
if (mode !== 'drawer') {
localStorage.setItem('sidebar_mode', mode);
}
}
/** 切换展开/折叠(不影响 drawer 模式) */
function toggleSidebar() {
if (sidebarMode.value === 'expanded') {
setSidebarMode('collapsed');
} else if (sidebarMode.value === 'collapsed') {
setSidebarMode('expanded');
}
}
/** 打开/关闭抽屉(仅 drawer 模式生效) */
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value;
}
/** 关闭抽屉 */
function closeDrawer() {
drawerOpen.value = false;
}
/** 打开 Roam 抽屉,可指定初始标签页 */
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
roamTab.value = tab;
showRoamDrawer.value = true;
}
/** 打开 Roam 抽屉并定位到指定歌曲的评论区 */
function openCommentForSong(songId: number) {
commentSongId.value = songId;
openRoamDrawer('comment');
}
/** 关闭 Roam 抽屉并重置标签页 */
function closeRoamDrawer() {
showRoamDrawer.value = false;
roamTab.value = 'lyric';
}
/** 切换 Roam 抽屉展开状态 */
function toggleRoamDrawer() {
showRoamDrawer.value = !showRoamDrawer.value;
}
return {
showRoamDrawer,
roamTab,
commentSongId,
dominantColor,
sidebarMode,
drawerOpen,
setSidebarMode,
toggleSidebar,
toggleDrawer,
closeDrawer,
openRoamDrawer,
openCommentForSong,
closeRoamDrawer,
toggleRoamDrawer,
};
});
+2 -2
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');
+138 -12
View File
@@ -1,14 +1,52 @@
@import "tailwindcss";
@theme {
--color-base: var(--c-bg);
--color-surface: var(--c-surface);
--color-subtle: var(--c-subtle);
--color-muted: var(--c-muted);
--color-emphasis: var(--c-emphasis);
--color-content: var(--c-content);
--color-content-2: var(--c-content-2);
--color-content-3: var(--c-content-3);
--color-content-4: var(--c-content-4);
--color-line: var(--c-line);
--color-line-2: var(--c-line-2);
--color-accent: var(--c-accent);
--color-accent-hover: var(--c-accent-hover);
--color-accent-text: var(--c-accent-text);
--color-accent-dim: var(--c-accent-dim);
--color-danger: var(--c-danger);
--color-danger-dim: var(--c-danger-dim);
--color-warning: var(--c-warning);
--color-info: var(--c-info);
}
@layer base {
/* 默认值(首次加载 fallback,JS 会立即覆盖) */
:root {
--color-surface: 255 255 255;
--color-primary: 34 197 94;
--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;
}
/* 确保 html 也应用暗色背景,防止空白区域 */
html {
background: #0f172a;
overflow: hidden;
height: 100%;
overscroll-behavior: none;
@@ -17,17 +55,25 @@
body {
@apply antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
/* 关键:锁住 body,彻底消除整体拖动 */
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;
}
/* 滚动条hover 容器显示主题色 thumb--c-muted / --c-emphasis默认透明
- 颜色跟随主题变量皮肤切换自动适配
- 宽度 5px不显眼hover 容器才显示hover thumb 变深
- 彻底无滚动条的容器歌词界面 .custom-scroll [data-no-scrollbar] */
::-webkit-scrollbar {
width: 5px;
height: 5px;
@@ -36,10 +82,90 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
background-color: transparent;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
*:hover > ::-webkit-scrollbar-thumb,
*:hover::-webkit-scrollbar-thumb {
background-color: var(--c-muted);
}
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--c-emphasis);
}
* {
scrollbar-width: thin;
scrollbar-color: var(--c-muted) transparent;
}
/* 彻底隐藏滚动条的容器(歌词界面等) */
.custom-scroll,
[data-no-scrollbar] {
scrollbar-width: none !important;
}
.custom-scroll::-webkit-scrollbar,
[data-no-scrollbar]::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
cursor: pointer;
}
select:focus {
border-color: var(--c-accent);
box-shadow: 0 0 0 2px var(--c-accent-dim);
}
select option {
background: var(--c-surface);
color: var(--c-content);
padding: 8px;
}
input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
border: 2px solid var(--c-emphasis);
border-radius: 4px;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.15s ease;
flex-shrink: 0;
}
input[type="checkbox"]:hover {
border-color: var(--c-accent);
}
input[type="checkbox"]:checked {
background: var(--c-accent);
border-color: var(--c-accent);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
/* 骨架屏脉冲动画:三个详情页(Playlist/Album/Artist)共用 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
+33
View File
@@ -0,0 +1,33 @@
/**
*
*/
/**
*
*/
export interface Song {
/** 歌曲 ID */
id: number;
/** 歌曲名称 */
name: string;
/** 歌手列表 */
ar: { id?: number; name: string }[];
/** 专辑信息 */
al: { id?: number; picUrl: string; name?: string };
/** 时长(毫秒) */
dt?: number;
/** 本地文件路径(本地歌曲专用) */
localPath?: string;
/** 推荐算法标识(私人 FM 推荐) */
alg?: string;
/** 音频码率 */
br?: number;
}
/**
*
* - loop: 列表循环
* - shuffle: 随机播放
* - repeat-one: 单曲循环
*/
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
+32
View File
@@ -0,0 +1,32 @@
/**
* hex rgba
*/
export function hexToRgba(hex: string, alpha: number): string {
const rgbMatch = hex.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`;
}
let h = hex.replace('#', '');
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* hex rgba hex input[type=color] 使
*/
export function toHex(color: string): string {
if (color.startsWith('#')) {
return color.length === 4
? '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]
: color;
}
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return '#000000';
const r = parseInt(m[1]).toString(16).padStart(2, '0');
const g = parseInt(m[2]).toString(16).padStart(2, '0');
const b = parseInt(m[3]).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
}
+15
View File
@@ -0,0 +1,15 @@
import { nextTick, type Ref } from 'vue';
// 检查描述元素是否溢出(用于显示"查看完整介绍"按钮)
// 三个详情页(PlaylistDetail / AlbumDetail / ArtistDetail)共用
export function checkOverflow(
elRef: Ref<HTMLElement | null>,
overflowRef: Ref<boolean>,
tolerance = 2,
) {
nextTick(() => {
if (elRef.value) {
overflowRef.value = elRef.value.scrollHeight > elRef.value.clientHeight + tolerance;
}
});
}
+29
View File
@@ -0,0 +1,29 @@
export function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
const YI = 100_000_000;
const WAN = 10_000;
export function formatPlayCount(count: number): string {
if (!count) return '0';
if (count >= YI) return (count / YI).toFixed(1) + '亿';
if (count >= WAN) return (count / WAN).toFixed(1) + '万';
return count.toString();
}
export function formatDate(ts: number): string {
if (!ts) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
+30 -3
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;
}
}
+89 -17
View File
@@ -1,18 +1,90 @@
/**
* al.picUrlardt
*/
export function normalizeSong(song: any) {
const normalized = { ...song };
// 封面 / 艺术家兼容
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
export type { Song } from '../types/song';
import type { Song } from '../types/song';
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 rawAr = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []);
// 过滤掉 id 和 name 同时不存在的歌手(下线艺人等)
const ar = rawAr.filter((a: any) => a.name);
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,
alg: song.alg || undefined,
br: song.br || undefined,
};
}
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;
}
export function getArtistDisplay(song: Song): string {
if (!song.ar || song.ar.length === 0) return '未知歌手';
const names = song.ar
.filter(a => a.id != null && a.name)
.map(a => a.name);
return names.length > 0 ? names.join(' / ') : '未知歌手';
}
const colorCache = new Map<string, string>();
const MAX_COLOR_CACHE = 200;
export function extractDominantColor(imageUrl: string): Promise<string> {
if (colorCache.has(imageUrl)) {
return Promise.resolve(colorCache.get(imageUrl)!);
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
// 时长:只保留合理的 dt(100ms ~ 2小时),否则置 0
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
normalized.dt = 0;
}
return normalized;
}
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const size = 8;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve(''); return; }
ctx.drawImage(img, 0, 0, size, size);
const data = ctx.getImageData(0, 0, size, size).data;
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
r = Math.round(r / count);
g = Math.round(g / count);
b = Math.round(b / count);
const color = `rgb(${r}, ${g}, ${b})`;
if (colorCache.size >= MAX_COLOR_CACHE) {
const firstKey = colorCache.keys().next().value;
if (firstKey !== undefined) colorCache.delete(firstKey);
}
colorCache.set(imageUrl, color);
resolve(color);
} catch {
resolve('');
}
};
img.onerror = () => resolve('');
img.src = imageUrl;
});
}
+664
View File
@@ -0,0 +1,664 @@
<template>
<DetailLayout ref="layoutRef">
<!-- 头部常驻滚动时收缩为紧凑态
参考 SPlayer ListDetail.vue .small 触发 height/font-size/opacity 过渡 -->
<template #header="{ compact }">
<div class="album-header" :class="{ 'is-compact': compact }">
<!-- 头部骨架 -->
<div v-if="!album && albumLoading" class="header-row">
<div class="cover-wrap">
<div class="cover-img cover-skeleton"></div>
</div>
<div class="info-col">
<div class="h-8 bg-muted rounded w-1/2 animate-pulse"></div>
<div class="info-collapse">
<div class="h-4 bg-muted rounded w-1/3 animate-pulse mt-3"></div>
<div class="h-3 bg-muted rounded w-1/4 animate-pulse mt-2"></div>
</div>
<div class="actions-row">
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
</div>
</div>
</div>
<!-- 头部信息 -->
<div v-else-if="album" class="header-row">
<!-- 封面 + 阴影背板 + 顶部渐变遮罩参考 SPlayer .cover + .cover-shadow + .cover-mask
封面图加载完成后淡入opacity 01避免空白闪烁 -->
<div class="cover-wrap">
<img :src="album.picUrl" class="cover-shadow" alt="" aria-hidden="true" />
<img
:src="album.picUrl"
class="cover-img"
:class="{ 'is-loaded': coverLoaded }"
:alt="album.name"
@load="coverLoaded = true"
/>
<!-- 顶部渐变遮罩紧凑时淡出参考 SPlayer .cover-mask -->
<div class="cover-mask"></div>
</div>
<!-- 信息列标题常驻+ 元信息紧凑时折叠+ 操作行常驻底部
操作行内左侧播放按钮右侧模糊搜索 + 歌曲/评论 tab参考 SPlayer .menu .left/.right -->
<div class="info-col">
<div class="info-top min-w-0">
<h1 class="album-title font-bold leading-tight">{{ album.name }}</h1>
<!-- 元信息紧凑时通过 max-height + opacity 折叠参考 SPlayer n-collapse-transition -->
<div class="info-collapse">
<!-- 艺术家行 -->
<div v-if="album.artists?.length" class="meta-row">
<IconUserRound class="meta-icon" />
<div class="flex flex-wrap items-center gap-x-1 gap-y-0.5">
<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="meta-text 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>
</div>
<!-- 歌曲数 + 发行日期 -->
<div class="meta-row">
<IconMusic class="meta-icon" />
<span class="meta-text">{{ songs.length }} 首歌曲</span>
<IconCalendar class="meta-icon ml-3" v-if="album.publishTime" />
<span class="meta-text" v-if="album.publishTime">{{ formatDate(album.publishTime) }}</span>
</div>
<!-- 简介如有 -->
<p v-if="album.description" class="album-desc meta-text mt-2">
{{ album.description }}
</p>
</div>
</div>
<!-- 操作行始终在信息列底部参考 SPlayer .menu position:absolute bottom:0
左侧播放按钮常驻右侧模糊搜索 + 歌曲/评论 tab窄屏隐藏 -->
<div class="actions-row">
<div class="actions-left">
<button
@click="playAll"
class="play-btn bg-accent hover:bg-accent-hover text-white font-medium rounded-full transition flex items-center gap-2"
>
<IconPlay class="w-4 h-4 fill-current" />
<span>播放全部</span>
</button>
</div>
<div class="actions-right">
<!-- 模糊搜索过滤当前歌曲列表 -->
<div class="search-wrap" @click.stop>
<IconSearch class="search-icon" />
<input
v-model="searchKeyword"
placeholder="模糊搜索"
class="search-input"
/>
<button
v-if="searchKeyword"
@click="searchKeyword = ''"
class="search-clear"
>
<IconX class="w-3 h-3" />
</button>
</div>
<!-- 歌曲 / 评论 tab -->
<div class="tabs-wrap">
<button
class="tab-btn"
:class="{ active: currentTab === 'songs' }"
@click="currentTab = 'songs'"
>歌曲<span class="tab-count">{{ songs.length }}</span></button>
<button
class="tab-btn"
:class="{ active: currentTab === 'comments' }"
@click="currentTab = 'comments'"
>评论</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 内容区独立滚动歌曲 / 评论 tab 切换渲染
songs v-if 仅激活时渲染comments 首次激活后保活模式v-if + v-show保留滚动位置 -->
<template #content>
<div class="album-list-inner">
<!-- 加载失败 -->
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
<p class="text-content-2 text-sm">加载失败</p>
<button @click="fetchAlbum(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
</div>
<!-- 歌曲列表骨架 -->
<div v-else-if="songsLoading" class="space-y-1">
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<!-- 歌曲 tabv-if 仅在激活时渲染 -->
<template v-else-if="currentTab === 'songs'">
<VirtualSongList
v-if="filteredSongs.length"
:songs="filteredSongs"
:current-song-id="player.currentSong?.id"
show-index
show-like
show-download
show-menu
show-duration
show-playing-overlay
@song-click="(_s, i) => player.playFromList(filteredSongs, i)"
/>
<!-- 搜索无结果 -->
<div v-else-if="searchKeyword" class="flex flex-col items-center justify-center py-16 gap-2 text-content-3 text-sm">
<p>搜不到关于 {{ searchKeyword }} 的任何歌曲</p>
</div>
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-16 gap-2 text-content-3 text-sm">
<p>暂无歌曲</p>
</div>
</template>
<!-- 评论 tab首次切到时挂载commentsActivated=true之后保活以保留滚动位置 -->
<CommentSection
v-if="commentsActivated"
v-show="currentTab === 'comments'"
:type="3"
:id="Number(route.params.id)"
/>
</div>
</template>
</DetailLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, onActivated } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { normalizeSong, type Song } from '../utils/song';
import { formatDate } from '../utils/format';
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
import VirtualSongList from '../components/VirtualSongList.vue';
import CommentSection from '../components/CommentSection.vue';
import DetailLayout from '../components/DetailLayout.vue';
import IconPlay from '~icons/lucide/play';
import IconUserRound from '~icons/lucide/user-round';
import IconMusic from '~icons/lucide/music';
import IconCalendar from '~icons/lucide/calendar';
import IconSearch from '~icons/lucide/search';
import IconX from '~icons/lucide/x';
defineOptions({ name: 'AlbumDetailView' });
const route = useRoute();
const router = useRouter();
const player = usePlayerStore();
const album = ref<any>(null);
const songs = ref<Song[]>([]);
const albumLoading = ref(true);
const songsLoading = ref(false);
const loadError = ref(false);
//
const coverLoaded = ref(false);
// tabsongs / comments SPlayer currentTab
const currentTab = ref<'songs' | 'comments'>('songs');
// tab + v-if v-show
const commentsActivated = ref(false);
// SPlayer searchValue
const searchKeyword = ref('');
// DetailLayout /tab &
const layoutRef = ref<InstanceType<typeof DetailLayout> | null>(null);
// //
const filteredSongs = computed<Song[]>(() => {
const kw = searchKeyword.value.trim().toLowerCase();
if (!kw) return songs.value;
return songs.value.filter((s) => {
const name = (s.name || '').toLowerCase();
const artist = (s.ar?.map((a: any) => a.name).join('') || '').toLowerCase();
const albumName = (s.al?.name || '').toLowerCase();
return name.includes(kw) || artist.includes(kw) || albumName.includes(kw);
});
});
// tab /
// tab
watch(currentTab, (tab) => {
layoutRef.value?.resetScroll();
if (tab === 'comments') commentsActivated.value = true;
});
async function fetchAlbum(id: number, force = false) {
const cacheKey = `album_${id}`;
// tab SPlayer watch detailData.id
currentTab.value = 'songs';
commentsActivated.value = false;
searchKeyword.value = '';
coverLoaded.value = false;
layoutRef.value?.resetScroll();
if (!force) {
const cached = pageCacheGet(cacheKey);
if (cached) {
album.value = cached.album;
songs.value = cached.songs;
albumLoading.value = false;
songsLoading.value = false;
loadError.value = false;
return;
}
}
albumLoading.value = true;
songsLoading.value = true;
loadError.value = false;
album.value = null;
songs.value = [];
try {
const jsonStr: string = await MusicApi.albumDetail(id);
const data = JSON.parse(jsonStr);
album.value = data.album;
albumLoading.value = false;
songs.value = (data.songs || []).map(normalizeSong);
songsLoading.value = false;
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
} catch (e) {
console.error('获取专辑详情失败', e);
loadError.value = true;
albumLoading.value = false;
songsLoading.value = false;
}
}
onMounted(() => {
fetchAlbum(Number(route.params.id));
});
// route.params.id watcher
watch(() => route.params.id, (newId) => {
if (newId && route.name === 'album') fetchAlbum(Number(newId));
});
onActivated(() => {
if (loadError.value) fetchAlbum(Number(route.params.id), true);
});
function playAll() {
//
const list = filteredSongs.value.length ? filteredSongs.value : songs.value;
if (list.length === 0) return;
player.playAll(list);
}
</script>
<style scoped>
/* === + padding ===
参考 SPlayer .detail height 240120 过渡
根容器布局由 DetailLayout 提供absolute + flex + header flex-shrink:0 */
.album-header {
height: 240px;
z-index: 1;
padding: 12px 32px 24px 32px;
border-bottom: 1px solid transparent;
transition:
height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.3s ease;
}
.album-header.is-compact {
height: 120px;
padding: 12px 32px 12px 32px;
border-bottom-color: var(--c-line-2);
}
/* 头部内行:封面 + 信息列横向排列 */
.header-row {
display: flex;
gap: 24px;
height: 100%;
align-items: stretch;
}
.album-header.is-compact .header-row {
align-items: center;
}
/* === SPlayer .cover + .cover-shadow ===
正方形高度跟随 header 高度240/120宽度等于高度
position:relative 承载阴影背板 */
.cover-wrap {
position: relative;
height: 100%;
aspect-ratio: 1 / 1;
flex-shrink: 0;
border-radius: 8px;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* SPlayer img opacity 01 + .is-loaded
未加载时 opacity:0 避免空白闪烁加载后 .is-loaded 触发淡入 */
.cover-img {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
z-index: 1;
opacity: 0;
transition: opacity 0.35s ease-in-out;
}
.cover-img.is-loaded {
opacity: 1;
}
/* 骨架占位 */
.cover-skeleton {
background-color: var(--c-muted);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 阴影背板:模糊 + 半透明 + 略小,营造封面浮起的氛围 */
.cover-shadow {
position: absolute;
top: 6px;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
filter: blur(12px) opacity(0.6);
transform: scale(0.92, 0.96);
z-index: 0;
pointer-events: none;
}
/* 顶部渐变遮罩(参考 SPlayer .cover-mask):紧凑时淡出 */
.cover-mask {
position: absolute;
top: 0;
left: 0;
height: 30%;
width: 100%;
border-radius: 8px;
overflow: hidden;
z-index: 2;
background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0));
transition: opacity 0.3s ease;
pointer-events: none;
}
.album-header.is-compact .cover-mask {
opacity: 0;
}
/* === SPlayer .data ===
flex:1 占满剩余宽度纵向 flex顶部信息 + 底部操作行
justify-content:space-between 让操作行始终贴底 */
.info-col {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding-right: 60px;
}
/* === SPlayer .name ===
展开 30px紧凑 22px固定 line-height + min-height 防字号变化抖动 */
.album-title {
font-size: 30px;
margin-bottom: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
min-height: 39px;
transition: font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.album-header.is-compact .album-title {
font-size: 22px;
margin-bottom: 0;
}
/* === SPlayer n-collapse-transition ===
展开 max-height opacity 1紧凑 max-height 0opacity 0收起间距 */
.info-collapse {
max-height: 160px;
opacity: 1;
overflow: hidden;
transition:
max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease,
margin 0.3s ease;
}
.album-header.is-compact .info-collapse {
max-height: 0;
opacity: 0;
margin-top: -4px;
pointer-events: none;
}
/* 元信息行:图标 + 文字,多行堆叠 */
.meta-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 14px;
}
.meta-row:first-of-type {
margin-top: 0;
}
.meta-icon {
width: 18px;
height: 18px;
color: var(--c-content-3);
flex-shrink: 0;
}
.meta-text {
color: var(--c-content-2);
font-size: 14px;
}
/* 简介:单行省略 */
.album-desc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* === SPlayer .menu position:absolute bottom:0 ===
始终在信息列底部左侧播放按钮 + 右侧搜索/Tab两端对齐 */
.actions-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.actions-left {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.actions-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.play-btn {
height: 40px;
padding: 0 20px;
font-size: 14px;
transition:
height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.2s ease;
}
.album-header.is-compact .play-btn {
height: 32px;
padding: 0 16px;
font-size: 13px;
}
/* 模糊搜索框(参考 SPlayer .search):胶囊形,聚焦时变宽 */
.search-wrap {
position: relative;
display: flex;
align-items: center;
height: 40px;
width: 130px;
border-radius: 25px;
background-color: var(--c-subtle);
border: 1px solid var(--c-line-2);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), height 0.3s ease;
}
.search-wrap:focus-within {
width: 200px;
border-color: var(--c-accent);
}
.search-icon {
width: 16px;
height: 16px;
margin-left: 12px;
color: var(--c-content-3);
flex-shrink: 0;
}
.search-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 8px;
background: transparent;
border: none;
outline: none;
font-size: 13px;
color: var(--c-content);
}
.search-input::placeholder { color: var(--c-content-3); }
.search-clear {
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
width: 18px;
height: 18px;
border-radius: 50%;
color: var(--c-content-3);
flex-shrink: 0;
transition: background-color 0.2s, color 0.2s;
}
.search-clear:hover {
background-color: var(--c-muted);
color: var(--c-content);
}
/* 歌曲/评论 tab(参考 SPlayer .tabs type=segment */
.tabs-wrap {
display: flex;
align-items: center;
height: 40px;
padding: 3px;
border-radius: 25px;
background-color: var(--c-subtle);
border: 1px solid var(--c-line-2);
transition: height 0.3s ease;
}
.tab-btn {
display: flex;
align-items: center;
gap: 2px;
height: 100%;
padding: 0 14px;
border-radius: 22px;
font-size: 13px;
color: var(--c-content-2);
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.3s, color 0.3s;
}
.tab-btn.active {
background-color: var(--c-accent);
color: #fff;
}
.tab-btn:not(.active):hover {
color: var(--c-content);
}
.tab-count {
font-size: 11px;
opacity: 0.7;
transform: translateY(-4px);
}
/* 紧凑态:右侧搜索/tab 高度缩小(参考 SPlayer .small .menu */
.album-header.is-compact .search-wrap,
.album-header.is-compact .tabs-wrap {
height: 32px;
}
.album-header.is-compact .search-wrap {
width: 110px;
}
.album-header.is-compact .search-wrap:focus-within {
width: 160px;
}
.album-header.is-compact .tab-btn {
padding: 0 10px;
font-size: 12px;
}
/* 窄屏隐藏右侧搜索/tab(参考 SPlayer @media max-width:1200px .right */
@media (max-width: 1200px) {
.actions-right {
display: none;
}
}
/* 窄屏适配(参考 SPlayer @media */
@media (max-width: 768px) {
.album-header {
height: 180px;
padding: 12px 16px 20px 16px;
}
.album-header.is-compact {
height: 100px;
}
.header-row {
gap: 12px;
}
.info-col {
padding-right: 12px;
}
.album-title {
font-size: 22px;
}
.album-header.is-compact .album-title {
font-size: 18px;
}
}
/* 列表内层 padding(外层滚动容器由 DetailLayout 提供) */
.album-list-inner {
padding: 0 32px 32px 32px;
}
@media (max-width: 768px) {
.album-list-inner {
padding: 0 16px 24px 16px;
}
}
</style>
+568
View File
@@ -0,0 +1,568 @@
<template>
<DetailLayout ref="layoutRef">
<!-- 头部常驻滚动时收缩为紧凑态头像缩小标题缩小简介隐藏
参考 SPlayer ListDetail.vue .small 触发 height/font-size/opacity 过渡 -->
<template #header="{ compact }">
<div class="ar-header" :class="{ 'is-compact': compact }">
<PageHeader />
<!-- 头部骨架 -->
<div v-if="!artist && !songs.length && !albums.length" class="ar-head-row">
<div class="ar-cover cover-skeleton"></div>
<div class="flex-1 space-y-3">
<div class="h-7 bg-muted rounded w-1/3 animate-pulse"></div>
<div class="h-4 bg-muted rounded w-1/4 animate-pulse"></div>
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="flex gap-3 mt-4">
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
</div>
</div>
</div>
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
<p class="text-content-2 text-sm">加载失败</p>
<button @click="fetchArtist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
</div>
<template v-if="!loadError && (artist || songs.length || albums.length)">
<!-- 头部行头像 + 信息标题/简介/操作按钮 + tab -->
<div class="ar-head-row">
<img v-if="artistCover" :src="artistCover" class="ar-cover" />
<div v-else class="ar-cover ar-cover-placeholder">
<IconMusic class="w-12 h-12 text-content-4" />
</div>
<div class="ar-info">
<div class="ar-info-top">
<h1 class="ar-title">{{ artistName }}</h1>
<!-- 元信息紧凑时折叠隐藏参考 SPlayer n-collapse-transition -->
<div class="ar-collapse">
<p v-if="artistFollowers || artist?.musicSize" class="text-xs text-content-3 mt-1">
<span v-if="artistFollowers">{{ formatPlayCount(artistFollowers) }} 粉丝</span>
<span v-if="artistFollowers && artist?.musicSize"> · </span>
<span v-if="artist?.musicSize">{{ artist.musicSize }} 首歌曲</span>
</p>
<div v-if="briefDesc" class="mt-2">
<p
ref="descEl"
class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap overflow-hidden"
style="max-height: 4.5em"
>{{ briefDesc }}</p>
<button
v-if="descOverflow"
@click="showDescModal = true"
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1.5 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
>
<IconChevronDown class="w-3 h-3" />
查看完整介绍
</button>
</div>
</div>
</div>
<!-- 操作行播放按钮 + 关注按钮 + tab始终常驻底部 -->
<div class="ar-actions">
<button
@click="playAll"
class="ar-play-btn"
>
<IconPlay class="w-4 h-4 fill-current" />
<span>播放全部</span>
</button>
<button
@click="toggleFollow"
:disabled="followLoading"
class="ar-follow-btn"
:class="{ 'is-followed': isFollowed }"
>
{{ isFollowed ? '已关注' : '关注' }}
</button>
<!-- 热门歌曲 / 专辑 tab紧凑态缩小 -->
<div class="ar-tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="ar-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>{{ tab.label }}</button>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<!-- 内容区独立滚动歌曲列表 / 专辑列表 tab 切换渲染 -->
<template #content>
<div class="px-8 pb-8 text-content" v-if="!loadError">
<!-- 歌曲 tabv-if 仅在激活时渲染无评论需保活 tab v-if -->
<div v-if="activeTab === 'songs'">
<div v-if="songsLoading" class="space-y-1">
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<VirtualSongList
v-else-if="songs.length"
:songs="songs"
:current-song-id="player.currentSong?.id"
show-index
show-like
show-download
show-menu
show-duration
show-playing-overlay
@song-click="(_s, i) => player.playFromList(songs, i)"
/>
</div>
<!-- 专辑 tabv-if 仅在激活时渲染 -->
<div v-if="activeTab === 'albums'">
<div v-if="albumsLoading" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="i in 8" :key="i" class="bg-muted rounded-xl animate-pulse">
<div class="w-full aspect-square"></div>
<div class="p-3 space-y-2">
<div class="h-4 bg-subtle rounded w-3/4"></div>
<div class="h-3 bg-subtle rounded w-1/2"></div>
</div>
</div>
</div>
<div v-else-if="albums.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div
v-for="album in albums"
:key="album.id"
@click="router.push({ name: 'album', params: { id: album.id } })"
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer"
>
<img :src="album.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ album.name }}</p>
<p class="text-xs text-content-2 mt-1">{{ formatDate(album.publishTime) }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
</DetailLayout>
<!-- 简介弹窗 -->
<Teleport to="body">
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-line-2">
<h2 class="text-lg font-semibold">{{ artistName }} 的介绍</h2>
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
<IconX class="w-5 h-5" />
</button>
</div>
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ briefDesc }}</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, onActivated } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { formatPlayCount, formatDate } from '../utils/format';
import { checkOverflow } from '../utils/dom';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
import VirtualSongList from '../components/VirtualSongList.vue';
import PageHeader from '../components/PageHeader.vue';
import DetailLayout from '../components/DetailLayout.vue';
import IconPlay from '~icons/lucide/play';
import IconMusic from '~icons/lucide/music';
import IconX from '~icons/lucide/x';
import IconChevronDown from '~icons/lucide/chevron-down';
defineOptions({ name: 'ArtistDetailView' });
const route = useRoute();
const router = useRouter();
const player = usePlayerStore();
const artist = ref<any>(null);
const songs = ref<Song[]>([]);
const albums = ref<any[]>([]);
const briefDesc = ref('');
const loadError = ref(false);
const songsLoading = ref(false);
const albumsLoading = ref(false);
const activeTab = ref<'songs' | 'albums'>('songs');
const showDescModal = ref(false);
const descOverflow = ref(false);
const descEl = ref<HTMLElement | null>(null);
const isFollowed = ref(false);
const followLoading = ref(false);
// DetailLayout &
const layoutRef = ref<InstanceType<typeof DetailLayout> | null>(null);
const tabs = [
{ key: 'songs' as const, label: '热门歌曲' },
{ key: 'albums' as const, label: '专辑' },
];
const artistName = computed(() => {
if (artist.value?.name) return artist.value.name;
if (songs.value.length > 0 && songs.value[0].ar?.length > 0) return songs.value[0].ar[0].name;
if (albums.value.length > 0) return albums.value[0].artist?.name || albums.value[0].artists?.[0]?.name || '';
return '未知歌手';
});
const artistCover = computed(() => {
if (artist.value?.cover) return artist.value.cover;
if (artist.value?.picUrl) return artist.value.picUrl;
if (artist.value?.img1v1Url) return artist.value.img1v1Url;
return '';
});
const artistFollowers = computed(() => {
if (!artist.value) return 0;
return artist.value.followeds || artist.value.followCount || artist.value.fans || 0;
});
function checkDescOverflow() {
checkOverflow(descEl, descOverflow);
}
async function fetchArtist(id: number, force = false) {
const cacheKey = `artist_${id}`;
// tab SPlayer watch detailData.id
activeTab.value = 'songs';
layoutRef.value?.resetScroll();
if (!force) {
const cached = pageCacheGet(cacheKey);
if (cached) {
artist.value = cached.artist;
songs.value = cached.songs;
albums.value = cached.albums;
briefDesc.value = cached.briefDesc;
loadError.value = false;
checkDescOverflow();
return;
}
}
loadError.value = false;
artist.value = null;
songs.value = [];
albums.value = [];
briefDesc.value = '';
const loadDetail = async () => {
try {
const jsonStr = await MusicApi.artistDetail(id);
const data = JSON.parse(jsonStr);
const a = data.artist || data.data?.artist || data;
artist.value = a;
} catch { /* 忽略 */ }
};
const loadFollowStatus = async () => {
try {
const jsonStr = await MusicApi.artistSublist(100, 0);
const data = JSON.parse(jsonStr);
const list = data.data || [];
isFollowed.value = list.some((item: any) => item.id === id);
} catch { /* 忽略 */ }
};
const loadSongs = async () => {
songsLoading.value = true;
try {
const jsonStr = await MusicApi.artistSongs({ id, order: 'hot', limit: 50, offset: 0 });
const data = JSON.parse(jsonStr);
songs.value = (data.songs || []).map(normalizeSong);
} catch { /* 忽略 */ }
finally { songsLoading.value = false; }
};
const loadAlbums = async () => {
albumsLoading.value = true;
try {
const jsonStr = await MusicApi.artistAlbum(id, 30, 0);
const data = JSON.parse(jsonStr);
albums.value = data.hotAlbums || [];
} catch { /* 忽略 */ }
finally { albumsLoading.value = false; }
};
const loadDesc = async () => {
try {
const jsonStr = await MusicApi.artistDesc(id);
const data = JSON.parse(jsonStr);
if (data.briefDesc) {
briefDesc.value = data.briefDesc;
} else if (Array.isArray(data.introduction) && data.introduction.length > 0) {
briefDesc.value = data.introduction.map((item: any) => item.txt || '').filter(Boolean).join('\n');
}
checkDescOverflow();
} catch { /* 忽略 */ }
};
await Promise.allSettled([loadDetail(), loadSongs(), loadAlbums(), loadDesc(), loadFollowStatus()]);
if (!artist.value && !songs.value.length && !albums.value.length && !briefDesc.value) {
loadError.value = true;
return;
}
pageCacheSet(cacheKey, { artist: artist.value, songs: songs.value, albums: albums.value, briefDesc: briefDesc.value });
}
onMounted(() => {
fetchArtist(Number(route.params.id));
});
watch(() => route.params.id, (newId) => {
if (newId && route.name === 'artist') fetchArtist(Number(newId));
});
onActivated(() => {
if (loadError.value) fetchArtist(Number(route.params.id), true);
});
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
async function toggleFollow() {
const id = Number(route.params.id);
if (!id || followLoading.value) return;
followLoading.value = true;
try {
await MusicApi.artistSub(id, !isFollowed.value);
isFollowed.value = !isFollowed.value;
} catch (e) {
console.error('关注操作失败', e);
} finally {
followLoading.value = false;
}
}
</script>
<style scoped>
/* padding padding
参考 SPlayer .detail height 240120 过渡 */
.ar-header {
padding: 12px 32px 24px 32px;
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ar-header.is-compact {
padding: 12px 32px 12px 32px;
}
/* 头部行:头像 + 信息横向排列 */
.ar-head-row {
display: flex;
gap: 24px;
margin-top: 16px;
}
/* 头像:圆形,176→96 紧凑过渡(参考 SPlayer .cover height:100% of 240/120 */
.ar-cover {
width: 176px;
height: 176px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ar-cover.cover-skeleton {
background-color: var(--c-muted);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.ar-cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--c-muted);
}
.ar-header.is-compact .ar-cover {
width: 96px;
height: 96px;
}
/* 信息列:纵向占满,顶部标题+折叠区,底部操作行 */
.ar-info {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
flex: 1;
}
.ar-info-top {
min-width: 0;
}
/* 2418 SPlayer .name 3022
固定 line-height + min-height 防止字号变化时抖动 */
.ar-title {
font-size: 24px;
font-weight: bold;
line-height: 1.3;
min-height: 32px;
transition: font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ar-header.is-compact .ar-title {
font-size: 18px;
}
/* 元信息折叠区:紧凑时 max-height:0 + opacity:0 隐藏(参考 SPlayer n-collapse-transition */
.ar-collapse {
max-height: 200px;
opacity: 1;
overflow: hidden;
transition:
max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease;
}
.ar-header.is-compact .ar-collapse {
max-height: 0;
opacity: 0;
}
/* 操作行:播放按钮 + 关注 + tab,紧凑态缩小 */
.ar-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.ar-play-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
background-color: var(--c-accent);
color: #fff;
font-weight: 500;
border-radius: 9999px;
transition:
background-color 0.2s ease,
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ar-play-btn:hover {
background-color: var(--c-accent-hover);
}
.ar-follow-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background-color: var(--c-accent-dim);
color: var(--c-accent-text);
border-radius: 9999px;
font-size: 14px;
transition:
background-color 0.2s ease,
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ar-follow-btn.is-followed {
background-color: var(--c-subtle);
color: var(--c-content-2);
}
.ar-follow-btn:not(.is-followed):hover {
background-color: var(--c-emphasis);
}
.ar-follow-btn.is-followed:hover {
background-color: var(--c-muted);
}
.ar-header.is-compact .ar-play-btn,
.ar-header.is-compact .ar-follow-btn {
padding: 6px 14px;
font-size: 13px;
}
/* 热门歌曲/专辑 tab:胶囊形,紧凑态缩小 */
.ar-tabs {
display: flex;
gap: 4px;
margin-left: auto;
background-color: var(--c-subtle);
padding: 3px;
border-radius: 9999px;
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ar-tab {
display: flex;
align-items: center;
padding: 6px 14px;
font-size: 13px;
color: var(--c-content-2);
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease,
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ar-tab.active {
background-color: var(--c-accent);
color: #fff;
}
.ar-tab:not(.active):hover {
color: var(--c-content);
}
.ar-header.is-compact .ar-tab {
padding: 5px 10px;
font-size: 12px;
}
/* 窄屏适配(参考 SPlayer @media */
@media (max-width: 768px) {
.ar-header {
padding: 12px 16px 20px 16px;
}
.ar-header.is-compact {
padding: 12px 16px 12px 16px;
}
.ar-head-row {
gap: 12px;
}
.ar-cover {
width: 120px;
height: 120px;
}
.ar-header.is-compact .ar-cover {
width: 72px;
height: 72px;
}
.ar-title {
font-size: 20px;
}
.ar-header.is-compact .ar-title {
font-size: 16px;
}
/* 窄屏隐藏 tab 文字以节省空间(仅活跃 tab 显示) */
.ar-tabs {
gap: 2px;
padding: 2px;
}
.ar-tab {
padding: 5px 10px;
font-size: 12px;
}
}
</style>
+436
View File
@@ -0,0 +1,436 @@
<template>
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">音乐云盘</h1>
<span v-if="totalCount" class="text-xs text-content-3">{{ totalCount }} </span>
<template #actions>
<button
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
<button
@click="pickAndUpload"
:disabled="uploading"
class="px-3 py-1 bg-accent/15 text-accent-text hover:bg-accent/25 rounded-full text-xs transition disabled:opacity-50"
>
{{ uploading ? '上传中...' : '上传歌曲' }}
</button>
<!-- 上传进度 -->
<div v-if="uploading && uploadProgress < 100" class="flex items-center gap-2 text-xs text-content-3">
<div class="w-24 h-1.5 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: uploadProgress + '%' }"></div>
</div>
<span>{{ uploadProgress.toFixed(0) }}%</span>
</div>
</template>
</PageHeader>
<!-- 存储空间 -->
<div v-if="cloudSize > 0" class="mb-6 p-4 bg-subtle rounded-xl">
<div class="flex items-center justify-between text-xs mb-2">
<span class="text-content-2">已使用 {{ formatFileSize(cloudSize) }} / {{ formatFileSize(cloudMaxSize) }}</span>
<span class="text-content-3">{{ cloudUsagePercent }}%</span>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: cloudUsagePercent + '%' }"></div>
</div>
</div>
<div v-if="!userStore.isLoggedIn" class="text-content-3 py-8">
请先登录后查看云盘音乐
</div>
<div v-else-if="loading && !songs.length" class="space-y-1">
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
<p class="text-content-2 text-sm">加载失败</p>
<button @click="refresh" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
</div>
<div v-else-if="!songs.length" class="text-content-3 py-8">云盘中暂无音乐</div>
<div v-else class="space-y-1">
<SongListItem
v-for="(song, index) in songs"
:key="song.id"
:song="song"
:index="index"
:is-current="player.currentSong?.id === song.id"
show-index
show-like
show-download
show-duration
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(songs, index)"
>
<template #actions>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(cloudData[index]?.fileSize || 0) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(song.id)"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="更多"
>
<IconEllipsis class="w-4 h-4 fill-current" />
</button>
<Transition name="fade">
<div v-if="openMenuId === song.id" class="absolute right-0 top-full mt-1 w-40 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="showDetail(index)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-content-2 hover:bg-subtle transition">
<IconInfo style="font-size: 14px" />
查看详情
</button>
<button @click="confirmDelete(song)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
<IconTrash2 style="font-size: 14px" />
从云盘删除
</button>
</div>
</Transition>
</div>
</template>
</SongListItem>
</div>
<!-- 加载更多 -->
<div v-if="hasMore && songs.length" class="flex justify-center py-6">
<button
@click="loadMore"
:disabled="loadingMore"
class="px-6 py-2 bg-subtle hover:bg-muted rounded-full text-sm transition disabled:opacity-50"
>
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
<!-- 详情弹窗 -->
<Transition name="fade">
<div v-if="showDetailModal && detailData" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDetailModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[380px] p-6 select-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold truncate pr-4">{{ detailData.songName }}</h2>
<button @click="showDetailModal = false" class="text-content-3 hover:text-content transition flex-shrink-0">
<IconX class="w-5 h-5" />
</button>
</div>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-content-3">文件名</span>
<span class="text-content-2 text-right max-w-[220px] truncate" :title="detailData.fileName">{{ detailData.fileName }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">歌手</span>
<span class="text-content-2">{{ detailData.artist }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">专辑</span>
<span class="text-content-2">{{ detailData.album }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">文件大小</span>
<span class="text-content-2">{{ formatFileSize(detailData.fileSize) }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">比特率</span>
<span class="text-content-2">{{ detailData.bitrate ? (detailData.bitrate / 1000) + ' kbps' : '未知' }}</span>
</div>
<div class="flex justify-between">
<span class="text-content-3">上传时间</span>
<span class="text-content-2">{{ detailData.addTime }}</span>
</div>
</div>
</div>
</div>
</Transition>
<!-- 删除确认 -->
<Transition name="fade">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
<p class="text-sm text-content-2 mb-5">确定要从云盘删除{{ deleteTarget?.name }}</p>
<div class="flex gap-3">
<button @click="showDeleteConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="doDelete"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
删除
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, onBeforeUnmount } from 'vue';
import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { showToast } from '../composables/useToast';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { formatFileSize } from '../composables/useLocalMusic';
import { open } from '@tauri-apps/plugin-dialog';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import SongListItem from '../components/SongListItem.vue';
import PageHeader from '../components/PageHeader.vue';
import IconEllipsis from '~icons/lucide/ellipsis';
import IconInfo from '~icons/lucide/info';
import IconTrash2 from '~icons/lucide/trash-2';
import IconX from '~icons/lucide/x';
defineOptions({ name: 'CloudMusicView' });
const player = usePlayerStore();
const userStore = useUserStore();
interface CloudItem {
songId: number;
fileSize: number;
fileName: string;
bitrate: number;
addTime: string;
artist: string;
album: string;
songName: string;
}
const songs = ref<Song[]>([]);
const cloudData = ref<CloudItem[]>([]);
const loading = ref(true);
const loadingMore = ref(false);
const loadError = ref(false);
const hasMore = ref(false);
const totalCount = ref(0);
const openMenuId = ref<number | null>(null);
const showDeleteConfirm = ref(false);
const showDetailModal = ref(false);
const detailData = ref<CloudItem | null>(null);
const deleteTarget = ref<Song | null>(null);
const cloudSize = ref(0);
const cloudMaxSize = ref(0);
const uploading = ref(false);
const uploadProgress = ref(0);
let unlistenProgress: UnlistenFn | null = null;
const cloudUsagePercent = computed(() => {
if (cloudMaxSize.value === 0) return 0;
return Math.min(100, Math.round(cloudSize.value / cloudMaxSize.value * 100));
});
const LIMIT = 30;
let currentOffset = 0;
function formatTimestamp(ts: number): string {
if (!ts) return '未知';
return new Date(ts).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
function closeMenu() {
openMenuId.value = null;
}
onMounted(() => {
document.addEventListener('click', closeMenu);
//
listen<{ filename: string; progress: number; uploaded: number; total: number }>('cloud-upload-progress', (e) => {
uploadProgress.value = e.payload.progress;
}).then(fn => { unlistenProgress = fn; });
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu);
unlistenProgress?.();
});
async function fetchCloud(offset = 0, append = false) {
if (!userStore.isLoggedIn) {
loading.value = false;
return;
}
if (!append) {
loading.value = true;
loadError.value = false;
} else {
loadingMore.value = true;
}
try {
const jsonStr = await MusicApi.userCloud(LIMIT, offset);
const data = JSON.parse(jsonStr);
const items = data.data || [];
const newSongs = items.map((item: any) => {
const s = item.simpleSong || {};
return normalizeSong({
...s,
id: s.id || item.songId,
name: s.name || item.fileName,
ar: s.ar || (item.artist ? [{ name: item.artist }] : []),
al: s.al || { name: item.album || '未知专辑' },
dt: s.dt || item.duration,
});
});
const newCloudData: CloudItem[] = items.map((item: any) => ({
songId: item.songId,
fileSize: item.fileSize || 0,
fileName: item.fileName || '',
bitrate: item.bitrate || 0,
addTime: formatTimestamp(item.addTime),
artist: item.artist || (item.simpleSong?.ar || []).map((a: any) => a.name).join(' / ') || '未知歌手',
album: item.album || item.simpleSong?.al?.name || '未知专辑',
songName: item.simpleSong?.name || item.fileName?.replace(/\.\w+$/, '') || '未知歌曲',
}));
if (append) {
songs.value = [...songs.value, ...newSongs];
cloudData.value = [...cloudData.value, ...newCloudData];
} else {
songs.value = newSongs;
cloudData.value = newCloudData;
}
totalCount.value = data.count || songs.value.length;
currentOffset = offset + items.length;
hasMore.value = songs.value.length < totalCount.value;
cloudSize.value = data.size || 0;
cloudMaxSize.value = data.maxSize || 0;
if (!append) {
pageCacheSet('cloudMusic', {
songs: songs.value, cloudData: cloudData.value, totalCount: totalCount.value,
hasMore: hasMore.value, offset: currentOffset,
cloudSize: cloudSize.value, cloudMaxSize: cloudMaxSize.value,
});
}
} catch (e) {
console.error('获取云盘音乐失败', e);
if (!append) loadError.value = true;
else showToast('加载更多失败', 'error');
} finally {
loading.value = false;
loadingMore.value = false;
}
}
function refresh() {
pageCacheInvalidate('cloudMusic');
currentOffset = 0;
fetchCloud(0, false);
}
function loadMore() {
fetchCloud(currentOffset, true);
}
async function pickAndUpload() {
const selected = await open({
multiple: true,
filters: [{ name: '音频文件', extensions: ['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a'] }],
title: '选择要上传的歌曲',
});
if (!selected) return;
const paths = Array.isArray(selected) ? selected : [selected];
uploading.value = true;
uploadProgress.value = 0;
for (const filePath of paths) {
uploadProgress.value = 0;
try {
await MusicApi.cloudUpload(filePath);
showToast('上传成功', 'success');
} catch (e: any) {
showToast(`上传失败: ${e || '未知错误'}`, 'error');
}
}
uploading.value = false;
uploadProgress.value = 0;
//
setTimeout(() => refresh(), 1000);
}
function showDetail(index: number) {
openMenuId.value = null;
detailData.value = cloudData.value[index] || null;
showDetailModal.value = true;
}
function confirmDelete(song: Song) {
openMenuId.value = null;
deleteTarget.value = song;
showDeleteConfirm.value = true;
}
async function doDelete() {
if (!deleteTarget.value) return;
try {
await MusicApi.userCloudDel(deleteTarget.value.id);
const targetId = deleteTarget.value.id;
const idx = songs.value.findIndex(s => s.id === targetId);
songs.value = songs.value.filter(s => s.id !== targetId);
if (idx !== -1) cloudData.value.splice(idx, 1);
totalCount.value = Math.max(0, totalCount.value - 1);
pageCacheInvalidate('cloudMusic');
showToast('已从云盘删除', 'success');
} catch {
showToast('删除失败', 'error');
}
showDeleteConfirm.value = false;
deleteTarget.value = null;
}
onMounted(() => {
if (!userStore.isLoggedIn) {
loading.value = false;
return;
}
const cached = pageCacheGet('cloudMusic');
if (cached) {
songs.value = cached.songs;
cloudData.value = cached.cloudData;
totalCount.value = cached.totalCount;
hasMore.value = cached.hasMore;
currentOffset = cached.offset;
cloudSize.value = cached.cloudSize || 0;
cloudMaxSize.value = cached.cloudMaxSize || 0;
loading.value = false;
return;
}
fetchCloud();
});
onActivated(() => {
if (loadError.value) refresh();
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+76 -34
View File
@@ -1,56 +1,98 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<h1 class="text-2xl font-bold mb-6">每日推荐</h1>
<div v-if="loading" class="text-gray-400">加载中...</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.play(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">每日推荐</h1>
<button
v-if="songs.length > 0"
@click="player.playAll(songs)"
class="px-4 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition text-sm"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
播放全部
</button>
</PageHeader>
<div v-if="loading" class="space-y-1">
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
</div>
</div>
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
<p class="text-content-2 text-sm">加载失败</p>
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
</div>
<VirtualSongList
v-else
:songs="songs"
:current-song-id="player.currentSong?.id"
show-index
show-like
show-download
show-menu
show-duration
show-playing-overlay
@song-click="(_s, i) => player.playFromList(songs, i)"
/>
</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 VirtualSongList from '../components/VirtualSongList.vue';
import PageHeader from '../components/PageHeader.vue';
import { usePlayerStore } from '../stores/player';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { normalizeSong, type Song } from '../utils/song';
defineOptions({ name: 'DailySongsView' });
const player = usePlayerStore();
const songs = ref<any[]>([]);
const songs = ref<Song[]>([]);
const loading = ref(true);
const loadError = ref(false);
onMounted(async () => {
async function loadData(force = false) {
if (!force) {
const cached = pageCacheGet('dailySongs');
if (cached) {
songs.value = cached;
loading.value = false;
loadError.value = false;
return;
}
}
loading.value = true;
try {
const jsonStr: string = await invoke('recommend_songs');
loadError.value = false;
const jsonStr: string = await MusicApi.recommendSongs();
const data = JSON.parse(jsonStr);
songs.value = data.data?.dailySongs || [];
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
pageCacheSet('dailySongs', songs.value);
} catch (e) {
console.error(e);
console.error('获取每日推荐失败', e);
loadError.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadData);
onActivated(() => {
if (loadError.value) {
loadData(true);
} else if (pageCacheIsStale('dailySongs')) {
loadData();
}
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
watch(() => navigator.onLine, (val, old) => {
if (val && !old && songs.value.length === 0) {
pageCacheInvalidate('dailySongs');
loadData();
}
});
</script>
+305 -58
View File
@@ -1,16 +1,56 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
<div class="p-8 text-content" @click="showSuggestions = false">
<div class="relative mb-6" @click.stop>
<div class="flex items-center gap-3">
<div class="relative flex-1">
<IconSearch class="absolute left-3.5 top-1/2 -translate-y-1/2 text-content-3 w-[18px] h-[18px]" />
<input
ref="searchInput"
v-model="keyword"
@input="onInputChange"
@keydown.enter="handleSearch"
@focus="onInputFocus"
placeholder="搜索歌曲、歌手、专辑..."
class="w-full rounded-xl bg-muted pl-10 pr-10 py-3 text-content placeholder-content-3 outline-none focus:bg-subtle focus:ring-1 focus:ring-accent/30 transition"
/>
<button v-if="keyword" @click="clearSearch" class="absolute right-3 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition">
<IconX class="w-4 h-4" />
</button>
</div>
</div>
<!-- 搜索框 -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲、歌手、专辑..."
class="mb-4 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
/>
<div v-if="showSuggestions && !hasSearched"
class="absolute z-30 left-0 right-0 top-full mt-2 bg-surface border border-line-2 rounded-xl shadow-xl overflow-hidden max-h-[60vh] overflow-y-auto">
<div v-if="suggestions.length" class="p-2">
<p class="text-xs text-content-3 px-3 py-1.5">搜索建议</p>
<button v-for="s in suggestions" :key="s" @click="searchTag(s)"
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
<IconSearch style="font-size: 14px" class="text-content-3 flex-shrink-0" />
<span>{{ s }}</span>
</button>
</div>
<div v-if="searchHistory.length && !suggestions.length" class="p-2">
<div class="flex items-center justify-between px-3 py-1.5">
<p class="text-xs text-content-3">搜索历史</p>
<button @click.stop="clearHistory" class="text-xs text-content-3 hover:text-danger transition">清空</button>
</div>
<button v-for="h in searchHistory" :key="h" @click="searchTag(h)"
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
<IconHistory style="font-size: 14px" class="text-content-3 flex-shrink-0" />
<span>{{ h }}</span>
</button>
</div>
<div v-if="hotTags.length && !suggestions.length && !searchHistory.length" class="p-2">
<p class="text-xs text-content-3 px-3 py-1.5">热门搜索</p>
<button v-for="tag in hotTags" :key="tag.searchWord" @click="searchTag(tag.searchWord)"
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
<IconClock style="font-size: 14px" class="text-content-3 flex-shrink-0" />
<span>{{ tag.searchWord }}</span>
</button>
</div>
</div>
</div>
<!-- 热门搜索标签仅在没有搜索且未显示结果时出现 -->
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
<div class="flex flex-wrap gap-2">
@@ -18,40 +58,84 @@
v-for="tag in hotTags"
:key="tag.searchWord"
@click="searchTag(tag.searchWord)"
class="px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 cursor-pointer transition text-sm"
class="px-3 py-1.5 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
>
{{ tag.searchWord }}
</span>
</div>
</div>
<!-- 输出设备选择 -->
<!-- <div class="mb-4">
<label class="mr-2 text-sm text-gray-400">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1 text-sm">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<div v-if="hasSearched">
<div class="flex items-center gap-1 mb-4 bg-muted rounded-lg p-1 w-fit">
<button v-for="tab in tabs" :key="tab.type" @click="switchTab(tab.type)"
:class="['px-4 py-1.5 rounded-md text-sm font-medium transition', activeTab === tab.type ? 'bg-surface text-content shadow-sm' : 'text-content-2 hover:text-content']">
{{ tab.label }}
<span v-if="resultCache.has(tab.type) && resultCache.get(tab.type)!.count > 0" class="text-xs text-content-3 ml-1">{{ resultCache.get(tab.type)!.count }}</span>
</button>
</div>
<!-- 搜索结果 -->
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="flex items-end gap-1 h-6">
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0s"></span>
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.12s"></span>
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.24s"></span>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
<template v-else>
<div v-if="activeTab === 1">
<div v-if="currentResults.length" class="space-y-2">
<SongListItem
v-for="(song, index) in currentResults"
:key="song.id"
:song="song"
:index="index"
show-download
show-menu
cover-size="w-12 h-12"
container-class="bg-subtle hover:bg-muted border border-line-2"
@click="player.playFromList(currentResults, index)"
/>
</div>
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌曲' }}</p>
</div>
<div v-else-if="activeTab === 100">
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="artist in currentResults" :key="artist.id" @click="router.push({ name: 'artist', params: { id: artist.id } })"
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl p-4 cursor-pointer transition flex items-center gap-3">
<img v-if="artist.picUrl" :src="artist.picUrl + '?param=100y100'" class="w-14 h-14 rounded-full object-cover flex-shrink-0" />
<div v-else class="w-14 h-14 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<IconUserRound class="w-5 h-5 text-content-3" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ artist.name }}</p>
<p v-if="artist.alias?.length" class="text-xs text-content-3 truncate">{{ artist.alias[0] }}</p>
<p v-if="artist.musicSize" class="text-xs text-content-3">{{ artist.musicSize }} 首歌曲</p>
</div>
</div>
</div>
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌手' }}</p>
</div>
<div v-else-if="activeTab === 10">
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div v-for="album in currentResults" :key="album.id" @click="router.push({ name: 'album', params: { id: album.id } })"
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl overflow-hidden cursor-pointer transition">
<img v-if="album.picUrl" :src="album.picUrl + '?param=200y200'" class="w-full aspect-square object-cover" />
<div v-else class="w-full aspect-square bg-muted flex items-center justify-center">
<IconDisc class="w-8 h-8 text-content-3" />
</div>
<div class="p-3">
<p class="text-sm font-medium truncate">{{ album.name }}</p>
<p class="text-xs text-content-2 truncate mt-0.5">{{ album.artist?.name || '' }}</p>
<p v-if="album.publishTime" class="text-xs text-content-3 mt-0.5">{{ formatDate(album.publishTime) }}</p>
</div>
</div>
</div>
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关专辑' }}</p>
</div>
</template>
</div>
</div>
</template>
@@ -59,35 +143,127 @@
<script setup lang="ts">
defineOptions({ name: 'DiscoverView' });
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player';
import SongListItem from '../components/SongListItem.vue';
import { normalizeSong, type Song } from '../utils/song';
import { formatDate } from '../utils/format';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
import IconSearch from '~icons/lucide/search';
import IconX from '~icons/lucide/x';
import IconHistory from '~icons/lucide/history';
import IconClock from '~icons/lucide/clock';
import IconUserRound from '~icons/lucide/user-round';
import IconDisc from '~icons/lucide/disc';
const router = useRouter();
const route = useRoute();
const player = usePlayerStore();
const { isOnline } = useOnlineStatus();
const searchInput = ref<HTMLInputElement | null>(null);
const keyword = ref('');
const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const hotTags = ref<any[]>([]);
const suggestions = ref<string[]>([]);
const showSuggestions = ref(false);
const activeTab = ref(1);
const cacheError = ref(false);
const devices = ref<string[]>([]);
interface CacheEntry {
data: Song[] | any[];
count: number;
dirty: boolean;
}
const resultCache = ref<Map<number, CacheEntry>>(new Map());
const lastSearchKeyword = ref('');
const currentResults = computed(() => {
const entry = resultCache.value.get(activeTab.value);
return entry ? entry.data : [];
});
const tabs = [
{ type: 1, label: '歌曲' },
{ type: 100, label: '歌手' },
{ type: 10, label: '专辑' },
];
const HISTORY_KEY = 'search_history';
const MAX_HISTORY = 15;
function loadSearchHistory(): string[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (raw) return JSON.parse(raw);
} catch { /* 忽略 */ }
return [];
}
function saveSearchHistory(q: string) {
let history = loadSearchHistory();
history = history.filter(h => h !== q);
history.unshift(q);
if (history.length > MAX_HISTORY) history = history.slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
const searchHistory = ref<string[]>(loadSearchHistory());
function clearHistory() {
searchHistory.value = [];
localStorage.removeItem(HISTORY_KEY);
}
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
function onInputChange() {
if (suggestTimer) clearTimeout(suggestTimer);
if (!keyword.value.trim()) {
suggestions.value = [];
showSuggestions.value = true;
return;
}
suggestTimer = setTimeout(async () => {
try {
const jsonStr: string = await MusicApi.searchSuggest(keyword.value.trim());
const data = JSON.parse(jsonStr);
const all = data.result?.allMatch || [];
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
showSuggestions.value = true;
} catch {
suggestions.value = [];
}
}, 300);
}
function onInputFocus() {
if (!hasSearched.value) {
showSuggestions.value = true;
}
}
async function loadHotTags() {
const cached = pageCacheGet('discover_hotTags');
if (cached) {
hotTags.value = cached;
} else {
try {
const json = await MusicApi.getHotSearch();
const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12);
pageCacheSet('discover_hotTags', hotTags.value);
} catch { /* 忽略 */ }
}
}
onMounted(async () => {
//
try { devices.value = await invoke('get_output_devices'); } catch {}
//
try {
const json = await invoke('get_hot_search');
const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12);
} catch {}
//
await loadHotTags();
const q = route.query.q as string;
if (q) {
keyword.value = q;
@@ -96,28 +272,99 @@ onMounted(async () => {
}
});
onActivated(async () => {
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
const q = route.query.q as string;
if (q && q !== lastSearchKeyword.value) {
keyword.value = q;
await handleSearch();
router.replace({ query: {} });
}
});
watch(isOnline, (val, old) => {
if (val && !old && hotTags.value.length === 0) {
pageCacheInvalidate('discover_hotTags');
loadHotTags();
}
});
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
const q = keyword.value.trim();
if (!q) return;
showSuggestions.value = false;
hasSearched.value = true;
cacheError.value = false;
saveSearchHistory(q);
searchHistory.value = loadSearchHistory();
if (q === lastSearchKeyword.value && resultCache.value.size > 0) return;
lastSearchKeyword.value = q;
resultCache.value.clear();
await Promise.all([
fetchTabResults(1),
fetchTabResults(100),
fetchTabResults(10),
]);
}
async function fetchTabResults(type: number) {
const entry = resultCache.value.get(type);
if (entry && !entry.dirty) return;
loading.value = true;
cacheError.value = false;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const jsonStr: string = await MusicApi.cloudsearch({
keyword: lastSearchKeyword.value, searchType: type, limit: 30
});
const data = JSON.parse(jsonStr);
results.value = data.result?.songs || [];
const result = data.result || {};
let items: any[] = [];
if (type === 1) {
items = (result.songs || []).map(normalizeSong);
} else if (type === 100) {
items = result.artists || [];
} else if (type === 10) {
items = result.albums || [];
}
resultCache.value.set(type, { data: items, count: items.length, dirty: false });
} catch (e) {
console.error('搜索出错:', e);
resultCache.value.set(type, { data: [], count: 0, dirty: true });
cacheError.value = true;
} finally {
loading.value = false;
}
}
async function switchTab(type: number) {
if (type === activeTab.value) return;
activeTab.value = type;
const entry = resultCache.value.get(type);
if (!entry || entry.dirty) {
await fetchTabResults(type);
}
}
function searchTag(tag: string) {
keyword.value = tag;
handleSearch();
}
async function playSong(song: any) {
player.play(song);
function clearSearch() {
keyword.value = '';
hasSearched.value = false;
resultCache.value.clear();
lastSearchKeyword.value = '';
cacheError.value = false;
suggestions.value = [];
showSuggestions.value = true;
nextTick(() => searchInput.value?.focus());
}
</script>
</script>
+177
View File
@@ -0,0 +1,177 @@
<template>
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">下载音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<template #actions>
<button
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
</template>
</PageHeader>
<div v-if="loading" class="space-y-1">
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<div v-else-if="songs.length === 0" class="text-content-3">
暂无下载音乐
</div>
<div v-else class="space-y-2">
<SongListItem
v-for="(song, index) in normalizedSongs"
:key="song.id + '-' + index"
:song="song"
:index="index"
:is-current="player.currentSong?.id === song.id"
show-index
show-duration
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(normalizedSongs, index)"
>
<template #actions>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(songs[index].id)"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="更多"
>
<IconEllipsis class="w-4 h-4 fill-current" />
</button>
<Transition name="fade">
<div v-if="openMenuId === songs[index].id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="confirmDelete(songs[index])" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
<IconTrash2 style="font-size: 14px" />
从磁盘中删除
</button>
</div>
</Transition>
</div>
</template>
</SongListItem>
</div>
<Transition name="fade">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
<p class="text-sm text-content-2 mb-5">确定要删除{{ deleteTarget?.name }}此操作不可撤销</p>
<div class="flex gap-3">
<button @click="showDeleteConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="doDelete"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
删除
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
import { DownloadApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
import SongListItem from '../components/SongListItem.vue';
import PageHeader from '../components/PageHeader.vue';
import IconEllipsis from '~icons/lucide/ellipsis';
import IconTrash2 from '~icons/lucide/trash-2';
defineOptions({ name: 'DownloadedMusicView' });
const player = usePlayerStore();
const download = useDownload();
const settings = useSettingsStore();
const songs = ref<LocalSong[]>([]);
const loading = ref(true);
const showDeleteConfirm = ref(false);
const deleteTarget = ref<LocalSong | null>(null);
const openMenuId = ref<number | null>(null);
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
function closeMenu() {
openMenuId.value = null;
}
onMounted(() => { document.addEventListener('click', closeMenu); });
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
async function refresh() {
loading.value = true;
try {
const list = await DownloadApi.listLocalSongs(settings.downloadPath || null);
songs.value = list;
pageCacheSet('downloadedMusic', list);
fetchMissingCovers(songs.value);
} catch (e) {
console.error('加载下载音乐列表失败', e);
} finally {
loading.value = false;
}
}
onMounted(refresh);
onActivated(() => {
if (pageCacheIsStale('downloadedMusic')) refresh();
});
watch(() => settings.downloadPath, () => { refresh(); });
function confirmDelete(song: LocalSong) {
openMenuId.value = null;
deleteTarget.value = song;
showDeleteConfirm.value = true;
}
async function doDelete() {
if (!deleteTarget.value) return;
try {
await DownloadApi.deleteLocalSong({ id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null });
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
download.localSongIds.delete(deleteTarget.value.id);
pageCacheInvalidate('downloadedMusic');
showToast('已删除', 'success');
} catch (e) {
showToast('删除失败', 'error');
}
showDeleteConfirm.value = false;
deleteTarget.value = null;
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+116 -4
View File
@@ -1,6 +1,118 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4"> 我喜欢的音乐</h1>
<p class="text-gray-400">正在施工...</p>
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
<button
v-if="songs.length"
@click="player.playAll(songs)"
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
>
<IconPlay class="w-4 h-4 fill-current" />
播放全部
</button>
</PageHeader>
<div v-if="!userStore.isLoggedIn" class="text-content-2">
请先登录后查看喜欢的音乐
</div>
<div v-else-if="loading" class="space-y-1">
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<div v-else-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
<p class="text-content-2 text-sm">加载失败</p>
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
</div>
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
<VirtualSongList
v-else
:songs="songs"
:current-song-id="player.currentSong?.id"
show-index
show-like
show-download
show-menu
show-duration
show-playing-overlay
@song-click="(_s, i) => player.playFromList(songs, i)"
/>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, onMounted, onActivated, watch } from 'vue';
import { MusicApi } from '../api';
import VirtualSongList from '../components/VirtualSongList.vue';
import PageHeader from '../components/PageHeader.vue';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import IconPlay from '~icons/lucide/play';
defineOptions({ name: 'FavoriteSongsView' });
const player = usePlayerStore();
const userStore = useUserStore();
const songs = ref<Song[]>([]);
const loading = ref(true);
const loadError = ref(false);
async function loadData(force = false) {
if (!userStore.isLoggedIn) {
loading.value = false;
return;
}
if (!force) {
const cached = pageCacheGet('favoriteSongs');
if (cached) {
songs.value = cached;
loading.value = false;
loadError.value = false;
return;
}
}
loading.value = true;
try {
loadError.value = false;
const playlistJson: string = await MusicApi.userPlaylist(userStore.user!.userId);
const playlistData = JSON.parse(playlistJson);
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
if (created.length === 0) {
loading.value = false;
return;
}
const likePlaylistId = created[0].id;
const trackJson: string = await MusicApi.playlistTrackAll(likePlaylistId);
const trackData = JSON.parse(trackJson);
songs.value = (trackData.songs || []).map(normalizeSong);
pageCacheSet('favoriteSongs', songs.value);
} catch (e) {
console.error('加载我喜欢的音乐失败', e);
loadError.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadData);
onActivated(() => {
if (loadError.value) {
loadData(true);
} else if (pageCacheIsStale('favoriteSongs')) {
loadData();
}
});
watch(() => navigator.onLine, (val, old) => {
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
pageCacheInvalidate('favoriteSongs');
loadData();
}
});
</script>
+470 -132
View File
@@ -1,7 +1,7 @@
<template>
<div class="p-8 text-white">
<div class="p-8 text-content">
<!-- 第一行每日推荐 & 私人漫游 卡片 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
<!-- 每日推荐 -->
<div
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
@@ -11,185 +11,469 @@
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
<div>
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
<h2 class="text-2xl font-bold">每日推荐</h2>
<h2 class="text-2xl font-bold text-white">每日推荐</h2>
</div>
<p class="text-xs text-white/60">根据你的口味生成每天 6:00 更新</p>
<p class="text-xs text-white/60">根据你的口味生成每天凌晨更新</p>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
</div>
<!-- 私人漫游 卡片 -->
<!-- 私人漫游 卡片 -->
<div
class="h-48 bg-gradient-to-br from-blue-600 to-cyan-500 rounded-3xl overflow-hidden relative group select-none"
@click="!userStore.isLoggedIn ? goLogin() : null"
>
<!-- 模糊封面层仅在有歌曲且有封面时显示低透明度模糊 -->
<div
v-if="player.fmSong && fmCoverUrl"
class="absolute inset-0 bg-cover bg-center opacity-30 blur-md scale-110"
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
></div>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition"></div>
<!-- 内容 -->
<div class="relative z-10 h-full">
<!-- 未登录 -->
<div v-if="!userStore.isLoggedIn" class="flex flex-col items-center justify-center h-full">
<p class="text-xs text-white/60 mb-1">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<p class="text-xs text-white/60 mt-2">登录后即可开启沉浸式音乐探索</p>
</div>
<!-- 登录后无歌曲 垂直居中播放按钮 -->
<div
v-else-if="!player.fmSong"
class="flex flex-col items-center justify-center h-full gap-3 cursor-pointer"
@click.stop="startFmPlay"
>
<p class="text-xs text-white/60">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<button
class="w-12 h-12 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition mt-2"
<div
class="h-48 rounded-3xl overflow-hidden relative group select-none cursor-pointer"
:class="player.fmSong && fmCoverUrl ? '' : 'bg-gradient-to-br from-indigo-600 via-blue-600 to-cyan-500'"
@click="onFmCardClick"
>
<svg width="22" height="22" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
</div>
<div
v-if="player.fmSong && fmCoverUrl"
class="absolute inset-0 bg-cover bg-center scale-110"
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 group-hover:from-black/60 transition"></div>
<!-- 有歌曲 横向布局左侧信息右侧按钮 -->
<div v-else class="flex items-center justify-between h-full px-6 cursor-pointer" @click.stop="player.toggleFm">
<!-- 左侧封面 + 歌曲信息 -->
<div class="flex items-center gap-3 min-w-0">
<img :src="fmCoverUrl" class="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
<div class="min-w-0">
<p class="text-sm font-semibold truncate">{{ fmDisplayName }}</p>
<p class="text-xs text-white/70 truncate">{{ fmDisplayArtists }}</p>
<div class="relative z-10 h-full flex flex-col justify-between p-6">
<div class="flex items-center gap-2">
<IconRadio class="w-4 h-4 text-white/50" />
<span class="text-xs text-white/50 font-medium">私人漫游</span>
</div>
<div class="flex items-end justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-xl font-bold text-white" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
<h2 class="text-xl font-bold text-white" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
<h2 class="text-lg font-bold truncate text-white" v-else>{{ fmDisplayName }}</h2>
<p v-if="!userStore.isLoggedIn" class="text-xs text-white/50 mt-1">登录后开启沉浸式音乐探索</p>
<p v-else-if="!player.fmSong" class="text-xs text-white/50 mt-1">根据你的喜好为你推荐意想不到的好歌</p>
<p v-else class="text-xs text-white/60 truncate mt-1">{{ fmDisplayArtists }}</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button v-if="userStore.isLoggedIn && !player.fmSong"
@click.stop="startFmPlay"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
<IconPlay class="w-4 h-4 fill-current text-white" />
</button>
<template v-if="player.fmSong">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
<IconPause v-if="player.fmPlaying" class="w-[18px] h-[18px] fill-current text-white" />
<IconPlay v-else class="w-[18px] h-[18px] fill-current text-white" />
</button>
<button @click.stop="player.nextFm"
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition">
<IconSkipForward class="w-[14px] h-[14px] text-white" />
</button>
</template>
</div>
</div>
</div>
</div>
<!-- 右侧控制按钮 -->
<div class="flex items-center gap-3 ml-4">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition"
</div>
<!-- 推荐歌单个性化无需登录也可获取 -->
<section class="mb-10">
<SectionHeader
:title="userStore.isLoggedIn ? '🎯 为你推荐' : '🎵 推荐歌单'"
:subtitle="userStore.isLoggedIn ? '根据你的口味生成' : '精选好歌单'"
:icon="IconSparkles"
/>
<CardGrid
:items="recPlaylists"
:loading="recLoading"
:error="recError"
error-text="推荐歌单加载失败"
@retry="fetchRecPlaylists"
>
<PlaylistCard
v-for="pl in recPlaylists"
:key="pl.id"
:playlist="pl"
:is-current-playing="player.currentSource.type === 'playlist' && player.currentSource.id === pl.id && player.playing"
@click="goPlaylist(pl.id)"
@play="playPlaylist(pl)"
/>
</CardGrid>
</section>
<!-- 新歌速递 -->
<section class="mb-10">
<SectionHeader
:title="userStore.isLoggedIn ? '🆕 推荐新歌' : '🆕 新歌速递'"
:subtitle="userStore.isLoggedIn ? '根据你的口味推荐' : '发现最新音乐'"
:icon="IconTrendingUp"
/>
<CardGrid
:items="newSongs"
:loading="newSongsLoading"
:error="newSongsError"
error-text="新歌加载失败"
:skeleton-count="4"
grid-class="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
@retry="fetchNewSongs"
>
<div
v-for="(song, index) in newSongs"
:key="song.id"
@click="playNewSong(index)"
class="flex items-center gap-3 p-3 rounded-xl bg-subtle hover:bg-muted transition cursor-pointer group"
>
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click.stop="player.nextFm" class="text-xl text-white/80 hover:text-white transition"></button>
</div>
</div>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20 pointer-events-none">🌊</div>
</div>
</div>
<!-- 第二行为你推荐需登录 -->
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer">
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
<p class="text-xs text-gray-400 mt-1">{{ pl.copywriter || '' }}</p>
<div class="w-12 h-12 rounded-md overflow-hidden flex-shrink-0 relative">
<img
v-if="song.al?.picUrl"
:src="song.al.picUrl"
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 class="w-4 h-4 text-content-4" />
</div>
</div>
</div>
</div>
</div>
<!-- 第三行热门歌单排行榜 -->
<div>
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer backdrop-blur-sm">
<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>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate text-content">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">{{ getArtistDisplay(song) }}</p>
</div>
<button
class="w-8 h-8 flex items-center justify-center rounded-full bg-muted/50 text-content-2 group-hover:bg-accent group-hover:text-white transition flex-shrink-0"
:title="isCurrentNewSong(song) ? (player.playing ? '暂停' : '播放') : '播放'"
>
<IconPause v-if="isCurrentNewSong(song) && player.playing" class="w-3.5 h-3.5 fill-current" />
<IconPlay v-else class="w-3.5 h-3.5 fill-current ml-0.5" />
</button>
</div>
</div>
</div>
</CardGrid>
</section>
<!-- 热门歌手 -->
<section class="mb-10">
<SectionHeader title="🎤 热门歌手" subtitle="人气音乐人" :icon="IconFlame" />
<CardGrid
:items="topArtists"
:loading="artistsLoading"
:error="artistsError"
error-text="热门歌手加载失败"
:skeleton-count="6"
grid-class="grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8"
@retry="fetchTopArtists"
>
<ArtistCard
v-for="ar in topArtists"
:key="ar.id"
:artist="ar"
@click="goArtist(ar.id)"
/>
</CardGrid>
</section>
<!-- 新碟上架 -->
<section class="mb-10">
<SectionHeader title="💿 新碟上架" subtitle="最新专辑" :icon="IconDisc" />
<CardGrid
:items="newAlbums"
:loading="albumsLoading"
:error="albumsError"
error-text="新碟上架加载失败"
@retry="fetchNewAlbums"
>
<AlbumCard
v-for="al in newAlbums"
:key="al.id"
:album="al"
:is-current-playing="player.currentSource.type === 'album' && player.currentSource.id === al.id && player.playing"
@click="goAlbum(al.id)"
@play="playAlbum(al)"
/>
</CardGrid>
</section>
<!-- 热门歌单排行榜 -->
<section>
<SectionHeader title="📈 热门歌单" subtitle="排行榜精选" :icon="IconTrendingUp" />
<CardGrid
:items="rankPlaylists"
:loading="rankLoading"
:error="rankError"
error-text="热门歌单加载失败"
@retry="fetchRankPlaylists"
>
<PlaylistCard
v-for="pl in rankPlaylists"
:key="pl.id"
:playlist="pl"
:is-current-playing="player.currentSource.type === 'playlist' && player.currentSource.id === pl.id && player.playing"
@click="goPlaylist(pl.id)"
@play="playPlaylist(pl)"
/>
</CardGrid>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, onActivated, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { MusicApi, RecApi } from '../api';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
import { useUiStore } from '../stores/ui';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
import { getCoverUrl, getArtistDisplay, normalizeSong, type Song } from '../utils/song';
import PlaylistCard, { type PlaylistCardData } from '../components/Card/PlaylistCard.vue';
import ArtistCard, { type ArtistCardData } from '../components/Card/ArtistCard.vue';
import AlbumCard, { type AlbumCardData } from '../components/Card/AlbumCard.vue';
import SectionHeader from '../components/Card/SectionHeader.vue';
import CardGrid from '../components/Card/CardGrid.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';
import IconMusic from '~icons/lucide/music';
import IconSparkles from '~icons/lucide/sparkles';
import IconTrendingUp from '~icons/lucide/trending-up';
import IconFlame from '~icons/lucide/flame';
import IconDisc from '~icons/lucide/disc-3';
defineOptions({ name: 'HomeView' });
const player = usePlayerStore();
const ui = useUiStore();
const router = useRouter();
const userStore = useUserStore();
const { isOnline } = useOnlineStatus();
const rankPlaylists = ref<any[]>([]);
const recPlaylists = ref<any[]>([]);
const todayStr = ref('');
// --- ---
const recPlaylists = ref<PlaylistCardData[]>([]);
const recLoading = ref(false);
const recError = ref(false);
// --- ---
const rankPlaylists = ref<PlaylistCardData[]>([]);
const rankLoading = ref(false);
const rankError = ref(false);
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
import { computed } from 'vue';
// --- ---
const newSongs = ref<Song[]>([]);
const newSongsLoading = ref(false);
const newSongsError = ref(false);
// --- ---
const topArtists = ref<ArtistCardData[]>([]);
const artistsLoading = ref(false);
const artistsError = ref(false);
const fmCoverUrl = computed(() => {
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || '';
});
// --- ---
const newAlbums = ref<AlbumCardData[]>([]);
const albumsLoading = ref(false);
const albumsError = ref(false);
const todayStr = ref('');
const fmCoverUrl = computed(() => getCoverUrl(player.fmSong) || '');
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
const fmDisplayArtists = computed(() => {
if (!player.fmSong) return '';
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') ||
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
return player.fmSong.ar?.map((a: { name: string }) => a.name).join(' / ') || '';
});
// FM
async function startFmPlay() {
// FM
if (!player.fmSong) {
await player.loadFm(); // loadFm fmSong
await player.loadFm();
} else {
// /
await player.toggleFm();
}
}
function onFmCardClick() {
if (!userStore.isLoggedIn) {
goLogin();
return;
}
if (!player.fmSong) {
startFmPlay();
return;
}
ui.openRoamDrawer();
}
// --- ---
async function fetchRankPlaylists() {
const cacheKey = 'home_rank';
const cached = pageCacheGet(cacheKey);
if (cached) {
rankPlaylists.value = cached;
return;
}
rankLoading.value = true;
rankError.value = false;
try {
const results = await Promise.allSettled(
RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
);
rankPlaylists.value = results
.filter(r => r.status === 'fulfilled')
.map((r: any) => {
const data = JSON.parse(r.value);
return data.playlist;
})
.filter(Boolean);
pageCacheSet(cacheKey, rankPlaylists.value);
} catch {
rankError.value = true;
} finally {
rankLoading.value = false;
}
}
async function fetchRecPlaylists() {
const cacheKey = 'home_rec';
const cached = pageCacheGet(cacheKey);
if (cached) {
recPlaylists.value = cached;
return;
}
recLoading.value = true;
recError.value = false;
try {
// recommend_resource personalized
if (userStore.isLoggedIn) {
const json = await MusicApi.recommendResource();
const data = JSON.parse(json as string);
recPlaylists.value = (data.recommend || []).slice(0, 12);
} else {
const json = await RecApi.personalized(12);
const data = JSON.parse(json as string);
recPlaylists.value = (data.result || []).slice(0, 12);
}
pageCacheSet(cacheKey, recPlaylists.value);
} catch {
recError.value = true;
} finally {
recLoading.value = false;
}
}
async function fetchNewSongs() {
const cacheKey = 'home_new_songs';
const cached = pageCacheGet(cacheKey);
if (cached) {
newSongs.value = cached;
return;
}
newSongsLoading.value = true;
newSongsError.value = false;
try {
let list: Song[];
if (userStore.isLoggedIn) {
// personalized_newsong
const json = await RecApi.personalizedNewsong(8);
const data = JSON.parse(json as string);
list = (data.result || []).slice(0, 8).map((item: any) => normalizeSong(item.song || item));
} else {
// top_song
const json = await RecApi.topSong(0);
const data = JSON.parse(json as string);
list = (data.data || []).slice(0, 8).map((s: any) => normalizeSong(s));
}
newSongs.value = list;
pageCacheSet(cacheKey, list);
} catch {
newSongsError.value = true;
} finally {
newSongsLoading.value = false;
}
}
async function fetchTopArtists() {
const cacheKey = 'home_top_artists';
const cached = pageCacheGet(cacheKey);
if (cached) {
topArtists.value = cached;
return;
}
artistsLoading.value = true;
artistsError.value = false;
try {
const json = await RecApi.topArtists(8, 0);
const data = JSON.parse(json as string);
topArtists.value = (data.artists || []).slice(0, 8);
pageCacheSet(cacheKey, topArtists.value);
} catch {
artistsError.value = true;
} finally {
artistsLoading.value = false;
}
}
async function fetchNewAlbums() {
const cacheKey = 'home_new_albums';
const cached = pageCacheGet(cacheKey);
if (cached) {
newAlbums.value = cached;
return;
}
albumsLoading.value = true;
albumsError.value = false;
try {
const json = await RecApi.albumNewest();
const data = JSON.parse(json as string);
newAlbums.value = (data.albums || []).slice(0, 12);
pageCacheSet(cacheKey, newAlbums.value);
} catch {
albumsError.value = true;
} finally {
albumsLoading.value = false;
}
}
async function loadData() {
//
await Promise.allSettled([
fetchRankPlaylists(),
fetchRecPlaylists(),
fetchNewSongs(),
fetchTopArtists(),
fetchNewAlbums(),
]);
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;
await loadData();
});
//
const results = await Promise.allSettled(
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
);
rankPlaylists.value = results
.filter(r => r.status === 'fulfilled')
.map((r: any) => {
const data = JSON.parse(r.value);
return data.playlist;
})
.filter(Boolean);
onActivated(() => {
if (pageCacheIsStale('home_rec') || pageCacheIsStale('home_new_songs')) loadData();
});
//
if (userStore.isLoggedIn) {
try {
const json = await invoke('recommend_resource');
const data = JSON.parse(json as string);
recPlaylists.value = data.recommend || [];
} catch { }
//
watch(() => userStore.isLoggedIn, (val, old) => {
if (val !== old) {
['home_rec', 'home_new_songs', 'home_rank', 'home_top_artists', 'home_new_albums']
.forEach(k => pageCacheInvalidate(k));
loadData();
}
});
watch(isOnline, (val, old) => {
if (val && !old) {
const hasAny = rankPlaylists.value.length || recPlaylists.value.length
|| newSongs.value.length || topArtists.value.length || newAlbums.value.length;
if (!hasAny) {
['home_rank', 'home_rec', 'home_new_songs', 'home_top_artists', 'home_new_albums']
.forEach(k => pageCacheInvalidate(k));
loadData();
} else {
if (rankError.value) { pageCacheInvalidate('home_rank'); fetchRankPlaylists(); }
if (recError.value) { pageCacheInvalidate('home_rec'); fetchRecPlaylists(); }
if (newSongsError.value) { pageCacheInvalidate('home_new_songs'); fetchNewSongs(); }
if (artistsError.value) { pageCacheInvalidate('home_top_artists'); fetchTopArtists(); }
if (albumsError.value) { pageCacheInvalidate('home_new_albums'); fetchNewAlbums(); }
}
}
});
// --- ---
function goDaily() {
router.push('/daily');
}
@@ -198,7 +482,61 @@ function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function goArtist(id: number) {
router.push({ name: 'artist', params: { id } });
}
function goAlbum(id: number) {
router.push({ name: 'album', params: { id } });
}
function goLogin() {
router.push('/login');
}
</script>
// --- ---
async function playPlaylist(pl: PlaylistCardData) {
try {
// /
if (player.currentSource.type === 'playlist' && player.currentSource.id === pl.id) {
player.toggle();
return;
}
const json = await MusicApi.playlistTrackAll(pl.id);
const data = JSON.parse(json as string);
const songs: Song[] = (data.songs || []).map((s: any) => normalizeSong(s));
if (songs.length > 0) {
player.playAll(songs, { type: 'playlist', id: pl.id });
}
} catch (e) {
console.error('播放歌单失败:', e);
}
}
async function playAlbum(al: AlbumCardData) {
try {
// /
if (player.currentSource.type === 'album' && player.currentSource.id === al.id) {
player.toggle();
return;
}
const json = await MusicApi.albumDetail(al.id);
const data = JSON.parse(json as string);
const songs: Song[] = (data.songs || []).map((s: any) => normalizeSong(s));
if (songs.length > 0) {
player.playAll(songs, { type: 'album', id: al.id });
}
} catch (e) {
console.error('播放专辑失败:', e);
}
}
function playNewSong(index: number) {
if (newSongs.value.length === 0) return;
player.playFromList(newSongs.value, index);
}
function isCurrentNewSong(song: Song): boolean {
return player.currentSong?.id === song.id;
}
</script>
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">本地音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<template #actions>
<button @click="cycleSort" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition-all flex items-center justify-center gap-1 whitespace-nowrap">
<IconArrowUpDown class="w-3 h-3" />
{{ sortLabel }}
</button>
<button @click="refresh" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">刷新</button>
<button @click="showFolderModal = true" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">扫描目录</button>
</template>
</PageHeader>
<div v-if="loading" class="space-y-1">
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<div v-else-if="settings.localMusicFolders.length === 0" class="text-content-3 py-4">
请先添加要扫描的文件夹
</div>
<div v-else-if="settings.enabledMusicPaths.length === 0" class="text-content-3 py-4">
请至少启用一个扫描文件夹
</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 sortedSongs"
:key="song.id + '-' + index"
:song="sortedNormalized[index]"
: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(sortedNormalized, index)"
>
<template #actions>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
<div class="relative flex-shrink-0" :ref="(el: any) => menuRefs[song.id] = el">
<button @click.stop="toggleMenu(song.id)" 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="openMenuId === song.id"
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[140px]">
<button @click.stop="openFolder(song.path)" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition whitespace-nowrap">
<IconFolderOpen class="w-3.5 h-3.5" />
打开所在文件夹
</button>
</div>
</div>
</template>
</SongListItem>
</div>
<!-- 文件夹管理弹窗 -->
<Transition name="fade">
<div v-if="showFolderModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFolderModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">扫描目录</h2>
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
<IconX class="w-5 h-5" />
</button>
</div>
<div v-if="settings.localMusicFolders.length === 0" class="text-sm text-content-3 py-4 text-center">
未添加任何文件夹
</div>
<div v-else class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
<div v-for="folder in settings.localMusicFolders" :key="folder.path" class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group">
<button @click="settings.toggleLocalMusicFolder(folder.path)" class="flex-shrink-0" :title="folder.enabled ? '点击禁用' : '点击启用'">
<IconCheckSquare v-if="folder.enabled" class="w-4 h-4 text-accent-text" />
<IconSquare v-else class="w-4 h-4 text-content-4" />
</button>
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
<span class="text-sm truncate flex-1" :class="folder.enabled ? 'text-content-2' : 'text-content-4 line-through'" :title="folder.path">{{ folder.path }}</span>
<button @click="settings.removeLocalMusicPath(folder.path)" class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100" title="移除">
<IconX class="w-4 h-4" />
</button>
</div>
</div>
<button @click="addFolder" class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition">
添加文件夹
</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, watch, onBeforeUnmount } from 'vue';
import { AppApi, DownloadApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { useSettingsStore } from '../stores/settings';
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
import { showToast } from '../composables/useToast';
import { open } from '@tauri-apps/plugin-dialog';
import SongListItem from '../components/SongListItem.vue';
import PageHeader from '../components/PageHeader.vue';
import IconFolder from '~icons/lucide/folder';
import IconFolderOpen from '~icons/lucide/folder-open';
import IconX from '~icons/lucide/x';
import IconArrowUpDown from '~icons/lucide/arrow-up-down';
import IconCheckSquare from '~icons/lucide/check-square';
import IconSquare from '~icons/lucide/square';
import IconEllipsis from '~icons/lucide/ellipsis';
defineOptions({ name: 'LocalMusicView' });
const player = usePlayerStore();
const settings = useSettingsStore();
const songs = ref<LocalSong[]>([]);
const loading = ref(true);
const showFolderModal = ref(false);
//
type SortKey = 'default' | 'name' | 'size';
const SORT_CYCLE: SortKey[] = ['default', 'name', 'size'];
const SORT_LABELS: Record<SortKey, string> = { default: '默认', name: '名称', size: '大小' };
const sortBy = ref<SortKey>('default');
const sortLabel = computed(() => SORT_LABELS[sortBy.value]);
function cycleSort() {
const idx = SORT_CYCLE.indexOf(sortBy.value);
sortBy.value = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length];
}
const sortedSongs = computed(() => {
const list = [...songs.value];
if (sortBy.value === 'name') {
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
} else if (sortBy.value === 'size') {
list.sort((a, b) => b.fileSize - a.fileSize);
}
return list;
});
const sortedNormalized = computed(() => sortedSongs.value.map(localSongToSong));
//
const openMenuId = ref<number | null>(null);
const menuRefs: Record<number, HTMLElement | null> = {};
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
async function openFolder(path: string) {
openMenuId.value = null;
try {
await AppApi.showItemInFolder(path);
} catch (e: any) {
showToast(e.toString(), 'error');
}
}
function onClickOutside(e: MouseEvent) {
if (openMenuId.value !== null) {
const el = menuRefs[openMenuId.value];
if (el && !el.contains(e.target as Node)) {
openMenuId.value = null;
}
}
}
onMounted(() => document.addEventListener('click', onClickOutside));
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
async function addFolder() {
const selected = await open({
directory: true,
multiple: false,
title: '选择音乐文件夹',
});
if (selected) {
settings.addLocalMusicPath(selected);
}
}
async function refresh() {
const paths = settings.enabledMusicPaths;
if (paths.length === 0) {
songs.value = [];
loading.value = false;
return;
}
loading.value = true;
try {
const list = await DownloadApi.scanLocalFolders(paths);
songs.value = list;
pageCacheSet('localMusic', list);
fetchMissingCovers(songs.value);
} catch (e) {
console.error('扫描本地音乐文件夹失败', e);
} finally {
loading.value = false;
}
}
onMounted(refresh);
onActivated(() => {
if (pageCacheIsStale('localMusic')) refresh();
});
watch(() => settings.enabledMusicPaths, () => { refresh(); }, { deep: true });
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+18 -46
View File
@@ -1,28 +1,26 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-950 text-white">
<div class="bg-white/5 backdrop-blur-md border border-white/10 p-8 rounded-2xl w-full max-w-sm text-center">
<div class="min-h-screen flex items-center justify-center bg-base text-content">
<div class="bg-subtle backdrop-blur-md border border-line p-8 rounded-2xl w-full max-w-sm text-center">
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
<p class="text-sm text-gray-400 mb-6">请使用网易云音乐 App 扫描二维码</p>
<!-- 二维码展示区 -->
<p class="text-sm text-content-2 mb-6">请使用网易云音乐 App 扫描二维码</p>
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
</div>
<div v-else class="w-48 h-48 bg-white/5 rounded-xl flex items-center justify-center mx-auto mb-4">
<span v-if="qrLoading" class="text-gray-400">加载中...</span>
<span v-else-if="qrError" class="text-red-400 text-sm">{{ qrError }}</span>
<div v-else class="w-48 h-48 bg-subtle rounded-xl flex items-center justify-center mx-auto mb-4">
<span v-if="qrLoading" class="text-content-2">加载中...</span>
<span v-else-if="qrError" class="text-danger text-sm">{{ qrError }}</span>
</div>
<!-- 状态提示 -->
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
<button @click="refreshQr" class="mt-4 text-xs text-green-400 hover:underline">重新获取二维码</button>
<button @click="refreshQr" class="mt-4 text-xs text-accent-text hover:underline">重新获取二维码</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { MusicApi } from '../api';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores/user';
import QRCode from 'qrcode';
@@ -33,7 +31,7 @@ const qrimg = ref('');
const qrLoading = ref(true);
const qrError = ref('');
const statusText = ref('等待扫码...');
const statusColor = ref('text-gray-400');
const statusColor = ref('text-content-2');
let qrKey = '';
let pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -54,24 +52,20 @@ async function refreshQr() {
qrError.value = '';
if (pollTimer) clearInterval(pollTimer);
try {
// 1. unikey
qrKey = await invoke('get_qr_key');
qrKey = await MusicApi.getQrKey();
if (!qrKey) {
qrError.value = '未获取到登录密钥';
qrLoading.value = false;
return;
}
// 2. create_qr
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
// 3. qrcode
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
qrimg.value = canvas.toDataURL('image/png');
qrLoading.value = false;
// 4.
startPolling();
} catch (e: any) {
qrError.value = '获取二维码失败';
@@ -79,45 +73,26 @@ async function refreshQr() {
}
}
// Canvas qrimg
// async function drawQrCode(url: string) {
// try {
// // DOM canvas
// const canvas = document.createElement('canvas');
// await QRCode.toCanvas(canvas, url, { width: 201, margin: 1 });
// // data URL
// qrimg.value = canvas.toDataURL('image/png');
// } catch (e) {
// console.error('', e);
// qrError.value = '';
// }
// }
function startPolling() {
pollTimer = setInterval(async () => {
try {
const jsonStr: string = await invoke('check_qr_status', { query: { key: qrKey } });
const jsonStr: string = await MusicApi.checkQrStatus(qrKey);
const data = JSON.parse(jsonStr);
const code = data.code;
if (code === 800) {
statusText.value = '二维码已过期,请刷新';
statusColor.value = 'text-red-400';
statusColor.value = 'text-danger';
clearInterval(pollTimer!);
} else if (code === 801) {
statusText.value = '等待扫码...';
statusColor.value = 'text-gray-400';
statusColor.value = 'text-content-2';
} else if (code === 802) {
statusText.value = '请在手机上确认登录';
statusColor.value = 'text-yellow-400';
} else if (code === 803) {
//
clearInterval(pollTimer!);
statusText.value = '登录成功!';
statusColor.value = 'text-green-400';
// cookie NcmApi线
// /login/status
// API NcmApi cookie线
// /user/account
statusColor.value = 'text-accent-text';
await fetchUserProfile();
setTimeout(() => router.push('/'), 500);
}
@@ -129,10 +104,7 @@ function startPolling() {
async function fetchUserProfile() {
try {
// login profile
// NcmApi cookie reqwest /user/account
// login Rust get_login_status
const profileJson: string = await invoke('get_login_status');
const profileJson: string = await MusicApi.getLoginStatus();
const profile = JSON.parse(profileJson);
if (profile.profile) {
userStore.setUser({
@@ -145,4 +117,4 @@ async function fetchUserProfile() {
console.error('获取用户信息失败', e);
}
}
</script>
</script>
+422 -65
View File
@@ -1,91 +1,448 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<DetailLayout ref="layoutRef">
<!-- 头部常驻滚动时收缩为紧凑态封面缩小标题缩小介绍隐藏 -->
<template #header="{ compact }">
<div class="pl-header" :class="{ 'is-compact': compact }">
<PageHeader />
<!-- 歌单信息 -->
<div v-if="playlist" class="flex gap-6 mb-8">
<img :src="playlist.coverImgUrl" class="w-40 h-40 rounded-xl object-cover shadow-lg" />
<div>
<h1 class="text-2xl font-bold">{{ playlist.name }}</h1>
<p class="text-sm text-gray-400 mt-2">{{ playlist.description }}</p>
<p class="text-xs text-gray-500 mt-2">
{{ playlist.trackCount }} 首歌曲 · 播放 {{ playlist.playCount }}
</p>
<button
@click="playAll"
class="mt-4 px-4 py-2 bg-green-500 hover:bg-green-600 rounded-full text-white font-medium transition"
>
播放全部
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="text-gray-400">加载中...</div>
<!-- 歌曲列表 -->
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="playSingle(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
<!-- 头部骨架 -->
<div v-if="!playlist && playlistLoading" class="pl-head-row">
<div class="pl-cover cover-skeleton"></div>
<div class="flex-1 space-y-3">
<div class="h-7 bg-muted rounded w-1/2 animate-pulse"></div>
<div class="h-4 bg-muted rounded w-1/3 animate-pulse"></div>
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="flex gap-3 mt-4">
<div class="h-10 w-28 bg-muted rounded-full animate-pulse"></div>
<div class="h-10 w-20 bg-muted rounded-full animate-pulse"></div>
</div>
</div>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
<!-- 头部信息 -->
<div v-else-if="playlist" class="pl-head-row">
<img :src="playlist.coverImgUrl" class="pl-cover" />
<div class="pl-info">
<div class="pl-info-top">
<h1 class="pl-title">{{ playlist.name }}</h1>
<!-- 元信息紧凑时折叠隐藏参考 SPlayer n-collapse-transition -->
<div class="pl-collapse">
<div v-if="playlist.creator" class="flex items-center gap-2 mt-2">
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
</div>
<div v-if="playlist.description" class="mt-2">
<p
ref="descEl"
class="text-sm text-content-2 leading-relaxed overflow-hidden"
style="max-height: 3em"
>{{ playlist.description }}</p>
<button
v-if="descOverflow"
@click="showDescModal = true"
class="inline-flex items-center gap-1 text-xs text-accent-text hover:text-accent-text/80 mt-1 px-2 py-0.5 rounded-full bg-accent-text/10 transition"
>
<IconChevronDown class="w-3 h-3" />
查看完整介绍
</button>
</div>
<p class="text-xs text-content-3 mt-2">
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }}
</p>
</div>
</div>
<!-- 操作行播放按钮 + tab始终常驻底部 -->
<div class="pl-actions">
<button
@click="playAll"
class="pl-play-btn"
>
<IconPlay class="w-4 h-4 fill-current" />
<span>播放全部</span>
</button>
<button
v-if="!isOwnPlaylist"
@click="toggleSubscribe"
class="pl-sub-btn"
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
>
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
{{ subscribed ? '已收藏' : '收藏歌单' }}
</button>
<!-- 歌曲 / 评论 tab紧凑态缩小 -->
<div class="pl-tabs">
<button
class="pl-tab"
:class="{ active: currentTab === 'songs' }"
@click="currentTab = 'songs'"
>歌曲<span class="pl-tab-count">{{ songs.length }}</span></button>
<button
class="pl-tab"
:class="{ active: currentTab === 'comments' }"
@click="currentTab = 'comments'"
>评论</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 内容区独立滚动歌曲 / 评论 tab 切换渲染 -->
<template #content>
<div class="px-8 pb-8 text-content">
<!-- 加载失败 -->
<div v-if="loadError" class="flex flex-col items-center justify-center py-16 gap-3">
<p class="text-content-2 text-sm">加载失败</p>
<button @click="fetchPlaylist(Number(route.params.id), true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
</div>
<template v-else>
<!-- 歌曲 tabv-if 仅在激活时渲染参考 SPlayer -->
<div v-if="currentTab === 'songs'">
<!-- 歌曲列表骨架 -->
<div v-if="songsLoading" class="space-y-1">
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<!-- 歌曲列表 -->
<VirtualSongList
v-else-if="songs.length"
:songs="songs"
:current-song-id="player.currentSong?.id"
show-index
show-like
show-download
show-menu
show-duration
show-playing-overlay
@song-click="(_s, i) => player.playFromList(songs, i)"
/>
<div v-else-if="!songsLoading" class="text-content-2">暂无歌曲</div>
</div>
<!-- 评论 tab首次切到时挂载commentsActivated=true之后保活以保留滚动位置 -->
<CommentSection
v-if="commentsActivated && playlist"
v-show="currentTab === 'comments'"
:type="2"
:id="Number(route.params.id)"
/>
</template>
</div>
</template>
</DetailLayout>
<!-- 简介弹窗 -->
<Teleport to="body">
<div v-if="showDescModal" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="showDescModal = false">
<div class="absolute inset-0 bg-black/50" @click="showDescModal = false"></div>
<div class="relative bg-surface rounded-2xl shadow-2xl max-w-lg w-full mx-4 max-h-[70vh] flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-line-2">
<h2 class="text-lg font-semibold">{{ playlist?.name }} 的介绍</h2>
<button @click="showDescModal = false" class="text-content-3 hover:text-content transition">
<IconX class="w-5 h-5" />
</button>
</div>
<div class="p-5 overflow-y-auto text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ playlist?.description }}</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted, watch, onActivated } from 'vue';
import { useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { showToast } from '../composables/useToast';
import { formatPlayCount } from '../utils/format';
import { checkOverflow } from '../utils/dom';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
import VirtualSongList from '../components/VirtualSongList.vue';
import CommentSection from '../components/CommentSection.vue';
import PageHeader from '../components/PageHeader.vue';
import DetailLayout from '../components/DetailLayout.vue';
import IconPlay from '~icons/lucide/play';
import IconBookmark from '~icons/lucide/bookmark';
import IconX from '~icons/lucide/x';
import IconChevronDown from '~icons/lucide/chevron-down';
defineOptions({ name: 'PlaylistDetailView' });
const route = useRoute();
const player = usePlayerStore();
const userStore = useUserStore();
const playlist = ref<any>(null);
const songs = ref<any[]>([]);
const loading = ref(true);
const songs = ref<Song[]>([]);
const playlistLoading = ref(true);
const songsLoading = ref(false);
const loadError = ref(false);
const subscribed = ref(false);
const showDescModal = ref(false);
const descOverflow = ref(false);
const descEl = ref<HTMLElement | null>(null);
const layoutRef = ref<InstanceType<typeof DetailLayout> | null>(null);
const currentTab = ref<'songs' | 'comments'>('songs');
// tab + v-if v-show
const commentsActivated = ref(false);
onMounted(async () => {
const id = Number(route.params.id);
try {
const jsonStr: string = await invoke('get_playlist_detail', { id });
const data = JSON.parse(jsonStr);
playlist.value = data.playlist;
songs.value = data.playlist.tracks || [];
} catch (e) {
console.error('获取歌单详情失败', e);
} finally {
loading.value = false;
}
const isOwnPlaylist = computed(() => {
if (!playlist.value || !userStore.user) return false;
return playlist.value.creator?.userId === userStore.user.userId;
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
function checkDescOverflow() {
checkOverflow(descEl, descOverflow);
}
async function playSingle(song: any) {
player.play(song);
async function fetchPlaylist(id: number, force = false) {
const cacheKey = `playlist_${id}`;
// tab
currentTab.value = 'songs';
commentsActivated.value = false;
layoutRef.value?.resetScroll();
if (!force) {
const cached = pageCacheGet(cacheKey);
if (cached) {
playlist.value = cached.playlist;
songs.value = cached.songs;
subscribed.value = cached.subscribed;
playlistLoading.value = false;
songsLoading.value = false;
loadError.value = false;
checkDescOverflow();
return;
}
}
playlistLoading.value = true;
songsLoading.value = true;
loadError.value = false;
playlist.value = null;
songs.value = [];
try {
const jsonStr: string = await MusicApi.getPlaylistDetail(id);
const data = JSON.parse(jsonStr);
playlist.value = data.playlist;
playlistLoading.value = false;
songs.value = (data.playlist.tracks || []).map(normalizeSong);
songsLoading.value = false;
subscribed.value = data.playlist.subscribed || false;
pageCacheSet(cacheKey, { playlist: playlist.value, songs: songs.value, subscribed: subscribed.value });
checkDescOverflow();
} catch (e) {
console.error('获取歌单详情失败', e);
loadError.value = true;
playlistLoading.value = false;
songsLoading.value = false;
showToast('获取歌单详情失败', 'error');
}
}
// tab /
// tab
watch(currentTab, (tab) => {
layoutRef.value?.resetScroll();
if (tab === 'comments') commentsActivated.value = true;
});
onMounted(() => {
fetchPlaylist(Number(route.params.id));
});
watch(() => route.params.id, (newId) => {
if (newId && route.name === 'playlist') fetchPlaylist(Number(newId));
});
onActivated(() => {
if (loadError.value) fetchPlaylist(Number(route.params.id), true);
});
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
</script>
async function toggleSubscribe() {
if (!playlist.value) return;
const newSubscribed = !subscribed.value;
try {
await MusicApi.playlistSubscribe(Number(playlist.value.id), newSubscribed);
subscribed.value = newSubscribed;
showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success');
} catch {
showToast('操作失败,请稍后重试', 'error');
}
}
</script>
<style scoped>
/* padding padding
参考 SPlayer .detail height 240120 过渡 */
.pl-header {
padding: 12px 32px 24px 32px;
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pl-header.is-compact {
padding: 12px 32px 12px 32px;
}
/* 头部行:封面 + 信息横向排列 */
.pl-head-row {
display: flex;
gap: 24px;
margin-top: 16px;
}
/* 封面:176→120 紧凑过渡(参考 SPlayer .cover height:100% of 240/120 */
.pl-cover {
width: 176px;
height: 176px;
border-radius: 12px;
object-fit: cover;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pl-cover.cover-skeleton {
background-color: var(--c-muted);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.pl-header.is-compact .pl-cover {
width: 96px;
height: 96px;
}
/* 信息列:纵向占满,顶部标题+折叠区,底部操作行 */
.pl-info {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
flex: 1;
}
.pl-info-top {
min-width: 0;
}
/* 标题:24→18 紧凑过渡(参考 SPlayer .name 30→22 */
.pl-title {
font-size: 24px;
font-weight: bold;
line-height: 1.3;
transition: font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pl-header.is-compact .pl-title {
font-size: 18px;
}
/* 元信息折叠区:紧凑时 max-height:0 + opacity:0 隐藏(参考 SPlayer n-collapse-transition */
.pl-collapse {
max-height: 200px;
opacity: 1;
overflow: hidden;
transition:
max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease;
}
.pl-header.is-compact .pl-collapse {
max-height: 0;
opacity: 0;
}
/* 操作行:播放按钮 + 收藏 + tab,紧凑态缩小 */
.pl-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.pl-play-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
background-color: var(--c-accent);
color: #fff;
font-weight: 500;
border-radius: 9999px;
transition:
background-color 0.2s ease,
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pl-play-btn:hover {
background-color: var(--c-accent-hover);
}
.pl-sub-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background-color: var(--c-muted);
border-radius: 9999px;
font-size: 14px;
transition:
background-color 0.2s ease,
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pl-sub-btn:hover {
background-color: var(--c-emphasis);
}
.pl-header.is-compact .pl-play-btn,
.pl-header.is-compact .pl-sub-btn {
padding: 6px 14px;
font-size: 13px;
}
/* 歌曲/评论 tab:胶囊形,紧凑态缩小 */
.pl-tabs {
display: flex;
gap: 4px;
margin-left: auto;
background-color: var(--c-subtle);
padding: 3px;
border-radius: 9999px;
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pl-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 14px;
border-radius: 9999px;
font-size: 14px;
color: var(--c-content-2);
transition:
background-color 0.2s ease,
color 0.2s ease,
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
font-size 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pl-tab.active {
background-color: var(--c-accent);
color: #fff;
}
.pl-tab-count {
font-size: 12px;
opacity: 0.7;
}
.pl-header.is-compact .pl-tab {
padding: 4px 10px;
font-size: 13px;
}
</style>
+26 -4
View File
@@ -1,6 +1,28 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">🕐 最近播放</h1>
<p class="text-gray-400">正在施工...</p>
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">最近播放</h1>
</PageHeader>
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录去听首歌吧</div>
<VirtualSongList
v-else
:songs="player.recentLocal"
:current-song-id="player.currentSong?.id"
show-index
show-like
show-download
show-menu
show-duration
show-playing-overlay
@song-click="(_s, i) => player.playFromList(player.recentLocal, i)"
/>
</div>
</template>
</template>
<script setup lang="ts">
import { usePlayerStore } from '../stores/player';
import VirtualSongList from '../components/VirtualSongList.vue';
import PageHeader from '../components/PageHeader.vue';
const player = usePlayerStore();
</script>
-126
View File
@@ -1,126 +0,0 @@
<template>
<div class="p-8 text-white flex flex-col items-center justify-center min-h-full">
<!-- 无歌曲时提示 -->
<div v-if="!currentSong" class="text-center">
<p class="text-gray-400 mb-4">私人漫游未启动</p>
<button
@click="startFm"
class="px-6 py-2 bg-white/10 hover:bg-white/20 rounded-full transition"
>
开始漫游
</button>
</div>
<!-- 歌曲信息展示 -->
<template v-else>
<!-- 专辑封面 -->
<img
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
/>
<!-- 歌曲名和艺术家 -->
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
<p class="text-lg text-gray-400 mb-8">
{{ artists }}
</p>
<!-- 控制按钮 -->
<div class="flex items-center gap-8">
<button
@click="togglePlay"
class="w-16 h-16 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20"
>
<!-- 暂停图标 -->
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<!-- 播放图标 -->
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button
@click="nextSong"
class="text-3xl text-gray-400 hover:text-white transition"
>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
const player = usePlayerStore();
// FM
const currentSong = computed(() => {
// FM FM
if (player.isFmMode && player.currentSong) {
return player.currentSong;
}
return null;
});
const artists = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
});
// FM
onMounted(async () => {
if (!player.isFmMode || !player.currentSong) {
await startFm();
}
});
async function startFm() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
player.enableFmMode(nextSong);
await player.playFmSong(song);
}
} catch (e) {
console.error('启动漫游失败', e);
}
}
function normalizeSong(song: any) {
const normalized = { ...song };
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
return normalized;
}
async function togglePlay() {
if (player.playing) {
await invoke('pause_audio');
} else {
if (player.currentSong) {
//
await invoke('resume_audio');
} else {
await startFm();
}
}
}
async function nextSong() {
await startFm();
}
</script>

Some files were not shown because too many files have changed in this diff Show More