34 Commits
Author SHA1 Message Date
lxh2875931338 3a60cabca5 fix(playback): 歌单/文件列表播放时标题不再显示百分号编码
播放路径是编码的,无元数据时直接截取文件名会显示
"%E6%9C%AA%E5%88%86..." 这种串。

- playPlaylist:统一解码后再去扩展名,DB 标题为空也回退到解码名
- PlaybackController:单曲与队列构建同样走 _displayName()
- 有标签时仍优先用标签标题
2026-11-01 16:34:44 +08:00
lxh2875931338 e10be527cb feat(playlist): 歌单变更与歌单内元数据实时刷新
两个边界场景:
1) 新建/重命名/删除歌单、加入/移除歌曲后,主页歌单页不刷新
2) 歌单详情页里歌曲补全元数据后,标题/封面不更新

- 新增 PlaylistEvents(void 事件)
- PlaylistRepository 的创建/重命名/删除/批量加入/移除后 publish
- 主页订阅后重新拉取歌单摘要
- 歌单详情页订阅 MetadataEvents,按 path 就地更新行
2026-11-01 16:32:35 +08:00
lxh2875931338 653c1f5563 fix(ui): 迷你播放器封面恢复左边距 + 列表底部留白加大
- global_mini_player 改封面兜底时误删了 Row 开头的 12px 间距,
  封面贴到屏幕左边缘;已恢复
- UIConstants.miniPlayerBottomSpace 80 -> 100,
  列表最后一项不再被迷你播放器+手势区遮住
2026-11-01 16:17:38 +08:00
lxh2875931338 708ab91ccb fix(playback): 播放页/迷你播放器立即显示已有封面,不再等元数据流程
原因有两层:
1) Song 模型没有封面路径,播放时只带 url/title/artist,
   播放页只能等元数据服务返回 artwork 字节后才显示;
2) 元数据回来后用新 Song 覆盖当前歌曲时丢了 artworkPath,
   即使带上了也会被冲掉。

- Song 增加 artworkPath 字段
- PlaybackController / 首页收藏 / playPlaylist 构造 Song 时带上
- 播放页:artwork 字节优先,其次直接读 artwork_path 文件
- 迷你播放器同样加 FileImage 兜底
- 元数据更新重建 Song 时保留 artworkPath

真机验证:点击播放后封面立即出现,不再先显示音符占位。
2026-11-01 16:15:49 +08:00
lxh2875931338 a927e3dd44 fix(favorite): 收藏状态变化时主页实时刷新
之前主页收藏列表只在启动或从文件列表返回时重查,
在播放页/文件列表点红心,主页不会变。

- 新增 FavoriteEvents(songPath 事件)
- 文件列表、播放页收藏/取消后 publish
- 主页订阅后重新拉取收藏列表
2026-11-01 15:24:28 +08:00
lxh2875931338 b6e2340aae style(ui): 收藏行高恢复至原版尺寸
封面 38->44、标题 14->16、副标题 12->13、行内边距回到 2。
媒体库区间的收紧(12 / 16)保持不变。
2026-11-01 15:12:13 +08:00
lxh2875931338 eea8e46a90 style(ui): 收藏行高回调一档
封面 32->38、标题 13->14、副标题 11->12,
行高从 ~71dp 回到 ~81dp,介于"过松"和"过小"之间。
2026-11-01 15:09:56 +08:00
lxh2875931338 fc9a630dd0 style(ui): 收紧媒体库内部间距
- WebDAV 卡片与"本地音乐/最近播放"之间:24 -> 12
- 按钮行与"我的收藏"标题之间:32 -> 16
2026-11-01 15:08:15 +08:00
lxh2875931338 dbbf215ba8 style(ui): 主页列表尺寸按设计稿收紧
- 清听 28->24、媒体库 22->20、我的收藏标题 22->20
- 本地音乐/最近播放按钮文字 14->13
- 收藏项:封面 44->32、标题 16->13、副标题 13->11,
  padding 2->0,minVerticalPadding 0
- 行高约 90dp -> 71dp,更接近设计稿的密度
2026-11-01 15:06:57 +08:00
lxh2875931338 7d701c5dfc feat(search): WebDAV 文件夹内搜索
- 列表页右上角「全部播放」换成搜索(刷新保留)
- 新页面 WebDAVSearchPage:输入关键字实时过滤当前文件夹的歌曲
- 匹配范围:文件名、元数据标题、艺术家(大小写不敏感)
- 结果显示封面/标题/艺术家,点击从该首开始播放并返回列表
- 空状态分两种:未输入提示、无结果提示

真机验证:搜索 "forza" 命中 2 首,一条显示标签标题、一条显示文件名。
2026-11-01 14:55:58 +08:00
lxh2875931338 83ab47d8fc fix(metadata): 补上事件订阅,缓存命中也推送一次
上一条只加了字段和回调,listen 没插进去(列表不会刷新)。
另外歌曲元数据在 SQLite 命中时直接 return,也不会发事件——
导致"标签其实早就在库里、列表却还显示文件名"。

- HomePage / WebDAVFileListPage 补上 stream.listen 与 cancel
- MetadataService 的 SQLite 命中分支也 publish 一次(幂等,按 path 覆盖)

验证方式:播放一首收藏里显示文件名的歌 -> 返回主页 -> 该行应变为标签标题。
2026-11-01 12:22:46 +08:00
lxh2875931338 6ca31c37ba feat(metadata): 元数据补全后主动推送界面刷新
之前标签补全后,收藏/文件列表要退出重进才显示新信息。
- 新增 MetadataEvents 事件总线(MetadataUpdate: path + songs 行)
- MetadataService 写库成功后 publish
- 首页收藏列表订阅:按 path 替换对应 SongItem(标题/艺术家/封面)
- WebDAV 文件列表订阅:按 path 更新 _metaByPath

效果:歌曲播完标签抓取完成后,列表里的那一行自动变成标签信息,
不需要返回重进。
2026-11-01 12:18:21 +08:00
lxh2875931338 632713d133 feat(ui): 主页收藏/歌单改为跟手 PageView
之前是"横向手势 + 淡入切换",与播放页的跟手滑动不一致。
- 列表区改为 SliverFillRemaining + PageView,直接跟手左右滑
- 媒体库/按钮保持固定,外层 CustomScrollView 不再滚动
- 收藏/歌单各自是独立 ListView,可正常纵向滚动
- 去掉原来的 GestureDetector 和淡入动画
- 标题行指示器继续跟随当前页

真机验证:左右滑动切换正常,指示器跟随。
2026-11-01 12:15:44 +08:00
lxh2875931338 9a932e92d4 style(ui): 小窗封面居中并加大,控件上移 8px
- 封面尺寸 30% -> 38%(上限 380dp),并在窗口中水平居中
- 控件行底部留白 12 -> 20(整体上移 8px)
2026-11-01 12:11:16 +08:00
lxh2875931338 5a1910bee4 fix(ui): 小窗播放页内容撑满窗口
紧凑模式原来用 SingleChildScrollView 直接包内容,
内容不足窗口高度时底部会留一大片空白。
改为 LayoutBuilder + ConstrainedBox(minHeight) + IntrinsicHeight:
- 内容不足时撑满并让 Spacer 把控件推到底部
- 内容超出时仍可滚动,不会把控件挤出屏幕

真机(Flyme 小窗)验证:封面缩小、歌词页隐藏、五个控件完整贴底。
2026-11-01 12:08:59 +08:00
lxh2875931338 6cb96e95dc feat(ui): 小窗/分屏自适应播放页
按可用宽高比判断紧凑模式(全屏 3192/1368≈2.33,小窗 2577/1368≈1.88):
- 封面由撑满宽度改为固定方形(可用高度的 30%,上限 340dp)
- 标题 24→20、艺术家 16→14,各段间距收紧
- 歌词页不显示(PageView 两页:信息 / 主页),指示器同步改两段
- 紧凑模式下整页可滚动,避免控件被挤出屏幕

不依赖"记住分辨率":直接读当前 MediaQuery,窗口尺寸变化时自动切换。
2026-11-01 12:06:12 +08:00
lxh2875931338 432e3b1260 fix(webdav): 重新登录成功后回到主页,而不是停在文件列表
原因:_connect() 成功后用 pushReplacement(WebDAVFileListPage),
把 Setup 页替换成文件列表,所以看起来"登录完不返回"。

- 改为 Navigator.popUntil(isFirst):无论首次配置还是重新登录,都回主页
- 首页"重新登录"入口改为 await push,返回后重新加载 WebDAV 状态并刷新
- 清理两个未使用的 import
2026-11-01 11:55:38 +08:00
lxh2875931338 7bf8d18f18 fix(ui): 加入歌单弹层不再被迷你播放器遮挡
原因:迷你播放器挂在 Navigator overlay 上,层级高于底部弹层,
弹层里的歌单列表被它盖住,看起来像"没读到歌单"。
(日志确认查询正常:读到 1 个歌单)

- RouteManager 抽到 services/route_manager.dart,新增临时隐藏开关
- 打开"加入歌单"弹层时隐藏迷你播放器,关闭后恢复
- main.dart 的 shouldShow 改用 showMiniPlayer(原来直接读路由,开关不生效)
- 弹层底部加 SafeArea minimum + 16px 余量,不再贴着手势区

真机验证:弹层正常显示 "TL / 1 首",底部留出安全距离。
2026-11-01 00:09:27 +08:00
lxh2875931338 f793223311 feat(ui): 主页收藏/歌单左右滑动切换
- 媒体库下方按钮由三个减为两个(本地音乐 / 最近播放),对称排列
- 收藏区改为双页:我的收藏 / 歌单列表,左右滑动切换
- 标题行随页变化(图标 + 文案),右侧加两点指示器(当前页长条)
- 歌单项左侧改为歌单图标(不再是歌曲封面),显示名称与歌曲数
- 点击歌单进入详情页,返回后自动刷新
- 手势挂在 CustomScrollView 外层,纵向滚动不受影响

真机验证:左右滑动切换正常,指示器跟随,歌单列表显示 TL / 1 首。
2026-10-31 23:55:02 +08:00
lxh2875931338 e89bd6381a feat(playlist): 歌单集合页 + 歌单详情页 + 首页入口
style(ui): 播放页红心与标题居中对齐、标题单行

- playlists_page:歌单列表(封面/名称/歌曲数)、新建、重命名、删除
- playlist_detail_page:歌单内歌曲、播放全部、单曲播放、移除
- 首页"歌单列表"按钮接入 PlaylistsPage
- 播放页:标题行改为 center 对齐、标题限制单行,
  收藏按钮压到 36x36,红心与标题不再错位
2026-10-31 23:37:47 +08:00
lxh2875931338 e3f72a69a9 style(ui): 播放页间距等宽 + 控制行上移 25 + 进度条重写
- 标题/歌手/专辑的间距统一为 10,视感更整齐
- 底部留白 70 -> 45(控制行上移 25)
- 进度条改用 LayoutBuilder + Positioned 绝对宽度,
  背景/缓冲/已播三层顺序明确,不再出现两端亮块
2026-10-31 23:31:54 +08:00
lxh2875931338 1c57d59d73 style(ui): 播放页内容下移 + 收藏移到标题右侧 + 底部改播放模式
按草图调整:
- 主播放页顶部留 16px,封面与指示器拉开距离
- 收藏心从底部控制行移到标题右侧(空心/实心红随收藏状态)
- 底部控制行第四位改为播放模式按钮(循环图标),队列保持在第五位
2026-10-31 23:17:01 +08:00
lxh2875931338 6351ef1395 fix(ui): 播放页顶部指示器在屏幕居中
AppBar 添加 centerTitle: true,指示器不再偏向左侧。
2026-10-31 23:03:30 +08:00
lxh2875931338 abe960cf58 style(ui): 播放页指示器改为"当前页长条"样式 + 补齐底部安全区
指示器(按设计稿):
- 三段:左点 / 中条 / 右点
- 当前页对应的段展开为 30x7 长条并高亮白色,
  另外两段收起为 7x7 小点(暗)
- 主页=中间长条,信息页=左长条,歌词页=右长条,220ms 动画过渡
- 点击任一段可直接跳到对应页

安全区:
- PageView 外层包 SafeArea(top: false),
  控制按钮不再贴着 mback 手势区
2026-10-31 22:59:27 +08:00
lxh2875931338 7cd1e13a82 feat(ui): 播放页改为三页横向切换(信息 / 主页 / 歌词)
- PageView 三页:左=文件信息,中=播放主页,右=歌词
- 顶部指示器:两点+连线,左点亮=信息页,右点亮=歌词页,
  中间都暗=主页;点圆点可直接跳转
- 文件信息页:标题/歌手/专辑/文件夹/文件名/格式/路径,
  优先用 DB 元数据,路径与文件名做解码
- 歌词页:占位(暂无歌词 + 歌名)

真机验证:慢速左右滑动切换正常,指示器跟随。
2026-10-31 22:55:16 +08:00
lxh2875931338 70227afea5 feat(playlist): 歌单数据层 + 加入歌单入口
数据层:
- PlaylistSummary(歌单名/歌曲数/封面),DB 用一条 SQL 取摘要,
  封面取歌单内第一首有封面的歌
- PlaylistRepository.getSummaries() / addSongs()(批量加入并去重)
- 修复 AudioService.playPlaylist:之前把文件路径直接当 URL,
  歌单播放必然失败;现在用 WebDAVService.getFileUrl 构造

入口(视觉为临时实现,等设计稿替换):
- 文件列表长按 -> "加入歌单" 底部弹层
- 可在弹层里新建歌单并直接加入

真机验证:长按歌曲 -> 新建 "TL" -> 数据库 playlists/playlist_songs 均有记录。
测试:新增 playlist_summary 4 个用例,累计 29 个全绿。
2026-10-31 22:43:55 +08:00
lxh2875931338 e010a9c0d3 style(ui): 去掉进度条滑块圆点
圆点是别的播放器的常见元素,我们的设计里不需要。
进度条保持"细线 + 时间在两侧"的纯净形态。
2026-10-31 22:32:47 +08:00
lxh2875931338 1604f296c8 feat(ui): 播放页改为大封面 + 左对齐信息 + 底部控制骨架
按设计稿重排(背景色保持 #0E1211 不变):
- 接近全宽的圆角封面(1:1,24 圆角)
- 标题/艺术家左对齐,下面补专辑名与所在目录
- 进度条改为"时间 + 滑块 + 时间"同一行,滑块加可见圆点
- 控制行:上一首 / 播放(68 圆) / 下一首 / 收藏 / 播放队列
- 收藏按钮接 favorites 表,状态随当前歌刷新
- 更多菜单:切换播放模式、播放队列、清除缓存
2026-10-31 22:32:10 +08:00
lxh2875931338 888ea3fd54 style(ui): 列表行距收紧 + 收藏项显示封面
- 收藏列表:vertical padding 6 -> 2,ListTile 用 compact,
  标题 17->16、副标题 14->13,行高明显变紧凑
- 收藏项 leading 从音符图标换成 SongArtwork(复用 songs.artwork_path)
- WebDAV 文件列表:contentPadding 去掉 vertical 2,加 compact

真机截图确认:行距更接近设计稿,有元数据的歌显示封面。
2026-10-31 22:05:44 +08:00
lxh2875931338 30e68780f4 feat(playback): 拥塞网络退避重试 + mpv 缓冲加大
针对公司共享出口(整栋楼抢网):
- 恢复退避:连续失败 3 次后 30s、4 次 60s、5 次 120s,
  冷却结束自动再试一次;累计 6 次后停止,等用户手动点
- 用户手动点播放会清零退避计数
- 成功出声(position>1s)后清零
- mpv 缓冲加大:cache-secs=60s、demuxer-max-bytes=64MiB、
  demuxer-readahead-secs=30

真机验证:缓冲参数设置成功,起播 1576ms(此前 2098ms)。
2026-10-31 22:03:09 +08:00
lxh2875931338 5c4b0c2728 fix(playback): 网络切换重连不再抢在切歌前面
日志(21:51,用户刚把手机切到有线):
  16.828 尝试重连:为爱痴狂(从 78s 继续)  ← 网络变化触发,但用户已点新歌
  17.569 play(新歌)
  18.128 尝试重连:Communications(从 0s)  ← 两条流程交叠
  23.669 Failed to open -> 30.459 起播 5568ms

- _onNetworkChanged:去抖开始时记录 songId + playbackGeneration,
  2s 后若已切歌则忽略本次重连
- _recoverFromStall:await 打开完成后校验 generation,
  切歌了就放弃本次恢复,避免把旧歌重新打开
2026-10-31 21:54:15 +08:00
lxh2875931338 b439d1cb8c fix(playback): 播放请求去重,避免同一次点击触发多次 open
日志证据(21:45):同一次切歌 play() 被调用 3 次
(31.798、32.839、33.442),互相顶掉,起播拖到 2977ms。

- _playCurrent 增加 800ms 同曲去重:同 index 且短时间内重复的请求直接忽略
- 实测:点击后 play() 只调用 1 次,起播 2977ms -> 2098ms

说明:剩余 ~2s 主要是 mpv 建连 + 首包 + 音频输出,
跨 FRP 链路属于正常范围,后续可单独调 mpv 参数优化。
2026-10-31 21:50:18 +08:00
lxh2875931338 fdca721250 fix(playback): 自动恢复熔断,避免坏文件导致无限重试
日志证据(21:32~21:33):同一首 "Communications - Keeno"
  Failed to open ×42,自动恢复 ×54 —— 看门狗每 10s 重开一次,死循环。

- 新增 _recoveryAttempts:同一首歌最多自动恢复 3 次
- 起播看门狗、网络切换、打开失败重试都受熔断约束
- 位置 >1s(真的出声)后清零,正常歌曲不受影响
- 切歌清零,用户手动点播放可重新尝试
- 熔断后写入 lastPlaybackError="无法播放:<title>" 并停住,不再刷请求

测试 25 个全绿;release 已安装。
2026-10-31 21:41:37 +08:00
lxh2875931338 ca4546871c fix(playback): 位置回退保护 + 恢复用最后已知位置 + 缓冲感知重试
对应 10/24 日志里的"播到一半中断后从头重播":
  23:05:26 ffurl_read -> 从 77s 重连成功
  23:05:33 再断 -> position 归 0 -> 看门狗"从 0s 继续",听感就是重新开始

- 新增 _lastKnownPosition:位置流累积,切歌归零;
  所有恢复路径(看门狗 / 网络切换 / 打开失败)都用它续播
- 位置回退保护(抽成 utils/position_guard.dart):
  已播 >10s 且新位置 <5s 时忽略回退,除非用户 seek / 切歌 / 正在恢复
- _scheduleOpenRetry 改为缓冲感知:
  缓冲余量 >=12s 时延迟 4s 重试(不打断),否则 1.2s;
  重试前若位置已推进超过 1s,判定 mpv 自愈,取消重连
- 收藏列表点击改为整队入队(setQueue,从点击项开始)

测试:新增 position_guard 6 个用例,累计 25 个全绿。
真机验证:收藏整队日志 "17 首,从第 16 首开始",播放正常。
2026-10-31 21:22:56 +08:00
25 changed files with 2345 additions and 415 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
// lib/constants/ui_constants.dart // lib/constants/ui_constants.dart
class UIConstants { class UIConstants {
static const double miniPlayerHeight = 60; static const double miniPlayerHeight = 60;
static const double miniPlayerBottomSpace = 80; // 60 + 20 安全余量 static const double miniPlayerBottomSpace = 100; // 迷你播放器 60 + 手势区/安全余量
} }
+20 -5
View File
@@ -92,14 +92,27 @@ class PlaybackController {
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
// 单曲播放(fallback) // 单曲播放(fallback)
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
/// 播放路径/文件名是百分号编码的,展示前必须解码
String _displayName(String pathOrName) {
final raw = pathOrName.split('/').last;
String decoded;
try {
decoded = Uri.decodeComponent(raw);
} catch (_) {
decoded = raw;
}
return decoded.replaceAll(RegExp(r'\.[^.]*$'), '');
}
Future<void> _playSingleSong(String songPath) async { Future<void> _playSingleSong(String songPath) async {
debugPrint('🎯 [PlaybackController] _playSingleSong: $songPath'); debugPrint('🎯 [PlaybackController] _playSingleSong: $songPath');
final url = WebDAVService.instance.getFileUrl(songPath); final url = WebDAVService.instance.getFileUrl(songPath);
final dbSong = await _db.getSongByPath(songPath); final dbSong = await _db.getSongByPath(songPath);
final song = Song( final song = Song(
id: songPath, id: songPath,
title: dbSong?['title'] as String? ?? artworkPath: dbSong?['artwork_path'] as String?,
songPath.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''), title: (dbSong?['title'] as String?)?.trim().isNotEmpty == true
? dbSong!['title'] as String
: _displayName(songPath),
artist: dbSong?['artist'] as String? ?? '未知艺术家', artist: dbSong?['artist'] as String? ?? '未知艺术家',
url: url, url: url,
); );
@@ -117,15 +130,17 @@ class PlaybackController {
if (dbSong != null) { if (dbSong != null) {
queue.add(Song( queue.add(Song(
id: item.path, id: item.path,
title: dbSong['title'] as String? ?? artworkPath: dbSong['artwork_path'] as String?,
item.name.replaceAll(RegExp(r'\.[^.]*$'), ''), title: (dbSong['title'] as String?)?.trim().isNotEmpty == true
? dbSong['title'] as String
: _displayName(item.path),
artist: dbSong['artist'] as String? ?? '未知艺术家', artist: dbSong['artist'] as String? ?? '未知艺术家',
url: url, url: url,
)); ));
} else { } else {
queue.add(Song( queue.add(Song(
id: item.path, id: item.path,
title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''), title: _displayName(item.path),
artist: '未知艺术家', artist: '未知艺术家',
url: url, url: url,
)); ));
+20
View File
@@ -313,6 +313,26 @@ class SongDatabase {
return await db.query('playlists', orderBy: 'created_at DESC'); return await db.query('playlists', orderBy: 'created_at DESC');
} }
/// 歌单摘要:歌曲数 + 封面(取歌单内第一首有封面的歌)。
/// 列表页只需要这些,不用把每首歌都拉出来。
Future<List<Map<String, dynamic>>> getPlaylistSummaries() async {
final db = await database;
return await db.rawQuery('''
SELECT p.id, p.name, p.updated_at,
(SELECT COUNT(*) FROM playlist_songs ps
WHERE ps.playlist_id = p.id) AS song_count,
(SELECT s.artwork_path FROM playlist_songs ps2
JOIN songs s ON s.remote_path = ps2.song_id
WHERE ps2.playlist_id = p.id
AND s.artwork_path IS NOT NULL
AND s.artwork_path != ''
ORDER BY ps2.order_index
LIMIT 1) AS cover_path
FROM playlists p
ORDER BY p.updated_at DESC
''');
}
Future<Map<String, dynamic>?> getPlaylist(String id) async { Future<Map<String, dynamic>?> getPlaylist(String id) async {
_logQuery('playlists', 'get', args: {'id': id}); _logQuery('playlists', 'get', args: {'id': id});
final db = await database; final db = await database;
+2 -24
View File
@@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'services/audio_service.dart'; import 'services/audio_service.dart';
import 'services/route_manager.dart';
import 'services/playback_service.dart'; import 'services/playback_service.dart';
import 'services/app_lifecycle_service.dart'; import 'services/app_lifecycle_service.dart';
import 'services/webdav_service.dart'; import 'services/webdav_service.dart';
@@ -107,28 +108,6 @@ void main() async {
}); });
} }
// ════════════════════════════════════════════════════════════
// 路由管理器(控制 MiniPlayer 显示/隐藏)
// ════════════════════════════════════════════════════════════
class RouteManager extends ChangeNotifier {
static final RouteManager _instance = RouteManager._internal();
factory RouteManager() => _instance;
RouteManager._internal();
String _currentRoute = '/';
String get currentRoute => _currentRoute;
bool get showMiniPlayer =>
_currentRoute != '/player' && _currentRoute != '/playlist';
void updateRoute(String route) {
if (_currentRoute != route) {
_currentRoute = route;
debugPrint('📌 路由更新: $route');
notifyListeners();
}
}
}
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
// 导航观察者(监听路由变化) // 导航观察者(监听路由变化)
@@ -399,8 +378,7 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
animation: RouteManager(), animation: RouteManager(),
builder: (context, _) { builder: (context, _) {
final routeManager = RouteManager(); final routeManager = RouteManager();
final shouldShow = routeManager.currentRoute != '/player' && final shouldShow = routeManager.showMiniPlayer;
routeManager.currentRoute != '/playlist';
return AnimatedSwitcher( return AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
+7
View File
@@ -7,6 +7,7 @@ import 'metadata_model.dart';
import 'metadata_reader.dart'; import 'metadata_reader.dart';
import 'metadata_normalizer.dart'; import 'metadata_normalizer.dart';
import 'metadata_stability.dart'; import 'metadata_stability.dart';
import '../services/metadata_events.dart';
import 'metadata_cache.dart'; import 'metadata_cache.dart';
import 'file_provider.dart'; import 'file_provider.dart';
import '../utils/artwork_helper.dart'; import '../utils/artwork_helper.dart';
@@ -118,6 +119,9 @@ class MetadataService {
source: 'database', source: 'database',
); );
await _memoryCache.put(fileId, metadata); await _memoryCache.put(fileId, metadata);
// 命中也推一次:列表页可能是元数据写入前加载的,
// 靠这次事件把那一行补上(幂等,订阅方按 path 覆盖)。
MetadataEvents().publish(fileId, dbSong);
return metadata; return metadata;
} }
debugPrint('🔁 [MetadataService] 记录未验证' debugPrint('🔁 [MetadataService] 记录未验证'
@@ -189,6 +193,9 @@ class MetadataService {
await _db.updateSong(songKey, row); await _db.updateSong(songKey, row);
} }
// 通知界面局部刷新(收藏/文件列表/歌单等)
MetadataEvents().publish(fileId, row);
final merged = FinalMetadata( final merged = FinalMetadata(
title: row['title'] as String? ?? '', title: row['title'] as String? ?? '',
artist: row['artist'] as String? ?? '', artist: row['artist'] as String? ?? '',
+33
View File
@@ -0,0 +1,33 @@
// lib/models/playlist_summary.dart
/// 歌单列表用的轻量摘要(不加载歌单内全部歌曲)
class PlaylistSummary {
final String id;
final String name;
final int songCount;
/// 封面:取歌单里第一首有封面的歌
final String? coverPath;
final int updatedAt;
const PlaylistSummary({
required this.id,
required this.name,
this.songCount = 0,
this.coverPath,
this.updatedAt = 0,
});
factory PlaylistSummary.fromRow(Map<String, dynamic> row) {
final cover = (row['cover_path'] as String?)?.trim() ?? '';
return PlaylistSummary(
id: row['id'] as String? ?? '',
name: (row['name'] as String?)?.trim() ?? '',
songCount: (row['song_count'] as num?)?.toInt() ?? 0,
coverPath: cover.isEmpty ? null : cover,
updatedAt: (row['updated_at'] as num?)?.toInt() ?? 0,
);
}
bool get isEmpty => songCount == 0;
}
+348 -151
View File
@@ -1,11 +1,19 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/audio_service.dart'; import '../services/audio_service.dart';
import '../services/favorite_events.dart';
import '../services/metadata_events.dart';
import '../services/playlist_events.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
import '../widgets/magnetic_scroll_physics.dart'; import '../widgets/magnetic_scroll_physics.dart';
import 'webdav_setup_page.dart'; import 'webdav_setup_page.dart';
import 'settings_page.dart'; import 'settings_page.dart';
import '../database/song_database.dart'; import '../database/song_database.dart';
import '../models/playlist_summary.dart';
import '../repositories/playlist_repository.dart';
import 'playlist_detail_page.dart';
import '../widgets/song_artwork.dart';
import 'webdav_file_list_page.dart'; import 'webdav_file_list_page.dart';
import '../constants/ui_constants.dart'; import '../constants/ui_constants.dart';
import '../base/base_state.dart'; import '../base/base_state.dart';
@@ -18,6 +26,7 @@ class SongItem {
final String fileName; final String fileName;
final String? title; final String? title;
final String? artist; final String? artist;
final String? artworkPath;
final String sourceTag; final String sourceTag;
final String metadataState; final String metadataState;
@@ -26,6 +35,7 @@ class SongItem {
required this.fileName, required this.fileName,
this.title, this.title,
this.artist, this.artist,
this.artworkPath,
required this.sourceTag, required this.sourceTag,
this.metadataState = 'unknown', this.metadataState = 'unknown',
}); });
@@ -154,6 +164,14 @@ class _HomePageState extends BaseState<HomePage> {
List<SongItem> _favorites = []; List<SongItem> _favorites = [];
bool _isLoading = true; bool _isLoading = true;
/// 0 = 我的收藏,1 = 歌单列表(左右滑动切换)
int _homeTab = 0;
final PageController _homePageController = PageController();
StreamSubscription<MetadataUpdate>? _metaSub;
StreamSubscription<String>? _favSub;
StreamSubscription<void>? _plSub;
List<PlaylistSummary> _playlistSummaries = [];
// ========== 滚动控制 ========== // ========== 滚动控制 ==========
late final ScrollController _scrollController; late final ScrollController _scrollController;
final GlobalKey _mediaLibraryKey = GlobalKey(); final GlobalKey _mediaLibraryKey = GlobalKey();
@@ -189,6 +207,9 @@ class _HomePageState extends BaseState<HomePage> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController()..addListener(_onScroll); _scrollController = ScrollController()..addListener(_onScroll);
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
_favSub = FavoriteEvents().stream.listen((_) => _onFavoriteChanged());
_plSub = PlaylistEvents().stream.listen((_) => _onPlaylistChanged());
_initWebDAV(); _initWebDAV();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -198,6 +219,10 @@ class _HomePageState extends BaseState<HomePage> {
@override @override
void dispose() { void dispose() {
_metaSub?.cancel();
_favSub?.cancel();
_plSub?.cancel();
_homePageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -228,6 +253,43 @@ class _HomePageState extends BaseState<HomePage> {
} }
} }
/// 歌单数据变化后刷新歌单页
Future<void> _onPlaylistChanged() async {
await _loadPlaylistSummaries();
if (mounted) safeSetState(() {});
}
/// 任何位置收藏/取消收藏后,重新拉一次收藏列表
Future<void> _onFavoriteChanged() async {
await _loadFavoritesFromDb();
if (mounted) safeSetState(() {});
}
/// 元数据补全后,按 path 更新收藏列表里的对应行
void _onMetadataUpdate(MetadataUpdate update) {
final index = _favorites.indexWhere((item) => item.path == update.path);
if (index < 0 || !mounted) return;
final row = update.row;
final title = (row['title'] as String?)?.trim() ?? '';
final artist = (row['artist'] as String?)?.trim() ?? '';
final artwork = (row['artwork_path'] as String?)?.trim() ?? '';
final old = _favorites[index];
safeSetState(() {
_favorites[index] = SongItem(
path: old.path,
fileName: old.fileName,
title: title.isNotEmpty ? title : old.title,
artist: artist.isNotEmpty ? artist : old.artist,
artworkPath: artwork.isNotEmpty ? artwork : old.artworkPath,
sourceTag: old.sourceTag,
metadataState: (title.isNotEmpty || old.title != null)
? 'success'
: 'unknown',
);
});
}
void _onScroll() { void _onScroll() {
if (mounted) safeSetState(() {}); if (mounted) safeSetState(() {});
} }
@@ -237,6 +299,7 @@ class _HomePageState extends BaseState<HomePage> {
try { try {
await WebDAVService.instance.loadCredentials(); await WebDAVService.instance.loadCredentials();
await _loadFavoritesFromDb(); await _loadFavoritesFromDb();
await _loadPlaylistSummaries();
} catch (e) { } catch (e) {
_favorites = []; _favorites = [];
} finally { } finally {
@@ -287,6 +350,9 @@ class _HomePageState extends BaseState<HomePage> {
fileName: displayName, fileName: displayName,
title: hasTitle ? title : null, title: hasTitle ? title : null,
artist: artist.isNotEmpty ? artist : null, artist: artist.isNotEmpty ? artist : null,
artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty == true
? (row!['artwork_path'] as String).trim()
: null,
sourceTag: folderName.isNotEmpty ? '($folderName)' : '', sourceTag: folderName.isNotEmpty ? '($folderName)' : '',
metadataState: hasTitle ? 'success' : 'unknown', metadataState: hasTitle ? 'success' : 'unknown',
)); ));
@@ -322,6 +388,7 @@ class _HomePageState extends BaseState<HomePage> {
id: song.path, id: song.path,
title: song.displayTitle, title: song.displayTitle,
artist: song.displaySubtitle, artist: song.displaySubtitle,
artworkPath: song.artworkPath,
url: url, url: url,
)); ));
} catch (e) { } catch (e) {
@@ -336,6 +403,250 @@ class _HomePageState extends BaseState<HomePage> {
} }
} }
Future<void> _loadPlaylistSummaries() async {
try {
final list = await PlaylistRepository().getSummaries();
if (!mounted) return;
setState(() => _playlistSummaries = list);
} catch (e) {
debugPrint('⚠️ [Home] 读取歌单失败: $e');
}
}
Widget _buildHomeTabIndicator() {
Widget seg(int index) {
final active = _homeTab == index;
return GestureDetector(
onTap: () => safeSetState(() => _homeTab = index),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: active ? 26 : 7,
height: 7,
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white24,
borderRadius: BorderRadius.circular(4),
),
),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
seg(0),
const SizedBox(width: 6),
seg(1),
],
);
}
Widget _buildFavoritesTab() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
);
}
if (_favorites.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite_border, size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(
20, 0, 20, UIConstants.miniPlayerBottomSpace),
itemCount: _favorites.length,
itemBuilder: (context, index) {
final song = _favorites[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
minVerticalPadding: 0,
leading: SongArtwork(
filePath: song.artworkPath,
size: 44,
fallbackIcon: Icons.music_note,
),
title: Text(
song.displayTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
song.displaySubtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(
Icons.favorite,
color: Color(0xFFE05C5C),
size: 20,
),
onPressed: () async {
await SongDatabase().removeFavorite(song.path);
await _loadFavoritesFromDb();
if (mounted) safeSetState(() {});
},
),
onTap: () => _playFavorites(index),
),
);
},
);
}
Widget _buildPlaylistsTab() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
);
}
if (_playlistSummaries.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.queue_music, size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'还没有歌单\n在文件列表长按歌曲即可加入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(
20, 0, 20, UIConstants.miniPlayerBottomSpace),
itemCount: _playlistSummaries.length,
itemBuilder: (context, index) {
final playlist = _playlistSummaries[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF1E2A28),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.queue_music,
color: Color(0xFFB8D4D0),
size: 22,
),
),
title: Text(
playlist.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${playlist.songCount} 首',
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlaylistDetailPage(
playlistId: playlist.id,
playlistName: playlist.name,
),
),
);
await _loadPlaylistSummaries();
if (mounted) safeSetState(() {});
},
),
);
},
);
}
/// 收藏列表整队入队:点哪首就从哪首开始,队列是整个收藏列表
Future<void> _playFavorites(int index) async {
if (_favorites.isEmpty) return;
try {
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先登录 WebDAV'),
backgroundColor: Colors.orange,
),
);
}
return;
}
final songs = _favorites
.map((item) => Song(
id: item.path,
title: item.displayTitle,
artist: item.displaySubtitle,
artworkPath: item.artworkPath,
url: WebDAVService.instance.getFileUrl(item.path),
))
.toList();
if (!mounted) return;
debugPrint('⭐ [Home] 收藏整队入队:${songs.length} 首,从第 $index 首开始');
await context.read<AudioService>().setQueue(songs, startIndex: index);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('播放失败: $e'), backgroundColor: Colors.red),
);
}
}
}
// ⭐ 退出/重新登录对话框 // ⭐ 退出/重新登录对话框
void _showReauthDialog() { void _showReauthDialog() {
showDialog( showDialog(
@@ -364,13 +675,17 @@ class _HomePageState extends BaseState<HomePage> {
_isLoading = false; _isLoading = false;
}); });
if (mounted) { if (mounted) {
// ⭐ 修复:用 push 保留 HomePage 在栈底 // 用 push 保留 HomePage 在栈底;登录成功后 Setup 会 popUntil 回到这里
Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const WebDAVSetupPage(), builder: (_) => const WebDAVSetupPage(),
), ),
); );
if (mounted) {
await _initWebDAV();
safeSetState(() {});
}
} }
}, },
child: const Text( child: const Text(
@@ -409,7 +724,7 @@ class _HomePageState extends BaseState<HomePage> {
const Text( const Text(
'清听', '清听',
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: Colors.white,
), ),
@@ -432,12 +747,7 @@ class _HomePageState extends BaseState<HomePage> {
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
physics: _mediaLibraryHeight > 0 physics: const NeverScrollableScrollPhysics(),
? MagneticScrollPhysics(
snapPoint: _mediaLibraryHeight,
magneticZoneStart: 0.20,
)
: const ClampingScrollPhysics(),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Opacity( child: Opacity(
@@ -451,7 +761,7 @@ class _HomePageState extends BaseState<HomePage> {
const Text( const Text(
'媒体库', '媒体库',
style: TextStyle( style: TextStyle(
fontSize: 22, fontSize: 20,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
), ),
@@ -559,7 +869,7 @@ class _HomePageState extends BaseState<HomePage> {
], ],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -579,7 +889,7 @@ class _HomePageState extends BaseState<HomePage> {
const Text( const Text(
'本地音乐', '本地音乐',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Colors.white70, color: Colors.white70,
), ),
@@ -607,35 +917,7 @@ class _HomePageState extends BaseState<HomePage> {
const Text( const Text(
'最近播放', '最近播放',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w400,
color: Colors.white70,
),
),
],
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: _ClickableTile(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.playlist_play,
size: 24,
color: const Color(0xFFB8D4D0)),
const SizedBox(width: 8),
const Text(
'歌单列表',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Colors.white70, color: Colors.white70,
), ),
@@ -647,7 +929,7 @@ class _HomePageState extends BaseState<HomePage> {
), ),
], ],
), ),
const SizedBox(height: 32), const SizedBox(height: 16),
], ],
), ),
), ),
@@ -660,127 +942,42 @@ class _HomePageState extends BaseState<HomePage> {
height: 48, height: 48,
color: const Color(0xFF0E1211), color: const Color(0xFF0E1211),
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: const Row( child: Row(
children: [ children: [
Icon(Icons.favorite, Icon(
color: Color(0xFFB8D4D0), size: 20), _homeTab == 0
SizedBox(width: 8), ? Icons.favorite
: Icons.queue_music,
color: const Color(0xFFB8D4D0),
size: 20,
),
const SizedBox(width: 8),
Text( Text(
'我的收藏', _homeTab == 0 ? '我的收藏' : '歌单列表',
style: TextStyle( style: const TextStyle(
fontSize: 22, fontSize: 20,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
), ),
), ),
const Spacer(),
_buildHomeTabIndicator(),
], ],
), ),
), ),
), ),
), ),
SliverPadding( SliverFillRemaining(
padding: const EdgeInsets.only( hasScrollBody: true,
left: 20, child: PageView(
right: 20, controller: _homePageController,
bottom: UIConstants.miniPlayerBottomSpace, onPageChanged: (index) =>
safeSetState(() => _homeTab = index),
children: [
_buildFavoritesTab(),
_buildPlaylistsTab(),
],
), ),
sliver: _isLoading
? const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(
color: Color(0xFFB8D4D0),
),
),
)
: _favorites.isEmpty
? SliverFillRemaining(
child: AnimatedOpacity(
opacity: 1.0 - _listReveal * 0.3,
duration: const Duration(milliseconds: 100),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_border,
size: 48,
color: Colors.grey[600],
),
const SizedBox(height: 16),
Text(
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
),
),
),
)
: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final song = _favorites[index];
// 收藏项不做滚动渐显:_listReveal 在首屏为 0,
// 会让条目完全透明(看起来"收藏了但不显示")。
return AnimatedOpacity(
opacity: 1.0,
duration: const Duration(milliseconds: 150),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 6),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.music_note,
color: Colors.white38,
size: 20,
),
title: Text(
song.displayTitle,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
song.displaySubtitle,
style: TextStyle(
fontSize: 14,
color: Colors.grey[400],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(
Icons.favorite,
color: Color(0xFFE05C5C),
size: 20,
),
onPressed: () async {
await SongDatabase()
.removeFavorite(song.path);
await _loadFavoritesFromDb();
if (mounted) safeSetState(() {});
},
),
onTap: () => _playSong(song),
),
),
);
},
childCount: _favorites.length,
),
),
), ),
], ],
), ),
+531 -200
View File
@@ -1,8 +1,12 @@
import 'dart:io';
// lib/pages/player_page.dart // lib/pages/player_page.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../base/base_state.dart'; import '../base/base_state.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
import '../services/favorite_events.dart';
class PlayerPage extends StatefulWidget { class PlayerPage extends StatefulWidget {
final VoidCallback? onClose; final VoidCallback? onClose;
@@ -21,6 +25,23 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends BaseState<PlayerPage> { class _PlayerPageState extends BaseState<PlayerPage> {
double? _dragProgress; double? _dragProgress;
bool _isFavorite = false;
String _album = '';
String _folder = '';
String _dbTitle = '';
String _dbArtist = '';
String? _metaSongId;
/// 0 = 文件信息,1 = 播放主页,2 = 歌词
final PageController _pageController = PageController(initialPage: 1);
int _pageIndex = 1;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
String _formatDuration(Duration d) { String _formatDuration(Duration d) {
if (d.inMilliseconds < 0) return '00:00'; if (d.inMilliseconds < 0) return '00:00';
final minutes = d.inMinutes; final minutes = d.inMinutes;
@@ -47,14 +68,12 @@ class _PlayerPageState extends BaseState<PlayerPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// ⭐ 页面加载时主动拉取状态(多次重试)
_syncStateWithRetry(); _syncStateWithRetry();
} }
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// ⭐ 依赖变化时(如从后台返回)重新同步
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
context.read<AudioService>().syncPlayerStateNow(); context.read<AudioService>().syncPlayerStateNow();
@@ -64,11 +83,8 @@ class _PlayerPageState extends BaseState<PlayerPage> {
void _syncStateWithRetry() { void _syncStateWithRetry() {
final service = context.read<AudioService>(); final service = context.read<AudioService>();
// 立即同步
service.syncPlayerStateNow(); service.syncPlayerStateNow();
// 延迟重试
const delays = [200, 500, 800, 1200]; const delays = [200, 500, 800, 1200];
for (final delay in delays) { for (final delay in delays) {
Future.delayed(Duration(milliseconds: delay), () { Future.delayed(Duration(milliseconds: delay), () {
@@ -79,6 +95,64 @@ class _PlayerPageState extends BaseState<PlayerPage> {
} }
} }
/// 元数据(专辑、收藏状态)只在切歌时查一次
Future<void> _loadMeta(Song song) async {
if (_metaSongId == song.id) return;
_metaSongId = song.id;
final db = SongDatabase();
String album = '';
String dbTitle = '';
String dbArtist = '';
bool favorite = false;
try {
final row = await db.getSongByPath(song.id);
album = (row?['album'] as String?)?.trim() ?? '';
dbTitle = (row?['title'] as String?)?.trim() ?? '';
dbArtist = (row?['artist'] as String?)?.trim() ?? '';
favorite = await db.isFavorite(song.id);
} catch (_) {
// 元数据只是装饰,失败忽略
}
// 上级目录名,作为没有专辑信息时的补充
String folder = '';
final segments =
song.id.split('/').where((s) => s.isNotEmpty).toList();
if (segments.length >= 2) {
try {
folder = Uri.decodeComponent(segments[segments.length - 2]);
} catch (_) {
folder = segments[segments.length - 2];
}
}
if (!mounted) return;
setState(() {
_album = album;
_folder = folder;
_dbTitle = dbTitle;
_dbArtist = dbArtist;
_isFavorite = favorite;
});
}
Future<void> _toggleFavorite(Song song) async {
try {
await SongDatabase().toggleFavorite(song.id);
FavoriteEvents().publish(song.id);
final favorite = await SongDatabase().isFavorite(song.id);
if (!mounted) return;
setState(() => _isFavorite = favorite);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('收藏失败: $e')),
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final service = context.watch<AudioService>(); final service = context.watch<AudioService>();
@@ -110,6 +184,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
); );
} }
_loadMeta(song);
final isPlaying = service.isPlaying; final isPlaying = service.isPlaying;
return Scaffold( return Scaffold(
@@ -118,25 +193,17 @@ class _PlayerPageState extends BaseState<PlayerPage> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70),
onPressed: _closePage, onPressed: _closePage,
), ),
title: const Text(
'正在播放',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w400,
),
),
centerTitle: true, centerTitle: true,
title: _buildPageIndicator(_isCompact),
actions: [ actions: [
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white54), icon: const Icon(Icons.more_vert, color: Colors.white54),
onSelected: (value) async { onSelected: (value) async {
if (value == 'clear_cache') { if (value == 'clear_cache') {
final service = context.read<AudioService>(); await context.read<AudioService>().clearCurrentSongCache();
await service.clearCurrentSongCache();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -145,9 +212,33 @@ class _PlayerPageState extends BaseState<PlayerPage> {
), ),
); );
} }
} else if (value == 'mode') {
context.read<AudioService>().togglePlayMode();
} else if (value == 'playlist') {
_openPlaylist();
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem(
value: 'mode',
child: Row(
children: [
Icon(service.playModeIcon, color: const Color(0xFFB8D4D0)),
const SizedBox(width: 12),
const Text('切换播放模式'),
],
),
),
const PopupMenuItem(
value: 'playlist',
child: Row(
children: [
Icon(Icons.queue_music, color: Color(0xFFB8D4D0)),
SizedBox(width: 12),
Text('播放队列'),
],
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'clear_cache', value: 'clear_cache',
child: Row( child: Row(
@@ -162,75 +253,318 @@ class _PlayerPageState extends BaseState<PlayerPage> {
), ),
], ],
), ),
body: Padding( body: SafeArea(
padding: const EdgeInsets.symmetric(horizontal: 24), top: false,
child: Column( child: PageView(
mainAxisAlignment: MainAxisAlignment.center, controller: _pageController,
onPageChanged: (index) => setState(() => _pageIndex = index),
children: [ children: [
Container( _buildInfoPage(song),
width: 240, _buildMainPage(service, song, isPlaying, compact: _isCompact),
height: 240, if (!_isCompact) _buildLyricsPage(song),
decoration: BoxDecoration( ],
shape: BoxShape.circle, ),
color: const Color(0xFF2A3332), ),
boxShadow: [ );
BoxShadow( }
color: const Color(0xFF7C9A9E).withOpacity(0.15),
blurRadius: 60, /// 小窗/分屏:宽高比明显小于全屏(1368x3192 → 2.33;1368x2577 → 1.88)
spreadRadius: 10, bool get _isCompact {
), final size = MediaQuery.of(context).size;
], if (size.width <= 0) return false;
image: song.artwork != null return size.height / size.width < 2.05;
? DecorationImage( }
image: MemoryImage(song.artwork!),
fit: BoxFit.cover, /// 顶部指示器:当前页对应"长条",另外的页是小点。
) /// 全屏三页(信息/主页/歌词),小窗两页(信息/主页)。
: null, Widget _buildPageIndicator(bool compact) {
), Widget segment(int index) {
child: song.artwork == null final active = _pageIndex == index;
? const Icon(Icons.music_note, return GestureDetector(
size: 64, color: Colors.white24) onTap: () => _pageController.animateToPage(
: null, index,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: active ? 30 : 7,
height: 7,
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white24,
borderRadius: BorderRadius.circular(4),
), ),
const SizedBox(height: 48), ),
Column( ),
crossAxisAlignment: CrossAxisAlignment.center, );
children: [ }
Text(
final indices = compact ? const [0, 1] : const [0, 1, 2];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < indices.length; i++) ...[
if (i > 0) const SizedBox(width: 6),
segment(indices[i]),
],
],
);
}
Widget _buildMainPage(AudioService service, Song song, bool isPlaying,
{required bool compact}) {
final screen = MediaQuery.of(context).size;
// 小窗:封面改为固定方形(约 30% 可用高度),给控件留出空间
final coverSide = compact
? (screen.height * 0.38).clamp(160.0, 380.0)
: null;
final artworkPath = song.artworkPath;
final hasArtworkFile = artworkPath != null &&
artworkPath.isNotEmpty &&
File(artworkPath).existsSync();
final cover = ClipRRect(
borderRadius: BorderRadius.circular(compact ? 18 : 24),
child: song.artwork != null
? Image.memory(
song.artwork!,
fit: BoxFit.cover,
gaplessPlayback: true,
)
: hasArtworkFile
? Image.file(
File(artworkPath),
fit: BoxFit.cover,
gaplessPlayback: true,
)
: Container(
color: const Color(0xFF2A3332),
child: Icon(
Icons.music_note,
size: compact ? 48 : 72,
color: Colors.white24,
),
),
);
final padded = Padding(
// 顶部留白:与 AppBar/指示器拉开距离,避免封面贴着顶部
padding: EdgeInsets.fromLTRB(24, compact ? 8 : 24, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---- 封面 ----
if (coverSide != null)
Center(
child: SizedBox(
width: coverSide,
height: coverSide,
child: cover,
),
)
else
AspectRatio(
aspectRatio: 1,
child: cover,
),
SizedBox(height: compact ? 12 : 28),
// ---- 标题 / 艺术家(收藏心在标题右侧)----
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
song.title, song.title,
style: const TextStyle( style: TextStyle(
fontSize: 20, fontSize: compact ? 20 : 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: Colors.white,
height: 1.25,
), ),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
song.artist,
style: TextStyle(
fontSize: 15,
color: Colors.grey[400],
),
textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ),
const SizedBox(width: 8),
IconButton(
onPressed: () => _toggleFavorite(song),
tooltip: '收藏',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 36, minHeight: 36),
iconSize: 24,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
? const Color(0xFFE05C5C)
: Colors.white54,
size: 24,
),
),
],
),
const SizedBox(height: 2),
Text(
song.artist,
style: TextStyle(
fontSize: compact ? 14 : 16,
color: Colors.grey[300],
), ),
const SizedBox(height: 40), maxLines: 1,
_buildProgressSection(service), overflow: TextOverflow.ellipsis,
const SizedBox(height: 32), ),
_buildControlButtons(service, isPlaying),
const SizedBox(height: 16), // ---- 信息区(专辑 / 目录)----
if (_album.isNotEmpty || _folder.isNotEmpty) ...[
const SizedBox(height: 10),
if (_album.isNotEmpty)
Text(
_album,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
height: 1.5,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (_folder.isNotEmpty)
Text(
'($_folder)',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
height: 1.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
], ],
const Spacer(),
// ---- 进度 ----
_buildProgressSection(service),
SizedBox(height: compact ? 10 : 18),
// ---- 控制 ----
_buildControlButtons(service, song, isPlaying),
// 底部多留 50px:控制行整体上移
SizedBox(height: compact ? 20 : 45),
],
),
);
if (!compact) return padded;
// 小窗/横屏:内容超高时可滚动;内容不足时撑满,避免底部大片空白
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(child: padded),
), ),
), ),
); );
} }
/// 左页:文件信息
Widget _buildInfoPage(Song song) {
final rawName = song.id.split('/').last;
String fileName = rawName;
try {
fileName = Uri.decodeComponent(rawName);
} catch (_) {}
final dot = fileName.lastIndexOf('.');
final format =
dot > 0 ? fileName.substring(dot + 1).toUpperCase() : '未知';
String decodedPath = song.id;
try {
decodedPath = Uri.decodeComponent(song.id);
} catch (_) {}
final effectiveArtist = _dbArtist.isNotEmpty
? _dbArtist
: (song.artist.isEmpty ? '未知' : song.artist);
final effectiveTitle = _dbTitle.isNotEmpty ? _dbTitle : song.title;
final rows = <MapEntry<String, String>>[
MapEntry('标题', effectiveTitle),
MapEntry('歌手', effectiveArtist),
MapEntry('专辑', _album.isEmpty ? '未知' : _album),
MapEntry('文件夹', _folder.isEmpty ? '根目录' : _folder),
MapEntry('文件名', fileName),
MapEntry('格式', format),
MapEntry('路径', decodedPath),
];
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'文件信息',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 20),
for (final row in rows) ...[
Text(
row.key,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
row.value,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
height: 1.4,
),
),
const SizedBox(height: 18),
],
],
),
);
}
/// 右页:歌词(占位)
Widget _buildLyricsPage(Song song) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lyrics_outlined, size: 44, color: Colors.grey[700]),
const SizedBox(height: 14),
Text(
'暂无歌词',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 6),
Text(
song.title,
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildProgressSection(AudioService service) { Widget _buildProgressSection(AudioService service) {
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: service.positionNotifier, valueListenable: service.positionNotifier,
@@ -238,7 +572,6 @@ class _PlayerPageState extends BaseState<PlayerPage> {
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: service.durationNotifier, valueListenable: service.durationNotifier,
builder: (context, duration, _) { builder: (context, duration, _) {
// ⭐ 如果 duration 为 0,显示加载指示器
if (duration.inMilliseconds == 0) { if (duration.inMilliseconds == 0) {
return const Padding( return const Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),
@@ -249,115 +582,118 @@ class _PlayerPageState extends BaseState<PlayerPage> {
); );
} }
final progress = duration.inMilliseconds > 0 final progress = position.inMilliseconds / duration.inMilliseconds;
? position.inMilliseconds / duration.inMilliseconds final bufferProgress = (service
: 0.0; .bufferedNotifier.value.inMilliseconds /
duration.inMilliseconds)
final bufferProgress = duration.inMilliseconds > 0 .clamp(0.0, 1.0);
? (service.bufferedNotifier.value.inMilliseconds /
duration.inMilliseconds)
.clamp(0.0, 1.0)
: 0.0;
final displayProgress = _dragProgress ?? progress; final displayProgress = _dragProgress ?? progress;
return Column( return Row(
children: [ children: [
SizedBox( Text(
height: 20, _formatDuration(
child: LayoutBuilder( _dragProgress != null
builder: (context, constraints) { ? Duration(
final width = constraints.maxWidth; milliseconds:
final bufferWidth = (_dragProgress! * duration.inMilliseconds)
width * bufferProgress.clamp(0.0, 1.0); .round(),
final progressWidth = )
width * displayProgress.clamp(0.0, 1.0); : position,
),
return Stack( style: TextStyle(fontSize: 12, color: Colors.grey[500]),
alignment: Alignment.centerLeft, ),
children: [ const SizedBox(width: 10),
Container( Expanded(
width: width, child: SizedBox(
height: 4, height: 24,
decoration: BoxDecoration( child: LayoutBuilder(
color: Colors.grey[800], builder: (context, constraints) {
borderRadius: BorderRadius.circular(2), final width = constraints.maxWidth;
), return Stack(
), alignment: Alignment.centerLeft,
Container( children: [
width: bufferWidth, // 背景轨道
height: 4, Positioned(
decoration: BoxDecoration( left: 0,
color: const Color(0xFF4A7A7A), right: 0,
borderRadius: BorderRadius.circular(2), top: 10.5,
), child: Container(
), height: 3,
Container( decoration: BoxDecoration(
width: progressWidth, color: Colors.grey[800],
height: 4, borderRadius: BorderRadius.circular(2),
decoration: BoxDecoration(
color: const Color(0xFFB8D4D0),
borderRadius: BorderRadius.circular(2),
),
),
Positioned.fill(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 0,
activeTrackColor: Colors.transparent,
inactiveTrackColor: Colors.transparent,
thumbColor: Colors.transparent,
overlayColor: Colors.transparent,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 0,
), ),
), ),
child: Slider( ),
value: displayProgress.clamp(0.0, 1.0), // 已缓冲
min: 0.0, Positioned(
max: 1.0, left: 0,
onChanged: (value) { top: 10.5,
setState(() { child: Container(
_dragProgress = value; width: width * bufferProgress.clamp(0.0, 1.0),
}); height: 3,
}, decoration: BoxDecoration(
onChangeEnd: (value) { color: const Color(0xFF4A7A7A),
final newPosition = Duration( borderRadius: BorderRadius.circular(2),
milliseconds: ),
(value * duration.inMilliseconds)
.round(),
);
service.seekTo(newPosition);
setState(() {
_dragProgress = null;
});
},
), ),
), ),
), // 已播放
], Positioned(
); left: 0,
}, top: 10.5,
child: Container(
width:
width * displayProgress.clamp(0.0, 1.0),
height: 3,
decoration: BoxDecoration(
color: const Color(0xFFB8D4D0),
borderRadius: BorderRadius.circular(2),
),
),
),
Positioned.fill(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 0,
activeTrackColor: Colors.transparent,
inactiveTrackColor: Colors.transparent,
thumbColor: Colors.transparent,
overlayColor: Colors.transparent,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 0,
),
),
child: Slider(
value: displayProgress.clamp(0.0, 1.0),
min: 0.0,
max: 1.0,
onChanged: (value) {
setState(() => _dragProgress = value);
},
onChangeEnd: (value) {
final newPosition = Duration(
milliseconds:
(value * duration.inMilliseconds)
.round(),
);
service.seekTo(newPosition);
setState(() => _dragProgress = null);
},
),
),
),
],
);
},
),
), ),
), ),
Row( const SizedBox(width: 10),
mainAxisAlignment: MainAxisAlignment.spaceBetween, Text(
children: [ _formatDuration(duration),
Text( style: TextStyle(fontSize: 12, color: Colors.grey[500]),
_formatDuration(position),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
), ),
], ],
); );
@@ -367,61 +703,56 @@ class _PlayerPageState extends BaseState<PlayerPage> {
); );
} }
Widget _buildControlButtons(AudioService service, bool isPlaying) { Widget _buildControlButtons(
AudioService service, Song song, bool isPlaying) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
IconButton(
onPressed: service.togglePlayMode,
icon: Icon(
service.playModeIcon,
color: const Color(0xFFB8D4D0),
size: 22,
),
padding: const EdgeInsets.all(12),
),
const SizedBox(width: 4),
IconButton( IconButton(
onPressed: service.hasQueue ? service.previous : null, onPressed: service.hasQueue ? service.previous : null,
icon: const Icon(Icons.skip_previous, size: 28), icon: const Icon(Icons.skip_previous_rounded, size: 34),
color: service.hasQueue ? Colors.white60 : Colors.grey[600], color: service.hasQueue ? Colors.white70 : Colors.grey[700],
padding: const EdgeInsets.all(12),
), ),
const SizedBox(width: 20),
Container( Container(
width: 56, width: 68,
height: 56, height: 68,
decoration: BoxDecoration( decoration: const BoxDecoration(
color: const Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow, isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: Colors.black87, color: Colors.black87,
size: 28, size: 36,
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: service.togglePlay, onPressed: service.togglePlay,
), ),
), ),
const SizedBox(width: 20),
IconButton( IconButton(
onPressed: service.hasQueue ? service.next : null, onPressed: service.hasQueue ? service.next : null,
icon: const Icon(Icons.skip_next, size: 28), icon: const Icon(Icons.skip_next_rounded, size: 34),
color: service.hasQueue ? Colors.white60 : Colors.grey[600], color: service.hasQueue ? Colors.white70 : Colors.grey[700],
padding: const EdgeInsets.all(12), ),
IconButton(
onPressed: service.togglePlayMode,
icon: Icon(
service.playModeIcon,
color: const Color(0xFFB8D4D0),
size: 26,
),
tooltip: '播放模式',
), ),
IconButton( IconButton(
onPressed: _openPlaylist, onPressed: _openPlaylist,
icon: const Icon( icon: const Icon(
Icons.playlist_play_outlined, Icons.queue_music_rounded,
color: Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
size: 26, size: 26,
), ),
padding: const EdgeInsets.all(8), tooltip: '播放队列',
), ),
const SizedBox(width: 4),
], ],
); );
} }
+211
View File
@@ -0,0 +1,211 @@
import 'dart:async';
// lib/pages/playlist_detail_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../database/song_database.dart';
import '../repositories/playlist_repository.dart';
import '../services/audio_service.dart';
import '../services/metadata_events.dart';
import '../widgets/song_artwork.dart';
/// 歌单详情:展示歌单内歌曲,支持播放全部 / 单曲播放 / 移除
class PlaylistDetailPage extends StatefulWidget {
final String playlistId;
final String playlistName;
const PlaylistDetailPage({
super.key,
required this.playlistId,
required this.playlistName,
});
@override
State<PlaylistDetailPage> createState() => _PlaylistDetailPageState();
}
class _SongRow {
final String path;
final String title;
final String artist;
final String? artworkPath;
const _SongRow({
required this.path,
required this.title,
required this.artist,
this.artworkPath,
});
}
class _PlaylistDetailPageState extends State<PlaylistDetailPage> {
final PlaylistRepository _repo = PlaylistRepository();
final SongDatabase _db = SongDatabase();
List<_SongRow> _songs = [];
StreamSubscription<MetadataUpdate>? _metaSub;
bool _loading = true;
@override
void initState() {
super.initState();
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
_load();
}
@override
void dispose() {
_metaSub?.cancel();
super.dispose();
}
/// 歌单里的歌补全元数据后,就地更新对应行
void _onMetadataUpdate(MetadataUpdate update) {
final index = _songs.indexWhere((item) => item.path == update.path);
if (index < 0 || !mounted) return;
final row = update.row;
final title = (row['title'] as String?)?.trim() ?? '';
final artist = (row['artist'] as String?)?.trim() ?? '';
final artwork = (row['artwork_path'] as String?)?.trim() ?? '';
final old = _songs[index];
setState(() {
_songs[index] = _SongRow(
path: old.path,
title: title.isNotEmpty ? title : old.title,
artist: artist.isNotEmpty ? artist : old.artist,
artworkPath: artwork.isNotEmpty ? artwork : old.artworkPath,
);
});
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final ids = await _repo.getPlaylistSongIds(widget.playlistId);
final meta = await _db.getSongsByPaths(ids);
final rows = <_SongRow>[];
for (final id in ids) {
final row = meta[id];
final title = (row?['title'] as String?)?.trim() ?? '';
final artist = (row?['artist'] as String?)?.trim() ?? '';
String fallbackName = id.split('/').last;
try {
fallbackName = Uri.decodeComponent(fallbackName);
} catch (_) {}
rows.add(_SongRow(
path: id,
title: title.isNotEmpty
? title
: fallbackName.replaceAll(RegExp(r'\.[^.]*$'), ''),
artist: artist.isNotEmpty ? artist : '未知艺术家',
artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty ==
true
? (row!['artwork_path'] as String).trim()
: null,
));
}
if (!mounted) return;
setState(() {
_songs = rows;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('读取歌单失败: $e')));
}
}
Future<void> _play(int index) async {
await context
.read<AudioService>()
.playPlaylist(widget.playlistId, startIndex: index);
}
Future<void> _remove(_SongRow song) async {
await _repo.removeSong(widget.playlistId, song.path);
await _load();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(widget.playlistName),
actions: [
IconButton(
icon: const Icon(Icons.play_arrow_rounded, color: Color(0xFFB8D4D0)),
tooltip: '播放全部',
onPressed: _songs.isEmpty ? null : () => _play(0),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _songs.isEmpty
? _buildEmpty()
: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _songs.length,
itemBuilder: (context, index) {
final song = _songs[index];
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
leading: SongArtwork(
filePath: song.artworkPath,
size: 44,
fallbackIcon: Icons.music_note,
),
title: Text(
song.title,
style:
const TextStyle(color: Colors.white, fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
song.artist,
style: const TextStyle(color: Colors.grey, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.close,
color: Colors.white24, size: 18),
tooltip: '从歌单移除',
onPressed: () => _remove(song),
),
onTap: () => _play(index),
);
},
),
);
}
Widget _buildEmpty() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.music_off_outlined, size: 44, color: Colors.grey[700]),
const SizedBox(height: 14),
Text(
'歌单还是空的',
style: TextStyle(color: Colors.grey[500], fontSize: 15),
),
const SizedBox(height: 6),
Text(
'在文件列表长按歌曲即可加入',
style: TextStyle(color: Colors.grey[700], fontSize: 12),
),
],
),
);
}
}
+241
View File
@@ -0,0 +1,241 @@
// lib/pages/playlists_page.dart
import 'package:flutter/material.dart';
import '../models/playlist_summary.dart';
import '../repositories/playlist_repository.dart';
import '../widgets/song_artwork.dart';
import 'playlist_detail_page.dart';
/// 歌单集合页:展示所有歌单,支持新建 / 重命名 / 删除
class PlaylistsPage extends StatefulWidget {
const PlaylistsPage({super.key});
@override
State<PlaylistsPage> createState() => _PlaylistsPageState();
}
class _PlaylistsPageState extends State<PlaylistsPage> {
final PlaylistRepository _repo = PlaylistRepository();
List<PlaylistSummary> _playlists = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final list = await _repo.getSummaries();
if (!mounted) return;
setState(() {
_playlists = list;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('读取歌单失败: $e')),
);
}
}
Future<void> _create() async {
final name = await _promptName('新建歌单');
if (name == null || name.isEmpty) return;
try {
await _repo.createPlaylist(name);
await _load();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('新建失败: $e')));
}
}
Future<void> _rename(PlaylistSummary playlist) async {
final name = await _promptName('重命名歌单', initial: playlist.name);
if (name == null || name.isEmpty) return;
await _repo.updatePlaylistName(playlist.id, name);
await _load();
}
Future<void> _delete(PlaylistSummary playlist) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text('删除歌单', style: TextStyle(color: Colors.white)),
content: Text(
'删除「${playlist.name}」?歌单内的歌曲不会从网盘删除。',
style: const TextStyle(color: Colors.grey),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
if (ok == true) {
await _repo.deletePlaylist(playlist.id);
await _load();
}
}
Future<String?> _promptName(String title, {String initial = ''}) async {
final controller = TextEditingController(text: initial);
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: Text(title, style: const TextStyle(color: Colors.white)),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(hintText: '歌单名称'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('确定'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: const Text('歌单'),
actions: [
IconButton(
icon: const Icon(Icons.add, color: Color(0xFFB8D4D0)),
tooltip: '新建歌单',
onPressed: _create,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _playlists.isEmpty
? _buildEmpty()
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
itemCount: _playlists.length,
itemBuilder: (context, index) {
final playlist = _playlists[index];
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
leading: SongArtwork(
filePath: playlist.coverPath,
size: 52,
fallbackIcon: Icons.queue_music,
),
title: Text(
playlist.name,
style: const TextStyle(
color: Colors.white, fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${playlist.songCount} 首',
style: const TextStyle(
color: Colors.grey, fontSize: 12),
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
color: Colors.white38, size: 20),
onSelected: (value) {
if (value == 'rename') _rename(playlist);
if (value == 'delete') _delete(playlist);
},
itemBuilder: (context) => const [
PopupMenuItem(
value: 'rename',
child: Row(
children: [
Icon(Icons.edit_outlined,
color: Color(0xFFB8D4D0), size: 18),
SizedBox(width: 10),
Text('重命名'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
color: Colors.redAccent, size: 18),
SizedBox(width: 10),
Text('删除'),
],
),
),
],
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlaylistDetailPage(
playlistId: playlist.id,
playlistName: playlist.name,
),
),
);
await _load();
},
);
},
),
),
);
}
Widget _buildEmpty() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.queue_music, size: 48, color: Colors.grey[700]),
const SizedBox(height: 14),
Text(
'还没有歌单',
style: TextStyle(color: Colors.grey[500], fontSize: 15),
),
const SizedBox(height: 6),
Text(
'点右上角 + 新建一个',
style: TextStyle(color: Colors.grey[700], fontSize: 12),
),
],
),
);
}
}
+33 -5
View File
@@ -1,3 +1,4 @@
import 'dart:async';
// lib/pages/webdav_file_list_page.dart // lib/pages/webdav_file_list_page.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
@@ -6,7 +7,11 @@ import '../base/base_state.dart';
import '../controllers/playback_controller.dart'; import '../controllers/playback_controller.dart';
import '../database/song_database.dart'; import '../database/song_database.dart';
import '../services/audio_service.dart'; import '../services/audio_service.dart';
import '../services/favorite_events.dart';
import '../services/metadata_events.dart';
import '../widgets/song_artwork.dart'; import '../widgets/song_artwork.dart';
import '../widgets/add_to_playlist_sheet.dart';
import 'webdav_search_page.dart';
class WebDAVFileListPage extends StatefulWidget { class WebDAVFileListPage extends StatefulWidget {
final String currentPath; final String currentPath;
@@ -26,6 +31,7 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家 /// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
Map<String, Map<String, dynamic>> _metaByPath = {}; Map<String, Map<String, dynamic>> _metaByPath = {};
String? _lastSyncedSongPath; String? _lastSyncedSongPath;
StreamSubscription<MetadataUpdate>? _metaSub;
/// 已收藏的歌曲路径 /// 已收藏的歌曲路径
Set<String> _favoritePaths = {}; Set<String> _favoritePaths = {};
@@ -44,15 +50,25 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
super.initState(); super.initState();
_currentPath = widget.currentPath; _currentPath = widget.currentPath;
AudioService().addListener(_onPlaybackChanged); AudioService().addListener(_onPlaybackChanged);
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
_loadDirectory(); _loadDirectory();
} }
@override @override
void dispose() { void dispose() {
_metaSub?.cancel();
AudioService().removeListener(_onPlaybackChanged); AudioService().removeListener(_onPlaybackChanged);
super.dispose(); super.dispose();
} }
/// 元数据补全后,按 path 更新列表行的标题/艺术家/封面
void _onMetadataUpdate(MetadataUpdate update) {
if (!_items.any((item) => item.path == update.path)) return;
if (!mounted) return;
safeSetState(() {
_metaByPath = {..._metaByPath, update.path: update.row};
});
}
/// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行 /// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行
void _onPlaybackChanged() { void _onPlaybackChanged() {
final path = AudioService().currentSong?.id; final path = AudioService().currentSong?.id;
@@ -77,6 +93,7 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
final wasFavorite = _favoritePaths.contains(path); final wasFavorite = _favoritePaths.contains(path);
try { try {
await SongDatabase().toggleFavorite(path); await SongDatabase().toggleFavorite(path);
FavoriteEvents().publish(path);
if (!mounted) return; if (!mounted) return;
safeSetState(() { safeSetState(() {
final next = {..._favoritePaths}; final next = {..._favoritePaths};
@@ -264,9 +281,17 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
), ),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.playlist_play), icon: const Icon(Icons.search),
onPressed: _playAll, onPressed: () => Navigator.push(
tooltip: '全部播放', context,
MaterialPageRoute(
builder: (_) => WebDAVSearchPage(
directoryPath: _currentPath,
items: _items,
),
),
),
tooltip: '搜索本文件夹',
), ),
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
@@ -351,7 +376,8 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
if (item.isDirectory) { if (item.isDirectory) {
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), contentPadding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
leading: const Icon( leading: const Icon(
Icons.folder_outlined, Icons.folder_outlined,
color: Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
@@ -397,7 +423,8 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
].join(' · '); ].join(' · ');
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), contentPadding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
leading: SongArtwork( leading: SongArtwork(
filePath: meta?['artwork_path'] as String?, filePath: meta?['artwork_path'] as String?,
size: 44, size: 44,
@@ -433,6 +460,7 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
onPressed: () => _toggleFavorite(item.path), onPressed: () => _toggleFavorite(item.path),
), ),
onTap: () => _playSong(item), onTap: () => _playSong(item),
onLongPress: () => showAddToPlaylistSheet(context, songPath: item.path),
); );
} }
} }
+215
View File
@@ -0,0 +1,215 @@
// lib/pages/webdav_search_page.dart
import 'package:flutter/material.dart';
import '../controllers/playback_controller.dart';
import '../database/song_database.dart';
import '../services/webdav_service.dart';
import '../widgets/song_artwork.dart';
/// 在当前 WebDAV 文件夹内搜索歌曲
class WebDAVSearchPage extends StatefulWidget {
final String directoryPath;
final List<WebDAVItem> items;
const WebDAVSearchPage({
super.key,
required this.directoryPath,
required this.items,
});
@override
State<WebDAVSearchPage> createState() => _WebDAVSearchPageState();
}
class _WebDAVSearchPageState extends State<WebDAVSearchPage> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
/// path -> songs 表行
Map<String, Map<String, dynamic>> _meta = {};
String _query = '';
List<WebDAVItem> get _files =>
widget.items.where((item) => !item.isDirectory).toList();
@override
void initState() {
super.initState();
_loadMeta();
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _loadMeta() async {
try {
final paths = _files.map((item) => item.path).toList();
final meta = await SongDatabase().getSongsByPaths(paths);
if (!mounted) return;
setState(() => _meta = meta);
} catch (e) {
debugPrint('⚠️ [Search] 读取元数据失败: $e');
}
}
String _decode(String input) {
try {
return Uri.decodeComponent(input);
} catch (_) {
return input;
}
}
List<WebDAVItem> get _results {
final keyword = _query.trim().toLowerCase();
if (keyword.isEmpty) return const [];
return _files.where((item) {
final name = _decode(item.name).toLowerCase();
final meta = _meta[item.path];
final title = (meta?['title'] as String? ?? '').toLowerCase();
final artist = (meta?['artist'] as String? ?? '').toLowerCase();
return name.contains(keyword) ||
title.contains(keyword) ||
artist.contains(keyword);
}).toList();
}
Future<void> _play(WebDAVItem item) async {
try {
await PlaybackController().playFromWebDAV(
directoryPath: widget.directoryPath,
clickedSongPath: item.path,
allItems: widget.items,
);
if (mounted) Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('播放失败: $e')),
);
}
}
@override
Widget build(BuildContext context) {
final results = _results;
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: Colors.white,
titleSpacing: 0,
title: TextField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
style: const TextStyle(color: Colors.white, fontSize: 16),
cursorColor: const Color(0xFFB8D4D0),
decoration: const InputDecoration(
hintText: '搜索本文件夹的歌曲',
hintStyle: TextStyle(color: Colors.white38, fontSize: 15),
border: InputBorder.none,
),
onChanged: (value) => setState(() => _query = value),
),
actions: [
if (_query.isNotEmpty)
IconButton(
icon: const Icon(Icons.close, color: Colors.white54),
tooltip: '清除',
onPressed: () {
_controller.clear();
setState(() => _query = '');
},
),
],
),
body: _query.trim().isEmpty
? _buildHint()
: results.isEmpty
? _buildEmpty()
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
itemCount: results.length,
itemBuilder: (context, index) {
final item = results[index];
final meta = _meta[item.path];
final metaTitle =
(meta?['title'] as String?)?.trim() ?? '';
final metaArtist =
(meta?['artist'] as String?)?.trim() ?? '';
final artwork =
(meta?['artwork_path'] as String?)?.trim() ?? '';
final name = _decode(item.name);
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
leading: SongArtwork(
filePath: artwork.isNotEmpty ? artwork : null,
size: 44,
fallbackIcon: Icons.audiotrack,
),
title: Text(
metaTitle.isNotEmpty ? metaTitle : name,
style: const TextStyle(
color: Colors.white, fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
metaArtist.isNotEmpty
? metaArtist
: (item.size != null
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
: ''),
style: const TextStyle(
color: Colors.grey, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => _play(item),
);
},
),
);
}
Widget _buildHint() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 48, color: Colors.grey[700]),
const SizedBox(height: 14),
Text(
'输入歌名、歌手或文件名',
style: TextStyle(color: Colors.grey[500], fontSize: 14),
),
const SizedBox(height: 6),
Text(
'只在当前文件夹内搜索',
style: TextStyle(color: Colors.grey[700], fontSize: 12),
),
],
),
);
}
Widget _buildEmpty() {
return Center(
child: Text(
'没有找到「${_query.trim()}」',
style: TextStyle(color: Colors.grey[500], fontSize: 14),
),
);
}
}
+4 -9
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
import 'webdav_file_list_page.dart';
import '../base/base_state.dart'; import '../base/base_state.dart';
class WebDAVSetupPage extends StatefulWidget { class WebDAVSetupPage extends StatefulWidget {
@@ -72,14 +71,10 @@ class _WebDAVSetupPageState extends BaseState<WebDAVSetupPage> {
backgroundColor: Color(0xFF4CAF50), backgroundColor: Color(0xFF4CAF50),
), ),
); );
// ⭐ 用 pushReplacement 替换当前页面(WebDAVSetupPage) // 登录成功后直接回到主页:
// 此时栈底是 HomePage,替换后栈变为 [HomePage, WebDAVFileListPage] // - 首次配置:用户配完先进主页,再自己点 WebDAV 进入文件列表
Navigator.pushReplacement( // - 重新登录:从主页 push 进来,直接回主页,不会停在文件列表
context, Navigator.of(context).popUntil((route) => route.isFirst);
MaterialPageRoute(
builder: (_) => const WebDAVFileListPage(),
),
);
} }
} catch (e) { } catch (e) {
safeSetState(() { safeSetState(() {
+26
View File
@@ -2,6 +2,8 @@
import 'dart:math'; import 'dart:math';
import '../database/song_database.dart'; import '../database/song_database.dart';
import '../models/playlist.dart'; import '../models/playlist.dart';
import '../models/playlist_summary.dart';
import '../services/playlist_events.dart';
import '../services/audio_service.dart'; import '../services/audio_service.dart';
class PlaylistRepository { class PlaylistRepository {
@@ -34,14 +36,36 @@ class PlaylistRepository {
Future<Playlist> createPlaylist(String name) async { Future<Playlist> createPlaylist(String name) async {
final playlist = Playlist(id: _generateId(), name: name); final playlist = Playlist(id: _generateId(), name: name);
await _db.insertPlaylist(playlist.toJson()); await _db.insertPlaylist(playlist.toJson());
PlaylistEvents().publish();
return playlist; return playlist;
} }
/// 歌单列表摘要(歌曲数 + 封面),给主页/列表页用
Future<List<PlaylistSummary>> getSummaries() async {
final rows = await _db.getPlaylistSummaries();
return rows.map(PlaylistSummary.fromRow).toList();
}
/// 批量加入歌曲(自动跳过歌单里已有的,避免打乱顺序)
Future<int> addSongs(String playlistId, List<String> songIds) async {
if (songIds.isEmpty) return 0;
final existing = (await getPlaylistSongIds(playlistId)).toSet();
var added = 0;
for (final id in songIds) {
if (existing.contains(id)) continue;
await _db.addSongToPlaylist(playlistId, id);
existing.add(id);
added++;
}
if (added > 0) PlaylistEvents().publish();
return added;
}
Future<void> updatePlaylistName(String id, String newName) async { Future<void> updatePlaylistName(String id, String newName) async {
await _db.updatePlaylist(id, { await _db.updatePlaylist(id, {
'name': newName, 'name': newName,
'updated_at': DateTime.now().millisecondsSinceEpoch, 'updated_at': DateTime.now().millisecondsSinceEpoch,
}); });
PlaylistEvents().publish();
} }
// ⭐ 新增:更新播放模式 // ⭐ 新增:更新播放模式
@@ -58,6 +82,7 @@ class PlaylistRepository {
Future<void> deletePlaylist(String id) async { Future<void> deletePlaylist(String id) async {
await _db.deletePlaylist(id); await _db.deletePlaylist(id);
PlaylistEvents().publish();
} }
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
@@ -75,6 +100,7 @@ class PlaylistRepository {
Future<void> removeSong(String playlistId, String songId) async { Future<void> removeSong(String playlistId, String songId) async {
await _db.removeSongFromPlaylist(playlistId, songId); await _db.removeSongFromPlaylist(playlistId, songId);
PlaylistEvents().publish();
} }
Future<void> reorderSongs( Future<void> reorderSongs(
+192 -17
View File
@@ -13,6 +13,7 @@ import '../metadata/metadata_service.dart';
import '../database/song_database.dart'; import '../database/song_database.dart';
import '../utils/artwork_helper.dart'; import '../utils/artwork_helper.dart';
import '../utils/song_key.dart'; import '../utils/song_key.dart';
import '../utils/position_guard.dart';
import '../repositories/playlist_repository.dart'; import '../repositories/playlist_repository.dart';
import '../models/playlist.dart'; import '../models/playlist.dart';
import 'webdav_service.dart'; import 'webdav_service.dart';
@@ -31,12 +32,17 @@ class Song {
final String? url; final String? url;
final Uint8List? artwork; final Uint8List? artwork;
/// 本地封面文件路径(songs.artwork_path)。
/// 命中过元数据的歌直接用它显示封面,不必等元数据流程回来。
final String? artworkPath;
Song({ Song({
required this.id, required this.id,
required this.title, required this.title,
required this.artist, required this.artist,
this.url, this.url,
this.artwork, this.artwork,
this.artworkPath,
}); });
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -103,6 +109,36 @@ class AudioService extends ChangeNotifier {
Duration _lastObservedPosition = Duration.zero; Duration _lastObservedPosition = Duration.zero;
DateTime _lastProgressAt = DateTime.now(); DateTime _lastProgressAt = DateTime.now();
/// 最后已知的有效播放位置。
///
/// 打开失败/重连瞬间 mpv 会短暂把 position 报成 0,
/// 恢复时若读实时位置就会"从头开始"。所有恢复路径都用这个值续播。
Duration _lastKnownPosition = Duration.zero;
/// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的)
bool _isRecovering = false;
/// 当前这首歌已经连续自动恢复了几次(成功后清零)。
/// 打开一首本身拿不到数据的歌时,看门狗会无限重试,
/// 必须熔断,否则日志和请求都会爆炸。
int _recoveryAttempts = 0;
static const int _maxRecoveryAttempts = 3;
/// 播放请求去重(同一次点击可能从多个入口触发)
DateTime? _lastPlayRequestAt;
int _lastPlayRequestIndex = -1;
/// 恢复退避:拥塞网络里连续失败后拉长间隔,避免和整栋楼抢带宽。
DateTime? _nextRecoveryAt;
Timer? _autoRetryTimer;
static Duration _backoffFor(int attempts) {
if (attempts <= 2) return Duration.zero;
if (attempts == 3) return const Duration(seconds: 30);
if (attempts == 4) return const Duration(seconds: 60);
return const Duration(seconds: 120);
}
/// 当前歌曲开始加载的时间,用于"起播看门狗": /// 当前歌曲开始加载的时间,用于"起播看门狗":
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、 /// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。 /// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
@@ -264,21 +300,29 @@ class AudioService extends ChangeNotifier {
final songs = <Song>[]; final songs = <Song>[];
for (final id in songIds) { for (final id in songIds) {
final dbSong = await _db.getSongByPath(id); final dbSong = await _db.getSongByPath(id);
if (dbSong != null) { final path = (dbSong?['remote_path'] as String?) ?? id;
songs.add(Song(
id: dbSong['remote_path'] as String, // 没有标签时回退文件名,必须解码:id 是百分号编码的播放路径,
title: dbSong['title'] as String? ?? '', // 直接展示会变成 "%E6%9C%AA..." 这种不可读串。
artist: dbSong['artist'] as String? ?? '未知艺术家', final rawName = path.split('/').last;
url: dbSong['remote_path'] as String, String displayName;
)); try {
} else { displayName = Uri.decodeComponent(rawName);
songs.add(Song( } catch (_) {
id: id, displayName = rawName;
title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
artist: '未知艺术家',
url: id,
));
} }
displayName = displayName.replaceAll(RegExp(r'\.[^.]*$'), '');
final dbTitle = (dbSong?['title'] as String?)?.trim() ?? '';
final dbArtist = (dbSong?['artist'] as String?)?.trim() ?? '';
songs.add(Song(
id: path,
artworkPath: dbSong?['artwork_path'] as String?,
title: dbTitle.isNotEmpty ? dbTitle : displayName,
artist: dbArtist.isNotEmpty ? dbArtist : '未知艺术家',
url: WebDAVService.instance.getFileUrl(path),
));
} }
if (songs.isNotEmpty) { if (songs.isNotEmpty) {
@@ -305,6 +349,18 @@ class AudioService extends ChangeNotifier {
return; return;
} }
// 去重:同一次点击可能从多个入口触发(列表 onTap + 控制器 + 通知),
// 反复 open 会互相顶掉,表现为"点了要等好几秒才出声"。
final now = DateTime.now();
if (_lastPlayRequestIndex == _currentIndex &&
_lastPlayRequestAt != null &&
now.difference(_lastPlayRequestAt!) < const Duration(milliseconds: 800)) {
debugPrint('⏭️ [AudioService] 忽略重复播放请求(index=$_currentIndex)');
return;
}
_lastPlayRequestAt = now;
_lastPlayRequestIndex = _currentIndex;
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。 // 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。 // 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
final epoch = ++_switchEpoch; final epoch = ++_switchEpoch;
@@ -312,6 +368,11 @@ class AudioService extends ChangeNotifier {
_isChangingTrack = true; _isChangingTrack = true;
_handlingCompletion = false; _handlingCompletion = false;
_hasStartedCurrentPlayback = false; _hasStartedCurrentPlayback = false;
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
_lastKnownPosition = Duration.zero;
_recoveryAttempts = 0;
_nextRecoveryAt = null;
_autoRetryTimer?.cancel();
try { try {
_playbackGeneration++; _playbackGeneration++;
@@ -556,10 +617,42 @@ class AudioService extends ChangeNotifier {
if (_openRetryGeneration == _playbackGeneration) return; if (_openRetryGeneration == _playbackGeneration) return;
_openRetryGeneration = _playbackGeneration; _openRetryGeneration = _playbackGeneration;
debugPrint('🔁 [AudioService] 打开失败,1.5s 后自动重试');
Timer(const Duration(milliseconds: 1500), () { // 缓冲余量决定重试节奏:
// 余量充足 -> 慢一点重试,期间 mpv 还有东西可播,用户不会察觉;
// 余量见底 -> 尽快重连,尽量减少静音时间。
final bufferAhead = bufferedNotifier.value - positionNotifier.value;
final hasBuffer = bufferAhead >= const Duration(seconds: 12);
final delay = hasBuffer
? const Duration(seconds: 4)
: const Duration(milliseconds: 1200);
final baseline = _lastKnownPosition;
debugPrint('🔁 [AudioService] 读取出错,缓冲余量 ${bufferAhead.inSeconds}s,'
'${delay.inMilliseconds}ms 后重试');
Timer(delay, () {
if (_currentSong == null) return; if (_currentSong == null) return;
if (_isChangingTrack) return; if (_isChangingTrack) return;
// mpv 可能自己接上了:位置在推进就不打断它
if (positionNotifier.value >
baseline + const Duration(seconds: 1)) {
debugPrint('♻️ [AudioService] 播放已自行恢复,取消重连');
return;
}
_recoverFromStall(force: true);
});
}
/// 冷却结束后的自动重试(只试一次,成功与否交给下一轮判断)
void _scheduleAutoRetry(Duration delay) {
_autoRetryTimer?.cancel();
_autoRetryTimer = Timer(delay, () {
_nextRecoveryAt = null;
if (_currentSong == null) return;
if (_isChangingTrack) return;
if (positionNotifier.value > const Duration(seconds: 1)) return;
debugPrint('⏰ [AudioService] 冷却结束,再试一次');
_recoverFromStall(force: true); _recoverFromStall(force: true);
}); });
} }
@@ -575,6 +668,30 @@ class AudioService extends ChangeNotifier {
final url = song.url; final url = song.url;
if (url == null || url.isEmpty) return; if (url == null || url.isEmpty) return;
// 熔断 + 退避:拥塞网络里连续失败,说明现在这条链路抢不到带宽。
// 拉长间隔,冷却结束后自动再试一次;超过上限就停住等用户手动点。
if (_nextRecoveryAt != null && DateTime.now().isBefore(_nextRecoveryAt!)) {
return;
}
if (_recoveryAttempts >= _maxRecoveryAttempts) {
final delay = _backoffFor(_recoveryAttempts);
_nextRecoveryAt = DateTime.now().add(delay);
debugPrint('⏳ [AudioService] 已连续恢复 $_recoveryAttempts 次,'
'${delay.inSeconds}s 后再试:${song.title}');
if (_recoveryAttempts >= _maxRecoveryAttempts + 3) {
// 退避到极限:停住,交给用户手动重试(点播放会清零计数)
debugPrint('⛔ [AudioService] 放弃自动恢复:${song.title}');
_lastPlaybackError = '网络拥塞,无法播放:${song.title}';
notifyListeners();
return;
}
_scheduleAutoRetry(delay);
_recoveryAttempts++;
return;
}
_recoveryAttempts++;
final now = DateTime.now(); final now = DateTime.now();
if (!force && if (!force &&
_lastStallRecoveryAt != null && _lastStallRecoveryAt != null &&
@@ -584,23 +701,36 @@ class AudioService extends ChangeNotifier {
} }
_lastStallRecoveryAt = now; _lastStallRecoveryAt = now;
final resumeAt = positionNotifier.value; // 恢复前记下代次:await 期间用户可能已经切歌,
// 不能把旧歌重新打开(否则就是"点了新歌却切回旧歌")。
final recoveryGeneration = _playbackGeneration;
final resumeAt = _lastKnownPosition > Duration.zero
? _lastKnownPosition
: positionNotifier.value;
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)'); debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
_isRecovering = true;
try { try {
_trackStartAt = DateTime.now(); _trackStartAt = DateTime.now();
_playReadyAt = null; _playReadyAt = null;
_firstAudioReported = false; _firstAudioReported = false;
await _playWithHeaders(url); await _playWithHeaders(url);
if (_playbackGeneration != recoveryGeneration) {
debugPrint('⏭️ [AudioService] 恢复期间已切歌,放弃本次恢复');
return;
}
_playReadyAt = DateTime.now(); _playReadyAt = DateTime.now();
if (resumeAt > Duration.zero) { if (resumeAt > Duration.zero) {
await PlaybackService().seek(resumeAt); await PlaybackService().seek(resumeAt);
positionNotifier.value = resumeAt; positionNotifier.value = resumeAt;
_lastKnownPosition = resumeAt;
} }
debugPrint('✅ [AudioService] 自动恢复完成'); debugPrint('✅ [AudioService] 自动恢复完成');
} catch (e) { } catch (e) {
debugPrint('❌ [AudioService] 自动恢复失败: $e'); debugPrint('❌ [AudioService] 自动恢复失败: $e');
_lastPlaybackError = '缓冲卡住,自动恢复失败'; _lastPlaybackError = '缓冲卡住,自动恢复失败';
notifyListeners(); notifyListeners();
} finally {
_isRecovering = false;
} }
} }
@@ -610,9 +740,18 @@ class AudioService extends ChangeNotifier {
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。 /// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
void _onNetworkChanged() { void _onNetworkChanged() {
_networkDebounce?.cancel(); _networkDebounce?.cancel();
// 记录去抖开始时的歌:2 秒内用户可能已经切歌,
// 那时再去重连"上一首"就是异常切换(日志里出现过 78s 重连旧歌)。
final expectedSongId = _currentSong?.id;
final expectedGeneration = _playbackGeneration;
_networkDebounce = Timer(const Duration(seconds: 2), () { _networkDebounce = Timer(const Duration(seconds: 2), () {
if (_currentSong == null) return; if (_currentSong == null) return;
if (_isChangingTrack) return; if (_isChangingTrack) return;
if (_currentSong?.id != expectedSongId ||
_playbackGeneration != expectedGeneration) {
debugPrint('🌐 [AudioService] 网络变化期间已切歌,忽略本次重连');
return;
}
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲'); debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
_recoverFromStall(force: true); _recoverFromStall(force: true);
}); });
@@ -728,6 +867,8 @@ class AudioService extends ChangeNotifier {
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist, artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
url: song.url, url: song.url,
artwork: metadata.artwork, artwork: metadata.artwork,
// 保留封面文件路径:元数据流程回来前,播放页靠它立即显示封面
artworkPath: song.artworkPath,
); );
_queue[_currentIndex] = updatedSong; _queue[_currentIndex] = updatedSong;
_currentSong = updatedSong; _currentSong = updatedSong;
@@ -872,6 +1013,10 @@ class AudioService extends ChangeNotifier {
if (_currentSong == null) return; if (_currentSong == null) return;
if (_isPlaying) return; if (_isPlaying) return;
// 用户手动点播放:重置退避计数,允许立即重试
_recoveryAttempts = 0;
_nextRecoveryAt = null;
if (_restoredPendingOpen) { if (_restoredPendingOpen) {
_restoredPendingOpen = false; _restoredPendingOpen = false;
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开'); debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
@@ -972,6 +1117,34 @@ class AudioService extends ChangeNotifier {
debugPrint('🎯 [AudioService] positionStream ignored: user seeking'); debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
return; return;
} }
// 位置回退保护:打开失败/重连时 mpv 会短暂报 0。
// 非用户操作、非切歌、非恢复中时,不允许从 77s 掉回 0s,
// 否则界面进度会归零,恢复时也会"从头播"。
if (shouldIgnorePositionRegression(
lastKnown: _lastKnownPosition,
incoming: position,
isChangingTrack: _isChangingTrack,
isRecovering: _isRecovering,
)) {
debugPrint('🛡️ [AudioService] 忽略位置回退: '
'${_lastKnownPosition.inSeconds}s -> ${position.inSeconds}s');
return;
}
if (position > _lastKnownPosition ||
_isChangingTrack ||
_isRecovering) {
_lastKnownPosition = position;
}
// 真的出声了(>1s),说明恢复成功,重置熔断计数
if (position > const Duration(seconds: 1)) {
_recoveryAttempts = 0;
_nextRecoveryAt = null;
_autoRetryTimer?.cancel();
}
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻, // 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
// 与 play() 发起时间相减即可算出起播延迟。 // 与 play() 发起时间相减即可算出起播延迟。
if (!_firstAudioReported && position > Duration.zero) { if (!_firstAudioReported && position > Duration.zero) {
@@ -1243,6 +1416,8 @@ class AudioService extends ChangeNotifier {
_progressTimer = null; _progressTimer = null;
_networkDebounce?.cancel(); _networkDebounce?.cancel();
_networkDebounce = null; _networkDebounce = null;
_autoRetryTimer?.cancel();
_autoRetryTimer = null;
NetworkWatcher().stop(); NetworkWatcher().stop();
savePlaybackState(); savePlaybackState();
_stopListening(); _stopListening();
+22
View File
@@ -0,0 +1,22 @@
// lib/services/favorite_events.dart
import 'dart:async';
/// 收藏状态变化事件。
///
/// 文件列表、播放页都能收藏/取消收藏,主页的收藏列表需要跟着变,
/// 否则要退出重进才看得到。
class FavoriteEvents {
static final FavoriteEvents _instance = FavoriteEvents._internal();
factory FavoriteEvents() => _instance;
FavoriteEvents._internal();
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get stream => _controller.stream;
void publish(String songPath) {
if (songPath.isEmpty) return;
_controller.add(songPath);
}
}
+30
View File
@@ -0,0 +1,30 @@
// lib/services/metadata_events.dart
import 'dart:async';
/// 一条元数据更新(path + songs 表的最新行)
class MetadataUpdate {
final String path;
final Map<String, dynamic> row;
const MetadataUpdate(this.path, this.row);
}
/// 元数据更新事件总线。
///
/// 之前各列表页靠"返回时重查"来刷新,歌曲补全标签后要退出重进才看得到。
/// 现在 MetadataService 写库成功后 publish,订阅的页面按 path 局部更新。
class MetadataEvents {
static final MetadataEvents _instance = MetadataEvents._internal();
factory MetadataEvents() => _instance;
MetadataEvents._internal();
final StreamController<MetadataUpdate> _controller =
StreamController<MetadataUpdate>.broadcast();
Stream<MetadataUpdate> get stream => _controller.stream;
void publish(String path, Map<String, dynamic> row) {
if (path.isEmpty) return;
_controller.add(MetadataUpdate(path, row));
}
}
+14
View File
@@ -41,6 +41,20 @@ class PlaybackService {
return; return;
} }
_player = Player(); _player = Player();
// 拥塞网络(公司共享出口)下加大缓冲:多存一点余量,
// 减少"播到一半断流 -> 重连"的次数。代价是起播稍慢。
// 通过 platform 动态调用,避免依赖 media_kit 的内部类型。
try {
final platform = _player!.platform as dynamic;
platform.setProperty('cache-secs', '60');
platform.setProperty('demuxer-max-bytes', '64MiB');
platform.setProperty('demuxer-readahead-secs', '30');
debugPrint('✅ [PlaybackService] mpv 缓冲参数已设置(cache 60s / 64MiB)');
} catch (e) {
debugPrint('⚠️ [PlaybackService] 设置 mpv 缓冲参数失败: $e');
}
_initialized = true; _initialized = true;
debugPrint( debugPrint(
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}'); '✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
+21
View File
@@ -0,0 +1,21 @@
// lib/services/playlist_events.dart
import 'dart:async';
/// 歌单数据变更事件(新建/重命名/删除/加歌/删歌/排序)。
///
/// 主页的歌单页、歌单详情页都靠它实时刷新,
/// 不用等页面重建。
class PlaylistEvents {
static final PlaylistEvents _instance = PlaylistEvents._internal();
factory PlaylistEvents() => _instance;
PlaylistEvents._internal();
final StreamController<void> _controller =
StreamController<void>.broadcast();
Stream<void> get stream => _controller.stream;
void publish() {
_controller.add(null);
}
}
+34
View File
@@ -0,0 +1,34 @@
// lib/services/route_manager.dart
import 'package:flutter/foundation.dart';
/// 路由管理器(控制 MiniPlayer 显示/隐藏)
class RouteManager extends ChangeNotifier {
static final RouteManager _instance = RouteManager._internal();
factory RouteManager() => _instance;
RouteManager._internal();
String _currentRoute = '/';
String get currentRoute => _currentRoute;
/// 弹层/对话框打开时临时隐藏迷你播放器,避免盖住内容
bool _temporarilyHidden = false;
bool get showMiniPlayer =>
!_temporarilyHidden &&
_currentRoute != '/player' &&
_currentRoute != '/playlist';
void setTemporarilyHidden(bool hidden) {
if (_temporarilyHidden == hidden) return;
_temporarilyHidden = hidden;
notifyListeners();
}
void updateRoute(String route) {
if (_currentRoute != route) {
_currentRoute = route;
debugPrint('📌 路由更新: $route');
notifyListeners();
}
}
}
+20
View File
@@ -0,0 +1,20 @@
// lib/utils/position_guard.dart
/// 位置回退保护。
///
/// 打开失败 / 重连瞬间,mpv 会短暂把 position 报成 0 或几秒。
/// 如果直接采用,界面进度会归零,恢复时也会"从头播"。
///
/// 规则:只有在"已经播过一段时间"且"新位置几乎归零"时才算异常回退;
/// 用户 seek、切歌、我们自己的恢复流程都允许回退,不拦截。
bool shouldIgnorePositionRegression({
required Duration lastKnown,
required Duration incoming,
required bool isChangingTrack,
required bool isRecovering,
}) {
if (isChangingTrack || isRecovering) return false;
if (lastKnown <= const Duration(seconds: 10)) return false;
if (incoming >= const Duration(seconds: 5)) return false;
return (lastKnown - incoming) > const Duration(seconds: 5);
}
+173
View File
@@ -0,0 +1,173 @@
// lib/widgets/add_to_playlist_sheet.dart
import 'package:flutter/material.dart';
import '../models/playlist_summary.dart';
import '../repositories/playlist_repository.dart';
import '../services/route_manager.dart';
/// 把一首歌加入歌单的底部弹层。
///
/// 视觉先保持系统样式,等设计定稿后只换这个文件即可,
/// 调用方(文件列表长按)不用改。
Future<void> showAddToPlaylistSheet(
BuildContext context, {
required String songPath,
}) async {
final repo = PlaylistRepository();
List<PlaylistSummary> playlists;
try {
playlists = await repo.getSummaries();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('读取歌单失败: $e')),
);
}
return;
}
if (!context.mounted) return;
RouteManager().setTemporarilyHidden(true);
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF1A1F1E),
builder: (sheetContext) {
return SafeArea(
minimum: const EdgeInsets.only(bottom: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 18, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'加入歌单',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
ListTile(
leading: const Icon(Icons.add, color: Color(0xFFB8D4D0)),
title: const Text(
'新建歌单',
style: TextStyle(color: Color(0xFFB8D4D0)),
),
onTap: () async {
Navigator.pop(sheetContext);
await _createPlaylistAndAdd(context, repo, songPath);
},
),
const Divider(height: 1, color: Colors.white12),
if (playlists.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text(
'还没有歌单,先新建一个',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return ListTile(
leading: const Icon(
Icons.queue_music,
color: Color(0xFFB8D4D0),
),
title: Text(
playlist.name,
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
'${playlist.songCount} 首',
style: const TextStyle(
color: Colors.grey, fontSize: 12),
),
onTap: () async {
final added = await repo.addSongs(
playlist.id,
[songPath],
);
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(added > 0
? '已加入「${playlist.name}」'
: '这首歌已经在「${playlist.name}」里了'),
),
);
},
);
},
),
),
const SizedBox(height: 16),
],
),
);
},
);
} finally {
RouteManager().setTemporarilyHidden(false);
}
}
Future<void> _createPlaylistAndAdd(
BuildContext context,
PlaylistRepository repo,
String songPath,
) async {
final controller = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text('新建歌单', style: TextStyle(color: Colors.white)),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(hintText: '歌单名称'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('取消'),
),
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, controller.text.trim()),
child: const Text('创建'),
),
],
),
);
if (name == null || name.isEmpty) return;
try {
final playlist = await repo.createPlaylist(name);
await repo.addSongs(playlist.id, [songPath]);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已新建「$name」并加入这首歌')),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('新建歌单失败: $e')),
);
}
}
+16 -2
View File
@@ -1,3 +1,4 @@
import 'dart:io';
// lib/widgets/global_mini_player.dart // lib/widgets/global_mini_player.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -66,9 +67,22 @@ class GlobalMiniPlayer extends StatelessWidget {
image: MemoryImage(song.artwork!), image: MemoryImage(song.artwork!),
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: null, : (song.artworkPath != null &&
song.artworkPath!.isNotEmpty &&
File(song.artworkPath!)
.existsSync())
? DecorationImage(
image: FileImage(
File(song.artworkPath!)),
fit: BoxFit.cover,
)
: null,
), ),
child: song.artwork == null child: (song.artwork == null &&
(song.artworkPath == null ||
song.artworkPath!.isEmpty ||
!File(song.artworkPath!)
.existsSync()))
? const Icon( ? const Icon(
Icons.music_note, Icons.music_note,
color: Colors.white38, color: Colors.white38,
+51
View File
@@ -0,0 +1,51 @@
// test/playlist_summary_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:qingting_player/models/playlist_summary.dart';
void main() {
group('PlaylistSummary.fromRow', () {
test('完整行解析', () {
final s = PlaylistSummary.fromRow({
'id': 'abc',
'name': '随手',
'song_count': 12,
'cover_path': '/data/artworks/x.jpg',
'updated_at': 1700000000,
});
expect(s.id, 'abc');
expect(s.name, '随手');
expect(s.songCount, 12);
expect(s.coverPath, '/data/artworks/x.jpg');
expect(s.isEmpty, isFalse);
});
test('没有封面时 coverPath 为 null', () {
final s = PlaylistSummary.fromRow({
'id': 'abc',
'name': '车用',
'song_count': 3,
'cover_path': '',
});
expect(s.coverPath, isNull);
expect(s.songCount, 3);
});
test('空歌单 isEmpty', () {
final s = PlaylistSummary.fromRow({
'id': 'x',
'name': '空',
'song_count': 0,
});
expect(s.isEmpty, isTrue);
expect(s.coverPath, isNull);
});
test('字段缺失时不崩', () {
final s = PlaylistSummary.fromRow({});
expect(s.id, '');
expect(s.name, '');
expect(s.songCount, 0);
expect(s.isEmpty, isTrue);
});
});
}
+79
View File
@@ -0,0 +1,79 @@
// test/position_guard_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:qingting_player/utils/position_guard.dart';
void main() {
group('shouldIgnorePositionRegression', () {
test('正常播放推进:不拦截', () {
expect(
shouldIgnorePositionRegression(
lastKnown: const Duration(seconds: 77),
incoming: const Duration(seconds: 78),
isChangingTrack: false,
isRecovering: false,
),
isFalse,
);
});
test('77s 突然归零:拦截(这就是"从头重播"的元凶)', () {
expect(
shouldIgnorePositionRegression(
lastKnown: const Duration(seconds: 77),
incoming: Duration.zero,
isChangingTrack: false,
isRecovering: false,
),
isTrue,
);
});
test('用户切歌:允许回退', () {
expect(
shouldIgnorePositionRegression(
lastKnown: const Duration(seconds: 200),
incoming: Duration.zero,
isChangingTrack: true,
isRecovering: false,
),
isFalse,
);
});
test('自动恢复中:允许回退(我们自己重开的)', () {
expect(
shouldIgnorePositionRegression(
lastKnown: const Duration(seconds: 200),
incoming: Duration.zero,
isChangingTrack: false,
isRecovering: true,
),
isFalse,
);
});
test('刚开始播放(最后位置 <10s):不拦截,避免误伤起播', () {
expect(
shouldIgnorePositionRegression(
lastKnown: const Duration(seconds: 8),
incoming: Duration.zero,
isChangingTrack: false,
isRecovering: false,
),
isFalse,
);
});
test('小幅回退(77s -> 73s):不拦截', () {
expect(
shouldIgnorePositionRegression(
lastKnown: const Duration(seconds: 77),
incoming: const Duration(seconds: 73),
isChangingTrack: false,
isRecovering: false,
),
isFalse,
);
});
});
}