Compare commits

64 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
lxh2875931338 672a545c38 feat(ui): 收藏项无元数据时,副标题显示上级文件夹名(带括号)
之前无标签时副标题固定写"收藏",和"歌手"语义混在一起。
现在取 path 的上一级目录(解码后)显示为 "(B-未分类)" 这种形式:
- 括号清楚表明是目录,不是艺术家
- 有标签时仍优先显示艺术家
- 目录层级不足或解码失败时回退为空(displaySubtitle 兜底"未知艺术家")

真机验证:收藏项显示 "13楼的大笨象 - 郑融" / "(B-未分类)"。
2026-10-24 21:44:14 +08:00
lxh2875931338 d3ed871626 fix(ui): 收藏列表文件名解码,避免显示成百分号编码串
收藏项没有元数据时回退文件名,但 path 是百分号编码的播放路径,
直接截取会显示 "13%E6%A5%BC%E7%9A%84..." 这种不可读内容。
- 展示前对文件名做 Uri.decodeComponent,解码失败才用原串
- 播放仍使用编码后的原 path,不受影响

验证:收藏 "13楼的大笨象 - 郑融" 在首页正常显示中文歌名。
2026-10-24 21:42:01 +08:00
lxh2875931338 c1e720311e feat(ui): 文件列表收藏红心 + 首页收藏绑定 favorites 表
交互调整(按讨论):
- WebDAV 文件列表:整行点击 = 播放(原来的行为),右侧播放按钮改成收藏红心
  已收藏实心红,未收藏空心;点击即写/删 favorites 表
- 首页"我的收藏"改为读 favorites 表 + songs 元数据;
  从文件列表返回时自动刷新
- 收藏项右侧红心改为真正删除(原来只是内存 removeAt)

修复:
- 收藏列表项透明度绑定 _listReveal,首屏为 0 导致条目完全透明
  (收藏成功但看不到)。收藏项改为固定 opacity 1.0。

验证(真机):
  列表点红心 -> favorites 表 +1 -> 返回首页 -> 收藏区出现该条目
2026-10-24 21:38:36 +08:00
lxh2875931338 4a8b1f28db feat(ui): 设置页第一版(缓存管理 / 诊断导出 / 连接状态 / 关于)
设置页此前没有任何入口,且内容全是占位。现在:
- 首页右上角菜单按钮 → 设置页
- 缓存:标签条数、封面数量/占用、临时探测文件占用,一键清理(保留歌单与收藏)
- 诊断:导出 版本/连接/缓存/当前播放/最近错误 到 txt,经 FileProvider 走系统分享
- 连接:显示 WebDAV 地址与用户,支持测试连接、退出登录
- 关于:版本、构建号、播放内核与 UI 许可说明
- 删除未被引用的 FullPlayerPage(与 player_page 重复)

真机验证:设置页正常打开,诊断导出成功调起系统分享面板。
2026-10-24 21:28:22 +08:00
lxh2875931338 d07086aaa0 feat(ui): 歌曲列表改用封面图,替代统一的小图标
- 新增 SongArtwork 组件:内存字节 > 本地封面文件 > 图标兜底,圆角裁剪
- WebDAV 文件列表:前导图标换成封面(来自 songs.artwork_path)
- 播放列表页:批量查封面路径(getSongsByPaths),正在播放的歌优先用内存里的封面字节;
  切歌后重新拉一次,保证新补全的封面能显示
- 没有封面的歌仍回退为音符/播放图标,不影响列表结构
2026-10-24 20:47:12 +08:00
lxh2875931338 0a1ebfa5df feat(ui): WebDAV 文件列表改用元数据标题/艺术家显示
之前列表只显示文件名,即使 DB 里已经有标签。
- SongDatabase 新增 getSongsByPaths():一次批量按路径查(分块 400,避开变量上限)
- WebDAVFileListPage:
  * 加载目录后批量取元数据,title/artist 有值就替换文件名
  * 副标题显示 "艺术家 · 大小",没有标签时仍显示大小
  * 监听 AudioService,正在播放的歌补全元数据后立即刷新对应行
  * 没有标签的新歌自然回退文件名,不影响使用
2026-10-24 20:41:21 +08:00
lxh2875931338 18308d9d4e feat(metadata): 元数据分阶段探测(128KB → 512KB)+ song_key 改为纯 URL
渐进探测:
- FileProvider.getFile 增加 maxBytes 参数,WebDAV 按需只取头部
- MetadataService 先取 128KB 读标签;title/artist 都为空时再补到 512KB
- 缓存文件按上限区分(metadata_<hash>_128k.part / _512k.part)

song_key:
- 从 md5(url|size) 改为 md5(url)。分阶段探测的大小不同,
  带 size 会让同一首歌产生两个键;URL 在各阶段稳定。
- 代价:同 URL 换内容不自动失效,由清除单曲缓存/forceRefresh 兜底
- 数据库升到 v6,清空 songs / metadata_cache(可再生)

验证(release,真机):
  📦 取头部 128KB(上限 128KB,不再整文件下载)
  🧭 状态机: first_seen → pending(validations=1)
  📊 pending conf=0.92 evidence={...}
  ✅ saved: 两生关 - 谢安琪

测试:新增 song_key 3 个用例,累计 19 个全绿。
2026-10-24 20:28:50 +08:00
lxh2875931338 b8138ad125 feat(metadata): 元数据引擎第 3 步之一——一致性与冲突感知的状态机
之前只要 confidence>=0.6 且 validation_count>=2 就升级 verified,
两次读到不同结果也会被当成"验证通过",错误标签一旦写入很难纠正。

新增 MetadataStability(纯函数,可单测):
- 首次见到 → pending(first_seen),次数 1
- 与上次记录标题/艺术家冲突 → pending(conflict),次数重置为 1
- 一致且次数 >= 2、置信度达标 → verified(stable)
- 置信度不足 → pending(low_confidence),次数继续累计
- 字段缺失不算冲突;比较时忽略大小写与首尾空格

metadata_service 改为调用该状态机,并打印判定原因:
  🧭 [MetadataService] 状态机: stable → verified(validations=2)

测试:新增 test/metadata_stability_test.dart 7 个用例,
连同原有 9 个共 16 个,flutter test 全绿。
已构建并安装 release 验证。
2026-10-24 20:24:55 +08:00
lxh2875931338 9f53d1c604 fix(playback): 打开失败后 1.5s 立即重试,切歌前摇从 ~20s 降到 ~7s
实测时间线(20:08:53 那次切歌):
  53.881 next -> play
  53.947 open 完成 65ms
  59.525 player error: tcp ffurl_read / Failed to open   (mpv 5.6s 后放弃)
  09:13.801 起播看门狗才触发重连                        (又等 14s)

原因:看门狗 5s 一跳、阈值 10s,最坏要两个 tick 才触发。

- error 流里识别 Failed to open / ffurl_read,1.5s 后立即重试一次
  (每首歌最多一次,force 模式跳过节流)
- 进度看门狗 tick 10s -> 5s,任何卡死最多多等 5s

预期:失败发生在 5~6s,重试在 7~8s 开始,前摇 ~10s 降到 7~8s;
若首次打开成功则完全无感。
2026-10-24 20:14:31 +08:00
lxh2875931338 98db2321fb fix(playback): 冷启动不再预加载音频,首次播放按保存进度打开
按今日日志定位:所谓"后台假死"其实是进程被系统回收
(13:14:46 ActivityManager: has died: prcp FGS,当时内存压力下批量杀进程)。
冷启动后旧代码会先联网 play→seek→pause,既慢又会和用户点播放打架。

- restorePlaybackState: 只恢复队列/进度/UI,不联网,不再调用 _loadRestoredPlayback
- 新增 playOrResume(): UI 按钮、通知栏、耳机键统一入口;
  冷启动首次播放走 _playCurrent(open + seek 到保存位置 + play)
- AudioPlayerHandler.play() 改走 playOrResume()
- 播放中每 10s 持久化一次进度,冷启动恢复更准确
- 窗口期收紧:网络轮询 10s→3s、切换去抖 3s→2s、
  起播看门狗 15s→10s、中段卡死 30s→20s

真机验证(release):
  冷启动 8s 内 play 调用 0 次(不再预加载)
  按键播放:冷启动首次播放 → open 21ms → restored position 15.751s → 1.5s 出声
2026-10-24 20:04:42 +08:00
lxh2875931338 ff41769c56 build(android): 接入正式签名配置 + 版本号改为 1.1.0-dev.1 (2)
- app/build.gradle.kts 读取 android/key.properties(不存在时回退 debug keystore)
- .gitignore 忽略 key.properties / keystore / *.jks
- pubspec: version 1.1.0-dev.1+2

注意:key.properties 与 keystore 文件由开发者本地生成,不入库。
生成后 flutter build apk --release 即为正式签名包。
2026-10-24 00:11:53 +08:00
lxh2875931338 30111a5f0c feat(metadata): 元数据引擎第 2 步——置信度模型 + pending/verified 状态机
之前 evaluate() 写死 0.8、decide() 直接透传,history 参数没人用,
"自学习"只有骨架。这次补上:

置信度(MetadataNormalizer.evaluate)
- 字段来源可追溯:NormalizedMetadata 新增 titleFromTag / artistFromTag / albumFromTag
- 基础分 0.25;标题 +0.20、标题来自标签再 +0.10(文件名当标题则 -0.10)
- 艺术家 +0.15、来自标签 +0.10;专辑 +0.10;年份 +0.03;封面 +0.02
- 历史一致性:同艺人 >=2 条 +0.05,同专辑 >=2 条 +0.03
- 结果 clamp 到 0.05~0.98,且每个加分项写入 evidence,便于日志解释

状态机(MetadataService)
- 只有 confidence>=0.6 且 status=verified 的记录才直接命中
- pending 记录下次播放会重新读取并合并(自修复)
- validation_count 累加;>=2 次且置信度达标才升级为 verified
- 新结果置信度低于旧结果时保留旧字段,只累加验证次数
- 日志输出 status / confidence / validation_count / evidence

测试
- 新增 test/metadata_normalizer_test.dart,9 个用例覆盖来源标记、分级、
  历史加权、证据、阈值;flutter test 全绿

真机验证
- 起播看门狗在真实弱网下触发:23:44:50 打开失败 → 15s 后自动重连 →
  1.4s 出声(open 仅 100ms)
2026-10-23 23:45:29 +08:00
lxh2875931338 a5849969d3 perf(metadata): 元数据探测从 2MB 降到 512KB
1Panel 访问日志显示:2MB 的探测请求在当前链路上要 10~18 秒,
期间 libmpv 的 206 吞吐被压到 ~1Mbps,直接拖慢起播。

- _maxBytes 2MB -> 512KB:常见标签与封面都在此范围内
- 读不到标签时仍由 80% 重试兜底
- 日志文案同步更新

另:访问日志 CSV 属于本地参考,加入 .gitignore
2026-10-23 23:16:07 +08:00
lxh2875931338 af895c18e1 fix(metadata): 元数据回退到文件名时,在 80% 处重试一次
云服日志显示:对 /dav/.../*.flac 的 Range GET 在 10.3s 后返回 502,
是反代等待上游首包超时(清远源站响应慢),与我方 15s 超时无关。
影响仅限该曲的标签/封面,播放不受影响,但会一直回退文件名。

- _loadMetadataForCurrentSong: source == 'filename' 时置 _metadataNeedsRetry
- 播放到 80% 且该曲未重试过 → 再抓一次(每首歌最多一次)
- 因"播放未稳定"被跳过抓取的情况也标记为待重试
- 换歌时清空标记
2026-10-23 23:11:39 +08:00
lxh2875931338 2d571eaae4 feat(playback): 下一首预热(60% 时 HEAD 预握手)
日志实测:open 完成仅 37~60ms,但起播耗时 2.1~2.8s,
说明等待发生在 mpv 拿首批数据,而不是建连。

- 新增 PrefetchService:HEAD + Range 0-0,只走 DNS/TLS/反代,
  不下载正文;失败静默,不影响播放
- AudioService 在进度达到 60%(且时长 > 60s)时预热下一首,
  每首歌只触发一次;shuffle 模式下按当前随机索引推算

待验证:播到 60% 时应出现
  🔥 [AudioService] 预热下一首:xxx
  🔥 [Prefetch] 预热完成 xxxms,status=200/206
对比下一首的 "起播耗时" 是否下降。
2026-10-23 23:06:18 +08:00
lxh2875931338 111369c20c feat(playback): 起播看门狗 + 起播耗时打点
现象(Plastic Love):state=PLAYING、position 恒为 0,75 秒内没有任何
buffering/error 日志,mpv 既不报错也不推进。通用看门狗要求"近期有缓冲",
因此完全没反应。

- 新增起播看门狗:position 持续为 0 超过 15s 且 playing=true → 强制重连
  (不再要求 buffering 事件;暂停时 playing=false,不会误触发)
- 中段卡死判定也放宽为 playing || recentlyBuffering
- 打点:
  * PlaybackService: "open 完成 xxxms,play 完成 xxxms"
  * AudioService: "起播耗时 xxxms(open 完成 -> 出声)"
  下次再出现"下一首很久",日志能直接区分是 open 慢还是数据到得慢。
2026-10-23 21:55:13 +08:00
lxh2875931338 b517c75cdd feat(playback): 网络切换后自动重连当前歌曲
mpv 的 HTTP 长连接不会跟随网络迁移:换 Wi-Fi / Wi-Fi<->移动数据
会换掉出口 IP,旧连接失效但不一定报错,表现为进度停住、一直缓冲。

- 新增 NetworkWatcher:每 10s 枚举 IPv4 接口,签名变化即视为网络切换
  (不依赖插件和额外权限,也能覆盖"同类型网络换 AP"的情况)
- AudioService 收到变化后 3s 去抖,然后强制重连当前歌曲并 seek 回原位置
  (force 参数跳过 2 分钟节流,因为这是明确的外部事件)

真机验证(公网链路 + 移动数据):
  21:43:23.123  检测到网络变化
  21:43:26.124  主动重连(3s 去抖)
  21:43:26.289  自动恢复完成(165ms)
  随后 position 35.5s -> 41.7s 正常推进
2026-10-23 21:44:55 +08:00
lxh2875931338 937bcdd63d fix(playback): 缓冲抖动下看门狗失效 + 元数据改到播放稳定后再抓
实测(公网链路,自然播完 -> 自动下一首)暴露两个新问题:

1. 看门狗在 buffering 抖动时永远不触发
   原实现每次 buffering=true 都重置 30s 计时器,而弱网下它会 true/false
   每 2~5s 抖一次,于是计时器一直被重置,卡死 60s+ 也不会自愈。
   改为记录"最近一次缓冲时间",用「进度 30s 未推进 + 20s 内发生过缓冲」
   两个条件判定,暂停时不会误触发。

2. 元数据抓取在起播瞬间抢带宽
   新歌 open 后 mpv 还在持续填缓冲,此时插入 2MB 标签下载会把缓冲打回 0。
   实测下一首因此要等 60s+。
   改为等播放真正稳定(进度 >= 5s 且连续 2s 不在 buffering,最多等 30s)
   再抓元数据;等不到就跳过本次(下次播放再试),优先保证出声。

验证:公网 + 移动数据下切歌,next 发起后 2ms 即 open;
未稳定时日志出现"播放未稳定,跳过本次元数据抓取",不再有 2MB 下载与播放抢带宽。
2026-10-23 21:35:40 +08:00
lxh2875931338 2b33c17aab fix(playback): 增加进度看门狗,兜住不产生 buffering 事件的半开连接
公网链路实测(清远->华为云->香港CN2->openresty->OpenList HTTPS)中
出现过 tcp: ffurl_read 错误但未触发 buffering=true 的情况,
仅靠缓冲事件无法覆盖这类"连接半死"的卡顿。

- 每 10s 检查一次播放进度;播放中且 30s 未推进 → 触发自愈(重开 + seek 回原位)
- 切歌中/暂停时重置基准,避免误触发
- 与 buffering 看门狗共用 2 分钟节流

验证:公网路径连续播放 65s,无 false positive,进度正常推进。
2026-10-23 21:18:02 +08:00
lxh2875931338 9762569832 chore(tools): device-check 兼容 Flyme 解冻,重试时延长等待 2026-10-23 21:09:40 +08:00
lxh2875931338 9e58d52678 fix(playback): 修复切歌永久假死,并让元数据抓取不再抢播放带宽
问题(后台播一段时间后无法缓冲,点下一首也没反应,只能重启):
1. _isChangingTrack 是没有超时的锁:_playCurrent() 置 true 后,
   只有走完 finally 才会清。mpv 的 open() 在弱网下可能挂很久,
   锁就一直不释放,之后 next()/previous() 全被静默拒绝。
2. 元数据用 dio.download 整文件下载,和 mpv 流式播放抢带宽,
   20Mbps FRP 下播放流只剩一两百 K/s,缓冲长期为 0。
3. 没有订阅 mpv 的 error/buffering,失败无感知、无法自愈。

改动:
- playback_service: open 20s / 控制命令 5s 超时;失败时 stop 回空闲并上抛异常
- audio_service:
  * 去掉「正在切歌就拒绝」的逻辑,改为 latest-wins(epoch 校验,
    旧请求自行放弃,只有最新请求解锁)
  * 播放失败不再吞掉:记录 lastPlaybackError 并 notifyListeners
  * 订阅 stream.error / stream.buffering
  * 缓冲超过 30s 自动重连当前歌曲并 seek 回原位置(2 分钟内最多一次)
- file_provider: 元数据改为 Range 只取头部 2MB,流式写入并在切歌时取消上一请求
- home_page: 去掉重复的 PlaybackService().play()(同一首歌会被 open 两次),
  登录校验提前

验证:dart analyze 无 error;gradlew assembleDebug BUILD SUCCESSFUL。
真机验证待设备重新连接后进行。
2026-10-23 21:04:45 +08:00
lxh2875931338 eab4a12ccc chore: 忽略本地个人笔记 bug-todo.txt 2026-10-23 12:33:31 +08:00
lxh2875931338 46a9add5cb build+fix: debug 包独立为 .dev,并修复元数据缓存永远查不到
包名:
- debug 使用 com.lxh.qingting_player.dev(应用名"清听 Dev"),可与 release 并存
- release 保持 com.lxh.qingting_player
- Android 包名每一段必须以字母开头,因此用 .dev 而非 _dev
- AGP 9 需显式开启 buildFeatures.resValues;移除 manifest 中已废弃的 package 属性

元数据(两个独立原因叠加):
1. song_key 之前用 String.hashCode / 本地临时文件 mtime,都不稳定;
2. songs.remote_path 存的是完整 URL,而所有查询都用相对路径(song.id),
   getSongByPath 永远 miss,UI 只能回退到文件名。

- 新增 utils/song_key.dart:songKeyFor(url,size)=md5(url|size),跨进程稳定
- metadata_service: remote_path 改存 fileId(相对路径),并改用稳定 song_key
- audio_service / file_provider: 同步使用稳定键
- 数据库升级 v5:清空 songs / metadata_cache(可再生缓存;歌单与收藏按路径存储,不受影响)
- device-check.ps1: 支持 -Package,默认校验 dev 包,启动组件改为向 PackageManager 解析

验证:两次独立 dart 进程 song_key 输出一致;真机 debug/release 并存安装,单实例自检通过。
2026-10-22 23:47:32 +08:00
lxh2875931338 87011c7adc 多实例问题已修复,代码已合并到main分支,release打包ing~ 2026-10-22 23:23:51 +08:00
lxh2875931338 6445ee94bf fix(playback): 播放中不再向系统上报 idle,修复播放设备页显示未在播放
audio_service 把 idle 映射成 PlaybackState.STATE_NONE,之前用 duration>0 判断,
时长尚未解析出来时会一直上报 NONE。改为 playing || duration>0 即视为 ready。

设备验证(MEIZU 21 Pro / Android 16):
  播放 -> state=PLAYING(3),暂停 -> state=PAUSED(2),不再出现 NONE(0)。

同时加固 device-check.ps1:确认进程真正退出后再抓冷启动日志,最多重试两次。
2026-10-22 22:57:34 +08:00
lxh2875931338 6f9fe6262d chore(tools): 增加真机自检脚本 device-check.ps1
一键完成:安装 debug APK → 冷启动 → 抓 logcat → 校验单实例
(PlaybackService / serviceId / Player / MediaSession 各只允许 1 个),
并检查崩溃与 libmediakitandroidhelper.so 加载失败。
2026-10-22 22:46:51 +08:00
lxh2875931338 2252b72a14 build(android): 修复模板覆盖导致的打包失败
- 恢复 versionCode/versionName 取自 pubspec(模板硬编码为 1/1.0.0)
- extractNativeLibs 从 Manifest 移到 packaging.jniLibs.useLegacyPackaging(AGP 9 要求)
- 恢复 kotlin.incremental=false / kotlin.parallel.tasks.in.project=false(模板覆盖时丢失)

已用 gradlew assembleDebug 验证:BUILD SUCCESSFUL,APK 内每个 ABI 仅一份 libmpv.so + libmediakitandroidhelper.so。
依赖解析已用 :app:dependencies --refresh-dependencies 验证可直连 Maven Central,无需镜像。
2026-10-22 22:40:45 +08:00
lxh2875931338 083a1612d5 chore: 停止跟踪构建日志与 IDE 生成文件 2026-10-22 22:14:20 +08:00
lxh2875931338 b62af9648b snapshot: 存档 flutter create 模板覆盖后的现场,作为多实例修复前的基线 2026-10-22 22:04:57 +08:00
lxh2875931338 56c26e2fd5 snapshot: 存档 flutter create 模板覆盖后的现场,作为多实例修复前的基线 2026-10-22 21:59:09 +08:00
56 changed files with 4664 additions and 988 deletions
+16
View File
@@ -35,3 +35,19 @@ desktop.ini
/tmp/ /tmp/
*.tmp *.tmp
.flutter-plugins-dependencies .flutter-plugins-dependencies
# ---------- 本地构建 / IDE 产物 ----------
gradle-build.log
*.iml
# ---------- 个人笔记(不上传) ----------
bug-todo.txt
# ---------- 服务器/监控日志(本地参考,不上传) ----------
monitor-log-*.csv
# ---------- 签名材料(绝不入库) ----------
android/key.properties
android/keystore/
*.jks
*.keystore
+5 -20
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
channel: "stable" channel: "stable"
project_type: app project_type: app
@@ -13,26 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- platform: android - platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- platform: ios
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: linux
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: macos
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: web
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: windows
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
# User provided section # User provided section
+1 -2
View File
@@ -8,11 +8,10 @@ GeneratedPluginRegistrant.java
.cxx/ .cxx/
.kotlin .kotlin
/build /build
.settings
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore # See https://flutter.dev/to/reference-keystore
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
.settings
+54 -25
View File
@@ -1,11 +1,24 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// 正式签名:android/key.properties(不入库)
// 文件缺失时自动回退到 debug keystore,保证 clone 下来也能直接构建
import java.io.FileInputStream
import java.util.Properties
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
}
android { android {
namespace = "com.lxh.qingting_player" namespace = "com.lxh.qingting_player"
compileSdk = 36 compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
@@ -14,49 +27,65 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.lxh.qingting_player" applicationId = "com.lxh.qingting_player"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = 34 targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
// 仅打包 arm64-v8a // 与 main 分支保持一致:仅打包 arm64-v8a
ndk { ndk {
abiFilters.addAll(listOf("arm64-v8a")) abiFilters.addAll(listOf("arm64-v8a"))
} }
} }
// 显式指定 jniLibs 源目录 // AGP 9 不再允许在 AndroidManifest 里写 android:extractNativeLibs,
sourceSets { // 改为在这里声明,效果等价于原先的“强制提取 .so”
getByName("main") { packaging {
jniLibs.srcDirs("src/main/jniLibs")
}
}
tasks.withType<Copy> {
if (name.startsWith("merge") && name.endsWith("JniLibFolders")) {
from("src/main/jniLibs")
into("$buildDir/intermediates/merged_jni_libs/debug/out")
}
}
// 打包配置
packagingOptions {
jniLibs { jniLibs {
useLegacyPackaging = true useLegacyPackaging = true
} }
pickFirsts += "**/*.so" }
// AGP 9 默认关闭 resValue 生成,这里显式打开(debug 需要 "清听 Dev" 的应用名)
buildFeatures {
resValues = true
}
signingConfigs {
create("release") {
if (hasReleaseKeystore) {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
}
}
} }
buildTypes { buildTypes {
debug { debug {
// 测试包与正式包并存安装:com.lxh.qingting_player.dev
// 说明:Android 包名每一段必须以字母开头,所以没法直接写 "_dev",
// 这里用 ".dev" 后缀,等价于给 debug 打上 dev 标记。
applicationIdSuffix = ".dev"
resValue("string", "app_name", "清听 Dev")
} }
release { release {
signingConfig = signingConfigs.getByName("debug") resValue("string", "app_name", "清听")
proguardFiles( // 有 key.properties 就用正式签名,否则回退 debug(仅本机调试用)
getDefaultProguardFile("proguard-android-optimize.txt"), signingConfig = if (hasReleaseKeystore) {
"proguard-rules.pro" signingConfigs.getByName("release")
) } else {
signingConfigs.getByName("debug")
}
} }
} }
} }
-45
View File
@@ -1,45 +0,0 @@
# ============================================================
# media_kit 核心
# ============================================================
-keep class com.media_kit.** { *; }
-keep class com.alexmercerind.** { *; }
-keep class org.mozilla.** { *; }
# 保留所有 native 方法
-keepclasseswithmembernames class * {
native <methods>;
}
# 保留所有 JNI 相关
-keep class * implements java.lang.reflect.Method { *; }
-keep class * extends java.lang.reflect.Method { *; }
# ============================================================
# audio_service 插件
# ============================================================
-keep class com.ryanheise.audioservice.** { *; }
# ============================================================
# Flutter 核心
# ============================================================
-keep class io.flutter.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.embedding.** { *; }
# ============================================================
# 忽略 Google Play Core 缺失类(如果不想添加依赖)
# ============================================================
-dontwarn com.google.android.play.core.**
-keep class com.google.android.play.core.** { *; }
# ============================================================
# 保留 Serializable 支持
# ============================================================
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
+14 -13
View File
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.lxh.qingting_player"> <!-- package 由 build.gradle.kts 的 namespace / applicationId 决定;AGP 9 已忽略这里的 package 属性 -->
<!-- 权限 -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -12,11 +12,9 @@
<application <application
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="清听" android:label="@string/app_name"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback">
android:extractNativeLibs="true"> <!-- ⭐ 强制提取 .so,避免压缩后加载失败 -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -34,8 +32,7 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- ⭐ AudioService(不加 meta-data) -->
<!-- AudioService -->
<service <service
android:name="com.ryanheise.audioservice.AudioService" android:name="com.ryanheise.audioservice.AudioService"
android:exported="true" android:exported="true"
@@ -45,8 +42,9 @@
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter> </intent-filter>
</service> </service>
<!-- Don't delete the meta-data below.
<!-- MediaButtonReceiver --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<!-- ⭐ MediaButtonReceiver -->
<receiver <receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver" android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"> android:exported="true">
@@ -55,7 +53,7 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- FileProvider for artwork(已配置,保持不变) --> <!-- ⭐ FileProvider -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -70,12 +68,15 @@
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
</queries> </queries>
</manifest> </manifest>
@@ -4,38 +4,26 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.File import java.io.File
import android.content.Context import android.content.Context
import android.media.AudioManager import android.media.AudioManager
class MainActivity : FlutterActivity() { // 必须继承 AudioServiceActivity,而不是 FlutterActivity:
// audio_service 0.18.x 会把 Dart 入口跑在一个单独的缓存引擎(audio_service_engine)里。
// 若这里用 FlutterActivity,Activity 会自建第二个 FlutterEngine,
// main() 因此被跑两遍,产生两个 PlaybackService / 两个 media_kit Player(libmpv 多实例),
// 表现为两路声音、通知栏控制的是另一个实例。
// AudioServiceActivity 通过 provideFlutterEngine() 复用同一个缓存引擎,保证全局只有一个 Player。
class MainActivity : AudioServiceActivity() {
private val CHANNEL = "com.lxh.qingting_player/file_provider" private val CHANNEL = "com.lxh.qingting_player/file_provider"
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// ⭐ 强制加载 libmediakitandroidhelper.so,触发 JNI_OnLoad,缓存 JavaVM
try {
System.loadLibrary("mediakitandroidhelper")
Log.d("MainActivity", "✅ libmediakitandroidhelper loaded")
} catch (e: UnsatisfiedLinkError) {
Log.e("MainActivity", "❌ libmediakitandroidhelper load failed", e)
}
// ⭐ 可选:预加载 libmpv.so(让系统 linker 也可见,增强兼容性)
try {
System.loadLibrary("mpv")
Log.d("MainActivity", "✅ libmpv loaded")
} catch (e: UnsatisfiedLinkError) {
Log.e("MainActivity", "❌ libmpv load failed", e)
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestAudioFocus() requestAudioFocus()
setHighRefreshRate() setHighRefreshRate()
} }
@@ -63,7 +51,7 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// FileProvider MethodChannel for sharing artwork via content:// URI // FileProvider MethodChannel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
if (call.method == "getContentUri") { if (call.method == "getContentUri") {
+2 -1
View File
@@ -3,6 +3,7 @@ android.useAndroidX=true
# This newDsl flag was added by the Flutter template # This newDsl flag was added by the Flutter template
android.newDsl=false android.newDsl=false
# This builtInKotlin flag was added by the Flutter template # This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
# 本机 Kotlin 守护进程连不上(fallback 编译),且增量缓存易损坏,沿用 main 分支原有的设置
kotlin.incremental=false kotlin.incremental=false
kotlin.parallel.tasks.in.project=false kotlin.parallel.tasks.in.project=false
android.builtInKotlin=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-all.zip distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.1-all.zip
-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="FacetManager">
<facet type="android" name="Android">
<configuration>
<option name="ALLOW_USER_CONFIGURATION" value="false" />
<option name="GEN_FOLDER_RELATIVE_PATH_APT" value="/gen" />
<option name="GEN_FOLDER_RELATIVE_PATH_AIDL" value="/gen" />
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/app/src/main/AndroidManifest.xml" />
<option name="RES_FOLDER_RELATIVE_PATH" value="/app/src/main/res" />
<option name="ASSETS_FOLDER_RELATIVE_PATH" value="/app/src/main/assets" />
<option name="LIBS_FOLDER_RELATIVE_PATH" value="/app/src/main/libs" />
<option name="PROGUARD_LOGS_FOLDER_RELATIVE_PATH" value="/app/src/main/proguard_logs" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/app/src/main/java" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/app/src/main/kotlin" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
</content>
<orderEntry type="jdk" jdkName="Android API 24 Platform" jdkType="Android SDK" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Flutter for Android" level="project" />
<orderEntry type="library" name="KotlinJavaRuntime" level="project" />
</component>
</module>
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false id("org.jetbrains.kotlin.android") version "2.4.0" apply false
} }
include(":app") include(":app")
+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,
)); ));
+56 -1
View File
@@ -27,7 +27,7 @@ class SongDatabase {
_log('📂 数据库路径: $path'); _log('📂 数据库路径: $path');
return await openDatabase( return await openDatabase(
path, path,
version: 4, // 升级版本号 version: 6, // v6:song_key 改为纯 URL 的 md5(配合分阶段探测)
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@@ -226,6 +226,14 @@ class SongDatabase {
await _createPlaybackStateTable(db); await _createPlaybackStateTable(db);
} }
} }
// ⭐ 升级到版本 5:song_key 由 String.hashCode 改为 md5(url|size|mtime)。
// 旧键带进程级随机种子、无法换算;songs / metadata_cache 都是可再生的缓存,
// 直接清空,下次播放时按需重建。歌单与收藏按路径(song_id)存储,不受影响。
if (oldVersion < 6) {
await db.delete('metadata_cache');
await db.delete('songs');
_log('🧹 已清空 songs / metadata_cache(song_key 算法升级)');
}
_log('✅ 数据库升级完成'); _log('✅ 数据库升级完成');
} }
@@ -248,6 +256,33 @@ class SongDatabase {
return result.isNotEmpty ? result.first : null; return result.isNotEmpty ? result.first : null;
} }
/// 批量按路径查元数据,返回 path -> row。
/// 文件列表页一次要显示几十上百首,逐条查会太慢。
Future<Map<String, Map<String, dynamic>>> getSongsByPaths(
List<String> paths) async {
final result = <String, Map<String, dynamic>>{};
if (paths.isEmpty) return result;
final db = await database;
const chunkSize = 400; // 避免 SQLite 变量数量上限
for (var i = 0; i < paths.length; i += chunkSize) {
final end = (i + chunkSize) > paths.length ? paths.length : i + chunkSize;
final slice = paths.sublist(i, end);
final placeholders = List.filled(slice.length, '?').join(',');
final rows = await db.query(
'songs',
where: 'remote_path IN ($placeholders)',
whereArgs: slice,
);
for (final row in rows) {
final path = row['remote_path'] as String?;
if (path != null) result[path] = row;
}
}
_logQuery('songs', 'getByPaths', args: {'count': result.length});
return result;
}
Future<Map<String, dynamic>?> getSongByHash(String contentHash) async { Future<Map<String, dynamic>?> getSongByHash(String contentHash) async {
_logQuery('songs', 'getByHash', args: {'content_hash': contentHash}); _logQuery('songs', 'getByHash', args: {'content_hash': contentHash});
final db = await database; final db = await database;
@@ -278,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),
+83 -15
View File
@@ -1,60 +1,128 @@
// lib/metadata/file_provider.dart // lib/metadata/file_provider.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; // ⭐ 添加 import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
import '../utils/song_key.dart';
abstract class FileProvider { abstract class FileProvider {
Future<File?> getFile(String url); /// [maxBytes] 是只取文件头部的上限(本地文件实现会忽略它)。
Future<File?> getFile(String url, {int maxBytes = 512 * 1024});
} }
class WebDAVFileProvider implements FileProvider { class WebDAVFileProvider implements FileProvider {
/// 只为读标签取文件头部,绝不整文件下载。
/// 之前用 dio.download 会把整首歌拉到临时目录,和 mpv 的流式播放抢带宽,
/// 20Mbps 的 FRP 链路上尤其明显(表现为流量很高但缓冲一直为 0)。
///
/// 从访问日志看:2MB 的探测请求在这条链路上要 10~18 秒,
/// 期间 libmpv 的 206 吞吐会被压到 1Mbps 左右,所以压到 512KB。
/// 常见标签 + 封面都在这范围内;读不到时由上层在 80% 处重试。
static const int defaultMaxBytes = 512 * 1024;
/// 切歌时取消上一次未完成的下载,避免多个下载叠加占满带宽。
static CancelToken? _activeToken;
final Dio _dio = Dio(); final Dio _dio = Dio();
@override @override
Future<File?> getFile(String url) async { Future<File?> getFile(String url, {int maxBytes = defaultMaxBytes}) async {
try { try {
// 1. 获取缓存目录
final cacheDir = await getTemporaryDirectory(); final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp'; final cachePath =
'${cacheDir.path}/metadata_${songKeyForUrl(url)}_${maxBytes ~/ 1024}k.part';
final cacheFile = File(cachePath); final cacheFile = File(cachePath);
// 2. 如果缓存存在且未过期,直接返回 // 1. 命中本地缓存(头部文件同样按 24h 过期)
if (await cacheFile.exists()) { if (await cacheFile.exists()) {
final stat = await cacheFile.stat(); final stat = await cacheFile.stat();
// 缓存 1 小时内有效(86400 秒) if (stat.size > 0 &&
if (DateTime.now().difference(stat.modified).inSeconds < 86400) { DateTime.now().difference(stat.modified).inSeconds < 86400) {
return cacheFile; return cacheFile;
} }
} }
// 3. 获取认证头 // 2. 认证头
final headers = await WebDAVService.instance.getAuthHeaders(); final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) return null; if (headers.isEmpty) return null;
// 4. 只下载前 1MB // 3. 取消上一次未完成的下载
await _dio.download( _activeToken?.cancel('superseded');
final token = CancelToken();
_activeToken = token;
// 4. 只请求前 2MB
final response = await _dio.get<ResponseBody>(
url, url,
cachePath, cancelToken: token,
options: Options( options: Options(
headers: headers, headers: {
receiveTimeout: const Duration(seconds: 10), ...headers,
'Range': 'bytes=0-${maxBytes - 1}',
},
responseType: ResponseType.stream,
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10), sendTimeout: const Duration(seconds: 10),
followRedirects: true,
), ),
); );
final body = response.data;
if (body == null) return null;
// 5. 写入临时文件(最多 _maxBytes)
final sink = cacheFile.openWrite();
var written = 0;
try {
await for (final chunk in body.stream) {
final remain = maxBytes - written;
if (remain <= 0) break;
final data =
chunk.lengthInBytes > remain ? chunk.sublist(0, remain) : chunk;
sink.add(data);
written += data.lengthInBytes;
if (written >= maxBytes) break;
}
} finally {
await sink.flush();
await sink.close();
}
if (written == 0) {
try {
await cacheFile.delete();
} catch (_) {}
return null;
}
debugPrint(
'📦 [WebDAVFileProvider] 取头部 ${(written / 1024).toStringAsFixed(0)}KB'
'(上限 ${maxBytes ~/ 1024}KB,不再整文件下载)');
return cacheFile; return cacheFile;
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
debugPrint('↩️ [WebDAVFileProvider] 下载已取消(切歌触发)');
return null;
}
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
return null;
} catch (e) { } catch (e) {
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e'); debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
return null; return null;
} }
} }
/// 主动取消进行中的元数据下载(切歌时可由上层调用)。
static void cancelActive() {
_activeToken?.cancel('superseded');
_activeToken = null;
}
} }
class LocalFileProvider implements FileProvider { class LocalFileProvider implements FileProvider {
@override @override
Future<File?> getFile(String url) async { Future<File?> getFile(String url, {int maxBytes = 512 * 1024}) async {
final file = File(url); final file = File(url);
if (await file.exists()) { if (await file.exists()) {
return file; return file;
+9
View File
@@ -57,6 +57,12 @@ class NormalizedMetadata {
final Duration? duration; final Duration? duration;
final Uint8List? artwork; final Uint8List? artwork;
/// 字段来源:true 表示来自标签,false 表示从文件名/路径推断。
/// 置信度模型需要区分这两者——"标签里的标题"和"文件名当标题"价值完全不同。
final bool titleFromTag;
final bool artistFromTag;
final bool albumFromTag;
const NormalizedMetadata({ const NormalizedMetadata({
this.title = '', this.title = '',
this.artist = '', this.artist = '',
@@ -70,6 +76,9 @@ class NormalizedMetadata {
this.totalDisc = 0, this.totalDisc = 0,
this.duration, this.duration,
this.artwork, this.artwork,
this.titleFromTag = false,
this.artistFromTag = false,
this.albumFromTag = false,
}); });
bool get isEmpty => bool get isEmpty =>
+96 -14
View File
@@ -4,21 +4,22 @@ import 'metadata_model.dart';
class MetadataNormalizer { class MetadataNormalizer {
NormalizedMetadata normalize(RawMetadata raw, NormalizedMetadata normalize(RawMetadata raw,
{String fileName = '', String filePath = ''}) { {String fileName = '', String filePath = ''}) {
final title = raw.title.trim(); final tagTitle = raw.title.trim();
final artist = raw.artist.trim(); final tagArtist = raw.artist.trim();
final tagAlbum = raw.album.trim();
final finalTitle = final finalTitle =
title.isNotEmpty ? title : _inferTitleFromFileName(fileName); tagTitle.isNotEmpty ? tagTitle : _inferTitleFromFileName(fileName);
final finalArtist =
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath); final artistFromPath = _inferArtistFromPath(filePath);
final finalArtist2 = finalArtist.isNotEmpty final finalArtist = tagArtist.isNotEmpty
? finalArtist ? tagArtist
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家'); : (raw.performers.isNotEmpty ? raw.performers.first : artistFromPath);
return NormalizedMetadata( return NormalizedMetadata(
title: finalTitle, title: finalTitle,
artist: finalArtist2, artist: finalArtist,
album: raw.album, album: tagAlbum,
genres: raw.genres, genres: raw.genres,
performers: raw.performers, performers: raw.performers,
year: raw.year, year: raw.year,
@@ -28,6 +29,9 @@ class MetadataNormalizer {
totalDisc: raw.totalDisc, totalDisc: raw.totalDisc,
duration: raw.duration, duration: raw.duration,
artwork: raw.artwork, artwork: raw.artwork,
titleFromTag: tagTitle.isNotEmpty,
artistFromTag: tagArtist.isNotEmpty || raw.performers.isNotEmpty,
albumFromTag: tagAlbum.isNotEmpty,
); );
} }
@@ -49,16 +53,94 @@ class MetadataNormalizer {
return '未知艺术家'; return '未知艺术家';
} }
/// 置信度评估(元数据引擎第 2 步)。
///
/// 设计原则:
/// 1. 标签 >= 推断:来自标签的字段永远比文件名/目录推断更可信;
/// 2. 字段齐全度:标题、艺术家、专辑依次加分;
/// 3. 历史一致性:同一艺人/专辑已有多条可信记录时额外加权,
/// 这是"自学习"的入口——库越大,判断越稳;
/// 4. 可解释:每个加分项都写进 evidence,方便日志与后续调参。
///
/// 分数区间大致:
/// 0.35 只有文件名
/// 0.55 标题来自标签、艺术家靠推断
/// 0.80 标题 + 艺术家都来自标签
/// 0.90 标题 + 艺术家 + 专辑
/// 0.95 以上再加年份/封面/历史一致性
CandidateMetadata evaluate( CandidateMetadata evaluate(
NormalizedMetadata normalized, List<Map<String, dynamic>> history) { NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
// 第一版:直接接受,置信度 0.8 final evidence = <String, double>{};
var confidence = 0.25;
evidence['base'] = 0.25;
if (normalized.title.isNotEmpty) {
confidence += 0.20;
evidence['title'] = 0.20;
}
if (normalized.titleFromTag) {
confidence += 0.10;
evidence['title_from_tag'] = 0.10;
} else if (normalized.title.isNotEmpty) {
// 拿文件名当标题:明确扣分
confidence -= 0.10;
evidence['title_inferred_penalty'] = -0.10;
}
if (normalized.artist.isNotEmpty && normalized.artist != '未知艺术家') {
confidence += 0.15;
evidence['artist'] = 0.15;
}
if (normalized.artistFromTag) {
confidence += 0.10;
evidence['artist_from_tag'] = 0.10;
}
if (normalized.album.isNotEmpty) {
confidence += 0.10;
evidence['album'] = 0.10;
}
if (normalized.year > 0) {
confidence += 0.03;
evidence['year'] = 0.03;
}
if (normalized.artwork != null && normalized.artwork!.isNotEmpty) {
confidence += 0.02;
evidence['artwork'] = 0.02;
}
// ---- 历史一致性(自学习的核心)----
if (history.isNotEmpty) {
final sameArtist = history
.where((row) => (row['artist'] as String?) == normalized.artist)
.length;
if (sameArtist >= 2) {
confidence += 0.05;
evidence['artist_history'] = 0.05;
}
if (normalized.album.isNotEmpty) {
final sameAlbum = history
.where((row) => (row['album'] as String?) == normalized.album)
.length;
if (sameAlbum >= 2) {
confidence += 0.03;
evidence['album_history'] = 0.03;
}
}
}
confidence = confidence.clamp(0.05, 0.98);
return CandidateMetadata( return CandidateMetadata(
metadata: normalized, metadata: normalized,
confidence: 0.8, confidence: double.parse(confidence.toStringAsFixed(2)),
evidence: {'normalized': 0.8}, evidence: evidence,
); );
} }
/// 按阈值决定最终元数据。
/// 低于阈值时仍返回数据(用于展示),但 source 标成 pending,
/// 调用方据此累计 validation_count、下次播放重新确认。
FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) { FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) {
return FinalMetadata( return FinalMetadata(
title: candidate.metadata.title, title: candidate.metadata.title,
@@ -76,7 +158,7 @@ class MetadataNormalizer {
totalDisc: candidate.metadata.totalDisc, totalDisc: candidate.metadata.totalDisc,
duration: candidate.metadata.duration, duration: candidate.metadata.duration,
artwork: candidate.metadata.artwork, artwork: candidate.metadata.artwork,
source: 'normalized', source: candidate.confidence >= threshold ? 'normalized' : 'pending',
); );
} }
} }
+99 -37
View File
@@ -6,9 +6,12 @@ import '../database/song_database.dart';
import 'metadata_model.dart'; 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 '../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';
import '../utils/song_key.dart';
class MetadataService { class MetadataService {
static final MetadataService _instance = MetadataService._internal(); static final MetadataService _instance = MetadataService._internal();
@@ -20,9 +23,8 @@ class MetadataService {
final MetadataCache _memoryCache = MetadataCache(); final MetadataCache _memoryCache = MetadataCache();
final SongDatabase _db = SongDatabase(); final SongDatabase _db = SongDatabase();
String _generateSongKey(String url, int fileSize, int modifiedTime) { String _generateSongKey(String url) {
final raw = '$url|$fileSize|$modifiedTime'; return songKeyFor(url);
return raw.hashCode.toString();
} }
FileProvider _getProvider(String url) { FileProvider _getProvider(String url) {
@@ -58,25 +60,39 @@ class MetadataService {
} }
} }
// 2. 获取文件 // 2. 分阶段取头部:先 128KB 拿 title/artist,读不到再补到 512KB。
// 弱网下首包更小,标签能更快出来,也少占带宽。
final provider = _getProvider(url); final provider = _getProvider(url);
final file = await provider.getFile(url); var file = await provider.getFile(url, maxBytes: 128 * 1024);
if (file == null || !await file.exists()) { if (file == null || !await file.exists()) {
debugPrint('⚠️ [MetadataService] file not available, using fallback'); debugPrint('⚠️ [MetadataService] file not available, using fallback');
return _fallbackFromFileName(fileName); return _fallbackFromFileName(fileName);
} }
final stat = await file.stat(); var raw = await _reader.readRawMetadata(file);
final songKey = if (raw.title.isEmpty && raw.artist.isEmpty) {
_generateSongKey(url, stat.size, stat.modified.millisecondsSinceEpoch); debugPrint('📦 [MetadataService] 128KB 没读到标签,补取到 512KB');
final bigger = await provider.getFile(url, maxBytes: 512 * 1024);
if (bigger != null && await bigger.exists()) {
file = bigger;
raw = await _reader.readRawMetadata(file);
}
}
// 3. SQLite 查询(高置信度直接使用) final stat = await file.stat();
final songKey = _generateSongKey(url);
// 3. SQLite 查询:只有「已验证」的记录才直接返回。
// pending 记录会继续往下走,重新读取并累计 validation_count,
// 这就是自修复:置信度不足的记录会在下次播放时被重新确认。
Map<String, dynamic>? dbSong;
if (!forceRefresh) { if (!forceRefresh) {
final dbSong = await _db.getSong(songKey); dbSong = await _db.getSong(songKey);
if (dbSong != null) { if (dbSong != null) {
final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0; final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0;
if (confidence >= 0.6) { final status = dbSong['metadata_status'] as String? ?? 'pending';
if (confidence >= 0.6 && status == 'verified') {
debugPrint( debugPrint(
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}'); '📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
@@ -103,59 +119,105 @@ 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] 记录未验证'
'(conf=${confidence.toStringAsFixed(2)}, status=$status),重新确认');
} }
} }
// 4. 读取原始 metadata // 4. 标准化(raw 已在上面分阶段读取)
final raw = await _reader.readRawMetadata(file);
// 5. 标准化
final normalized = _normalizer.normalize( final normalized = _normalizer.normalize(
raw, raw,
fileName: fileName, fileName: fileName,
filePath: url, filePath: url,
); );
// 6. 评估 // 6. 评估(含历史一致性)
final history = await _db.getSongsByArtist(normalized.artist, limit: 10); final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
final candidate = _normalizer.evaluate(normalized, history); final candidate = _normalizer.evaluate(normalized, history);
final finalMetadata = _normalizer.decide(candidate); final finalMetadata = _normalizer.decide(candidate);
debugPrint(
'📢 [MetadataService] finalMetadata.artwork is ${finalMetadata.artwork != null ? 'not null (${finalMetadata.artwork!.length} bytes)' : 'null'}');
// 7. 保存封面图到本地 // 7. 与已有记录合并(一致性 + 次数由状态机决定)
String? artworkPath; final prevConfidence = (dbSong?['confidence'] as num?)?.toDouble() ?? 0.0;
if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) { final decision = MetadataStability.decide(
previous: dbSong,
candidate: candidate,
title: finalMetadata.title,
artist: finalMetadata.artist,
);
final validationCount = decision.validationCount;
final useNew = dbSong == null || candidate.confidence >= prevConfidence;
final effectiveConfidence =
useNew ? candidate.confidence : prevConfidence;
// 8. 保存封面图(只有采用新结果且确实读到图时才写)
String? artworkPath = dbSong?['artwork_path'] as String?;
if (useNew &&
finalMetadata.artwork != null &&
finalMetadata.artwork!.isNotEmpty) {
artworkPath = artworkPath =
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey); await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
} }
// 8. 保存到 SQLite final status = decision.status;
await _db.insertSong({ debugPrint('🧭 [MetadataService] 状态机: ${decision.reason} → $status'
'(validations=$validationCount)');
final row = <String, dynamic>{
'song_key': songKey, 'song_key': songKey,
'remote_path': url, // remote_path 必须存应用内路径(fileId / song.id),不能存完整 URL
'remote_path': fileId,
'file_size': stat.size, 'file_size': stat.size,
'modified_time': stat.modified.millisecondsSinceEpoch, 'modified_time': stat.modified.millisecondsSinceEpoch,
'title': finalMetadata.title, 'title': useNew ? finalMetadata.title : (dbSong?['title'] ?? finalMetadata.title),
'artist': finalMetadata.artist, 'artist':
'album': finalMetadata.album, useNew ? finalMetadata.artist : (dbSong?['artist'] ?? finalMetadata.artist),
'genre': finalMetadata.genre, 'album': useNew ? finalMetadata.album : (dbSong?['album'] ?? finalMetadata.album),
'genre': useNew ? finalMetadata.genre : (dbSong?['genre'] ?? finalMetadata.genre),
'artwork_path': artworkPath, 'artwork_path': artworkPath,
'confidence': candidate.confidence, 'confidence': effectiveConfidence,
'validation_count': 1, 'validation_count': validationCount,
'first_scan': DateTime.now().millisecondsSinceEpoch, 'first_scan':
dbSong?['first_scan'] ?? DateTime.now().millisecondsSinceEpoch,
'last_scan': DateTime.now().millisecondsSinceEpoch, 'last_scan': DateTime.now().millisecondsSinceEpoch,
'metadata_status': candidate.confidence >= 0.6 ? 'verified' : 'pending', 'metadata_status': status,
}); };
if (dbSong == null) {
await _db.insertSong(row);
} else {
await _db.updateSong(songKey, row);
}
// 通知界面局部刷新(收藏/文件列表/歌单等)
MetadataEvents().publish(fileId, row);
final merged = FinalMetadata(
title: row['title'] as String? ?? '',
artist: row['artist'] as String? ?? '',
album: row['album'] as String? ?? '',
genre: row['genre'] as String? ?? '',
genres: finalMetadata.genres,
performers: finalMetadata.performers,
year: finalMetadata.year,
duration: finalMetadata.duration,
artwork: finalMetadata.artwork,
source: status,
);
// 9. 内存缓存 // 9. 内存缓存
await _memoryCache.put(fileId, finalMetadata); await _memoryCache.put(fileId, merged);
debugPrint( debugPrint('📊 [MetadataService] $status '
'✅ [MetadataService] saved: ${finalMetadata.title} - ${finalMetadata.artist} (confidence: ${candidate.confidence})'); 'conf=${effectiveConfidence.toStringAsFixed(2)} '
return finalMetadata; 'validations=$validationCount '
'evidence=${candidate.evidence}');
debugPrint('✅ [MetadataService] saved: ${merged.title} - ${merged.artist}');
return merged;
} }
FinalMetadata _fallbackFromFileName(String fileName) { FinalMetadata _fallbackFromFileName(String fileName) {
+98
View File
@@ -0,0 +1,98 @@
// lib/metadata/metadata_stability.dart
import 'metadata_model.dart';
/// 状态机判定结果
class StabilityDecision {
/// 'verified' | 'pending'
final String status;
final String reason;
final int validationCount;
const StabilityDecision({
required this.status,
required this.reason,
required this.validationCount,
});
bool get isVerified => status == 'verified';
}
/// 元数据状态机:决定一条记录能不能升级为 verified。
///
/// 第 2 步的规则是"置信度 >= 0.6 且验证次数 >= 2",太粗暴:
/// 两次读到的是不同结果也会被当成"验证通过"。这里加入一致性检查——
///
/// - 首次见到 → pending(需要第二次确认)
/// - 与上次记录冲突(标题或艺术家明显不同)→ 次数重置为 1,继续 pending
/// - 一致且次数 >= 2、置信度达标 → verified
/// - 置信度不足 → pending
///
/// 这样一次误读不会把错误标签固化,下次播放会重新校正。
class MetadataStability {
static const double threshold = 0.6;
static StabilityDecision decide({
required Map<String, dynamic>? previous,
required CandidateMetadata candidate,
required String title,
required String artist,
}) {
if (previous == null) {
return const StabilityDecision(
status: 'pending',
reason: 'first_seen',
validationCount: 1,
);
}
final prevConfidence =
(previous['confidence'] as num?)?.toDouble() ?? 0.0;
final prevTitle = previous['title'] as String? ?? '';
final prevArtist = previous['artist'] as String? ?? '';
final prevCount = (previous['validation_count'] as int?) ?? 0;
final consistent = _similar(prevTitle, title) && _similar(prevArtist, artist);
if (!consistent) {
return const StabilityDecision(
status: 'pending',
reason: 'conflict',
validationCount: 1,
);
}
final count = prevCount + 1;
final best = candidate.confidence > prevConfidence
? candidate.confidence
: prevConfidence;
if (best < threshold) {
return StabilityDecision(
status: 'pending',
reason: 'low_confidence',
validationCount: count,
);
}
if (count < 2) {
return StabilityDecision(
status: 'pending',
reason: 'need_second_validation',
validationCount: count,
);
}
return StabilityDecision(
status: 'verified',
reason: 'stable',
validationCount: count,
);
}
/// 字段缺失不算冲突;只有两边都有值且不同才算。
static bool _similar(String a, String b) {
final left = a.trim().toLowerCase();
final right = b.trim().toLowerCase();
if (left.isEmpty || right.isEmpty) return true;
return left == right;
}
}
+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;
}
+424 -166
View File
@@ -1,10 +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 '../services/playback_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 '../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';
@@ -17,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;
@@ -25,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',
}); });
@@ -34,11 +45,10 @@ class SongItem {
: fileName.replaceAll(RegExp(r'\.[^.]*$'), ''); : fileName.replaceAll(RegExp(r'\.[^.]*$'), '');
String get displaySubtitle { String get displaySubtitle {
if (metadataState == 'success' && artist != null) { if (metadataState == 'success' && artist != null && artist!.isNotEmpty) {
return artist!; return artist!;
} else {
return sourceTag;
} }
return sourceTag.isNotEmpty ? sourceTag : '未知艺术家';
} }
} }
@@ -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(() {});
} }
@@ -236,7 +298,8 @@ class _HomePageState extends BaseState<HomePage> {
safeSetState(() => _isLoading = true); safeSetState(() => _isLoading = true);
try { try {
await WebDAVService.instance.loadCredentials(); await WebDAVService.instance.loadCredentials();
_favorites = []; await _loadFavoritesFromDb();
await _loadPlaylistSummaries();
} catch (e) { } catch (e) {
_favorites = []; _favorites = [];
} finally { } finally {
@@ -244,18 +307,65 @@ class _HomePageState extends BaseState<HomePage> {
} }
} }
/// 收藏列表直接绑定 favorites 表 + songs 元数据
Future<void> _loadFavoritesFromDb() async {
final db = SongDatabase();
final favRows = await db.getFavorites();
final paths = favRows
.map((row) => row['song_id'] as String?)
.whereType<String>()
.toList();
final meta = await db.getSongsByPaths(paths);
final items = <SongItem>[];
for (final path in paths) {
final row = meta[path];
final title = (row?['title'] as String?)?.trim() ?? '';
final artist = (row?['artist'] as String?)?.trim() ?? '';
final hasTitle = title.isNotEmpty;
// path 是百分号编码的(播放时必须保持),显示前要解码,
// 否则没有元数据的收藏会显示成 "13%E6%A5%BC..." 这种不可读串。
final rawName = path.split('/').last;
String displayName;
try {
displayName = Uri.decodeComponent(rawName);
} catch (_) {
displayName = rawName;
}
// 取上级目录名,作为没有标签时的副标题(加括号区分"这是文件夹")
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
String folderName = '';
if (segments.length >= 2) {
try {
folderName = Uri.decodeComponent(segments[segments.length - 2]);
} catch (_) {
folderName = segments[segments.length - 2];
}
}
items.add(SongItem(
path: path,
fileName: displayName,
title: hasTitle ? title : 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)' : '',
metadataState: hasTitle ? 'success' : 'unknown',
));
}
_favorites = items;
}
void _playSong(SongItem song) async { void _playSong(SongItem song) async {
try { try {
final url = WebDAVService.instance.getFileUrl(song.path); // 先校验登录,再开始播放。
// 以前这里是:playSong() 不 await,紧跟着又 PlaybackService().play() 一次,
final audioService = context.read<AudioService>(); // 同一个 Player 会被 open 两次,media_kit 串行化时第二次会顶掉第一次,
audioService.playSong(Song( // 表现就是"媒体加载了但没有声音"。
id: song.path,
title: song.displayTitle,
artist: song.displaySubtitle,
url: url,
));
final headers = await WebDAVService.instance.getAuthHeaders(); final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) { if (headers.isEmpty) {
@@ -270,7 +380,17 @@ class _HomePageState extends BaseState<HomePage> {
return; return;
} }
await PlaybackService().play(url, headers: headers); if (!mounted) return;
final url = WebDAVService.instance.getFileUrl(song.path);
final audioService = context.read<AudioService>();
await audioService.playSong(Song(
id: song.path,
title: song.displayTitle,
artist: song.displaySubtitle,
artworkPath: song.artworkPath,
url: url,
));
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -283,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(
@@ -311,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(
@@ -356,14 +724,21 @@ 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,
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.menu, color: Colors.white54), icon: const Icon(Icons.menu, color: Colors.white54),
onPressed: () {}, onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const SettingsPage(),
),
);
},
), ),
], ],
), ),
@@ -372,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(
@@ -391,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),
), ),
@@ -406,6 +776,7 @@ class _HomePageState extends BaseState<HomePage> {
builder: (_) => const WebDAVFileListPage(), builder: (_) => const WebDAVFileListPage(),
), ),
); );
await _loadFavoritesFromDb();
_resetSnapState(); _resetSnapState();
safeSetState(() {}); safeSetState(() {});
} else { } else {
@@ -498,7 +869,7 @@ class _HomePageState extends BaseState<HomePage> {
], ],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -518,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,
), ),
@@ -546,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,
), ),
@@ -586,7 +929,7 @@ class _HomePageState extends BaseState<HomePage> {
), ),
], ],
), ),
const SizedBox(height: 32), const SizedBox(height: 16),
], ],
), ),
), ),
@@ -599,128 +942,43 @@ 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),
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: [ children: [
Icon( _buildFavoritesTab(),
Icons.favorite_border, _buildPlaylistsTab(),
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];
final itemProgress =
(_listReveal * 2 - index / 5)
.clamp(0.0, 1.0);
return AnimatedOpacity(
opacity: itemProgress,
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(0xFFB8D4D0),
size: 20,
),
onPressed: () {
safeSetState(() {
_favorites.removeAt(index);
});
},
),
onTap: () => _playSong(song),
),
),
);
},
childCount: _favorites.length,
),
),
),
], ],
), ),
), ),
+465 -134
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,71 +253,314 @@ 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,
spreadRadius: 10,
),
], ],
image: song.artwork != null ),
? DecorationImage( ),
image: MemoryImage(song.artwork!), );
}
/// 小窗/分屏:宽高比明显小于全屏(1368x3192 → 2.33;1368x2577 → 1.88)
bool get _isCompact {
final size = MediaQuery.of(context).size;
if (size.width <= 0) return false;
return size.height / size.width < 2.05;
}
/// 顶部指示器:当前页对应"长条",另外的页是小点。
/// 全屏三页(信息/主页/歌词),小窗两页(信息/主页)。
Widget _buildPageIndicator(bool compact) {
Widget segment(int index) {
final active = _pageIndex == index;
return GestureDetector(
onTap: () => _pageController.animateToPage(
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),
),
),
),
);
}
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, fit: BoxFit.cover,
gaplessPlayback: true,
) )
: null, : 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,
), ),
child: song.artwork == null
? const Icon(Icons.music_note,
size: 64, color: Colors.white24)
: null,
), ),
const SizedBox(height: 48), );
Column(
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, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( 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: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 8), ),
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( Text(
song.artist, song.artist,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: compact ? 14 : 16,
color: Colors.grey[400], color: Colors.grey[300],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// ---- 信息区(专辑 / 目录)----
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,
), ),
textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
),
const SizedBox(height: 40), const Spacer(),
// ---- 进度 ----
_buildProgressSection(service), _buildProgressSection(service),
const SizedBox(height: 32), SizedBox(height: compact ? 10 : 18),
_buildControlButtons(service, isPlaying),
const SizedBox(height: 16), // ---- 控制 ----
_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,
),
],
), ),
); );
} }
@@ -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,57 +582,77 @@ 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 /
final bufferProgress = duration.inMilliseconds > 0
? (service.bufferedNotifier.value.inMilliseconds /
duration.inMilliseconds) duration.inMilliseconds)
.clamp(0.0, 1.0) .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(
_dragProgress != null
? Duration(
milliseconds:
(_dragProgress! * duration.inMilliseconds)
.round(),
)
: position,
),
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 24,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final width = constraints.maxWidth; final width = constraints.maxWidth;
final bufferWidth =
width * bufferProgress.clamp(0.0, 1.0);
final progressWidth =
width * displayProgress.clamp(0.0, 1.0);
return Stack( return Stack(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
children: [ children: [
Container( // 背景轨道
width: width, Positioned(
height: 4, left: 0,
right: 0,
top: 10.5,
child: Container(
height: 3,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[800], color: Colors.grey[800],
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
Container( ),
width: bufferWidth, // 已缓冲
height: 4, Positioned(
left: 0,
top: 10.5,
child: Container(
width: width * bufferProgress.clamp(0.0, 1.0),
height: 3,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF4A7A7A), color: const Color(0xFF4A7A7A),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
Container( ),
width: progressWidth, // 已播放
height: 4, Positioned(
left: 0,
top: 10.5,
child: Container(
width:
width * displayProgress.clamp(0.0, 1.0),
height: 3,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFB8D4D0), color: const Color(0xFFB8D4D0),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
),
Positioned.fill( Positioned.fill(
child: SliderTheme( child: SliderTheme(
data: SliderTheme.of(context).copyWith( data: SliderTheme.of(context).copyWith(
@@ -317,9 +670,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
min: 0.0, min: 0.0,
max: 1.0, max: 1.0,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() => _dragProgress = value);
_dragProgress = value;
});
}, },
onChangeEnd: (value) { onChangeEnd: (value) {
final newPosition = Duration( final newPosition = Duration(
@@ -328,9 +679,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
.round(), .round(),
); );
service.seekTo(newPosition); service.seekTo(newPosition);
setState(() { setState(() => _dragProgress = null);
_dragProgress = null;
});
}, },
), ),
), ),
@@ -340,24 +689,11 @@ class _PlayerPageState extends BaseState<PlayerPage> {
}, },
), ),
), ),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(position),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
), ),
const SizedBox(width: 10),
Text( Text(
_formatDuration(duration), _formatDuration(duration),
style: TextStyle( style: TextStyle(fontSize: 12, color: Colors.grey[500]),
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),
),
],
),
);
}
}
+46 -4
View File
@@ -2,6 +2,8 @@
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 '../database/song_database.dart';
import '../widgets/song_artwork.dart';
class PlaylistPage extends StatefulWidget { class PlaylistPage extends StatefulWidget {
const PlaylistPage({super.key}); const PlaylistPage({super.key});
@@ -17,6 +19,9 @@ class _PlaylistPageState extends State<PlaylistPage> {
int _scrollGeneration = 0; int _scrollGeneration = 0;
bool _hasScrolledToCurrent = false; bool _hasScrolledToCurrent = false;
/// path -> 封面文件路径(来自 songs 表)
Map<String, String> _artworkByPath = {};
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整) // ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
// 实际约 72-76px,取 74px 作为稳定值 // 实际约 72-76px,取 74px 作为稳定值
static const double _itemExtent = 74.0; static const double _itemExtent = 74.0;
@@ -28,13 +33,46 @@ class _PlaylistPageState extends State<PlaylistPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadArtworkPaths();
AudioService().addListener(_onAudioChanged);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToCurrentSong(); _scrollToCurrentSong();
}); });
} }
String? _lastArtworkSongId;
/// 切歌后可能有新歌补全了封面,重新拉一次路径表
void _onAudioChanged() {
final song = AudioService().currentSong;
if (song == null || song.id == _lastArtworkSongId) return;
_lastArtworkSongId = song.id;
_loadArtworkPaths();
}
/// 批量取封面文件路径,供列表显示
Future<void> _loadArtworkPaths() async {
try {
final queue = AudioService().queue;
if (queue.isEmpty) return;
final rows =
await SongDatabase().getSongsByPaths(queue.map((s) => s.id).toList());
final map = <String, String>{};
rows.forEach((path, row) {
final artwork = row['artwork_path'] as String?;
if (artwork != null && artwork.isNotEmpty) map[path] = artwork;
});
if (mounted && map.isNotEmpty) {
setState(() => _artworkByPath = map);
}
} catch (_) {
// 封面只是装饰,失败不影响列表
}
}
@override @override
void dispose() { void dispose() {
AudioService().removeListener(_onAudioChanged);
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -271,11 +309,15 @@ class _PlaylistPageState extends State<PlaylistPage> {
horizontal: 8, horizontal: 8,
vertical: 4, vertical: 4,
), ),
leading: Icon( leading: SongArtwork(
bytes: song.artwork,
filePath: _artworkByPath[song.id],
size: 48,
fallbackIcon:
isCurrent ? Icons.play_arrow : Icons.music_note, isCurrent ? Icons.play_arrow : Icons.music_note,
color: fallbackColor: isCurrent
isCurrent ? const Color(0xFFB8D4D0) : Colors.grey[600], ? const Color(0xFFB8D4D0)
size: 24, : Colors.grey[600],
), ),
title: Text( title: Text(
song.title, song.title,
+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),
),
],
),
);
}
}
+352 -44
View File
@@ -1,59 +1,367 @@
import 'package:flutter/material.dart'; // lib/pages/settings_page.dart
import 'dart:io';
class SettingsPage extends StatelessWidget { import 'package:android_intent_plus/android_intent.dart';
import 'package:android_intent_plus/flag.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
import '../services/webdav_service.dart';
import '../utils/file_provider_utils.dart';
/// 与 pubspec.yaml 的 version 保持同步(发版时一起改)
const String kAppVersion = '1.1.0-dev.1';
const int kAppBuild = 2;
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
Widget build(BuildContext context) { State<SettingsPage> createState() => _SettingsPageState();
return Scaffold( }
appBar: AppBar(title: const Text('设置')),
body: ListView( class _SettingsPageState extends State<SettingsPage> {
children: [ bool _loading = true;
const SizedBox(height: 20),
_buildTile( int _songRows = 0;
icon: Icons.folder_open_outlined, int _metadataRows = 0;
title: '本地文件 (SAF)', int _favoriteRows = 0;
subtitle: '通过系统文件选择器导入', int _artworkCount = 0;
onTap: () { int _artworkBytes = 0;
// 🟢 这里粘贴你旧项目的 SAF 逻辑 int _tempCount = 0;
ScaffoldMessenger.of(context).showSnackBar( int _tempBytes = 0;
const SnackBar(content: Text('待接入 SAF 逻辑')),
); @override
}, void initState() {
super.initState();
_loadStats();
}
Future<void> _loadStats() async {
setState(() => _loading = true);
try {
final db = await SongDatabase().database;
final songRows =
(await db.rawQuery('SELECT COUNT(*) AS c FROM songs')).first['c'];
final metaRows = (await db.rawQuery(
'SELECT COUNT(*) AS c FROM metadata_cache'))
.first['c'];
final favRows =
(await db.rawQuery('SELECT COUNT(*) AS c FROM favorites')).first['c'];
var artworkCount = 0, artworkBytes = 0;
final docs = await getApplicationDocumentsDirectory();
final artDir = Directory('${docs.path}/artworks');
if (await artDir.exists()) {
await for (final entity in artDir.list()) {
if (entity is File) {
artworkCount++;
artworkBytes += await entity.length();
}
}
}
var tempCount = 0, tempBytes = 0;
final tmp = await getTemporaryDirectory();
if (await tmp.exists()) {
await for (final entity in tmp.list()) {
if (entity is File &&
entity.path.contains('/metadata_') &&
entity.path.endsWith('.part')) {
tempCount++;
tempBytes += await entity.length();
}
}
}
if (!mounted) return;
setState(() {
_songRows = songRows as int? ?? 0;
_metadataRows = metaRows as int? ?? 0;
_favoriteRows = favRows as int? ?? 0;
_artworkCount = artworkCount;
_artworkBytes = artworkBytes;
_tempCount = tempCount;
_tempBytes = tempBytes;
_loading = false;
});
} catch (e) {
debugPrint('⚠️ [Settings] 统计失败: $e');
if (mounted) setState(() => _loading = false);
}
}
Future<void> _clearMetadataCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text('清理元数据缓存', style: TextStyle(color: Colors.white)),
content: const Text(
'会删除歌曲标签缓存和封面文件,歌单与收藏不受影响。\n下次播放时重新抓取。',
style: TextStyle(color: Colors.grey),
), ),
_buildTile( actions: [
icon: Icons.cloud_outlined, TextButton(
title: 'WebDAV 云盘', onPressed: () => Navigator.pop(context, false),
subtitle: '连接你的 NAS 或网盘', child: const Text('取消'),
onTap: () {
// 🟢 这里调用你现成的 WebDAV 通道
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待接入 WebDAV 逻辑')),
);
},
), ),
_buildTile( TextButton(
icon: Icons.equalizer_outlined, onPressed: () => Navigator.pop(context, true),
title: '均衡器', child: const Text('清理', style: TextStyle(color: Colors.redAccent)),
subtitle: '简单低音增强 (后续)', ),
onTap: () {}, ],
),
);
if (confirmed != true) return;
try {
final db = await SongDatabase().database;
await db.delete('songs');
await db.delete('metadata_cache');
final docs = await getApplicationDocumentsDirectory();
final artDir = Directory('${docs.path}/artworks');
if (await artDir.exists()) {
await artDir.delete(recursive: true);
}
final tmp = await getTemporaryDirectory();
if (await tmp.exists()) {
await for (final entity in tmp.list()) {
if (entity is File &&
entity.path.contains('/metadata_') &&
entity.path.endsWith('.part')) {
await entity.delete();
}
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('元数据缓存已清理')),
);
}
await _loadStats();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('清理失败: $e')));
}
}
}
Future<void> _exportDiagnostics() async {
try {
final now = DateTime.now();
final docs = await getApplicationDocumentsDirectory();
final file =
File('${docs.path}/diagnostics-${now.millisecondsSinceEpoch}.txt');
final song = AudioService().currentSong;
final buffer = StringBuffer()
..writeln('清听 诊断信息')
..writeln('时间: ${now.toIso8601String()}')
..writeln('版本: $kAppVersion ($kAppBuild)')
..writeln('平台: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
..writeln()
..writeln('[连接]')
..writeln('baseUrl: ${WebDAVService.instance.baseUrl ?? '(未配置)'}')
..writeln('username: ${WebDAVService.instance.username ?? '(未配置)'}')
..writeln('connected: ${WebDAVService.instance.isConnected}')
..writeln()
..writeln('[数据]')
..writeln('songs: $_songRows')
..writeln('metadata_cache: $_metadataRows')
..writeln('favorites: $_favoriteRows')
..writeln('artwork 文件: $_artworkCount 个 / ${_formatBytes(_artworkBytes)}')
..writeln('临时探测文件: $_tempCount 个 / ${_formatBytes(_tempBytes)}')
..writeln()
..writeln('[当前播放]')
..writeln('song: ${song?.title ?? '(无)'} - ${song?.artist ?? ''}')
..writeln('path: ${song?.id ?? ''}')
..writeln('position: ${AudioService().position}')
..writeln('duration: ${AudioService().duration}')
..writeln('lastError: ${AudioService().lastPlaybackError ?? '(无)'}');
await file.writeAsString(buffer.toString());
if (!mounted) return;
final uri = await FileProviderUtils.getContentUri(file);
if (uri != null) {
try {
final intent = AndroidIntent(
action: 'android.intent.action.SEND',
type: 'text/plain',
arguments: {
'android.intent.extra.STREAM': uri.toString(),
'android.intent.extra.SUBJECT': '清听诊断信息',
},
flags: const [Flag.FLAG_GRANT_READ_URI_PERMISSION],
);
await intent.launch();
return;
} catch (e) {
debugPrint('⚠️ [Settings] 分享失败: $e');
}
}
if (mounted) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text('诊断信息已导出', style: TextStyle(color: Colors.white)),
content: SelectableText(
file.path,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('好'),
), ),
], ],
), ),
); );
} }
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('导出失败: $e')));
}
}
} }
Widget _buildTile( Future<void> _testConnection() async {
{required IconData icon, try {
required String title, await WebDAVService.instance.listDirectory(path: '/');
required String subtitle, if (mounted) {
required VoidCallback onTap}) { ScaffoldMessenger.of(context).showSnackBar(
return ListTile( const SnackBar(content: Text('连接正常')),
leading: Icon(icon, color: const Color(0xFF7C9A9E)),
title: Text(title),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: onTap,
); );
} }
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败: $e')),
);
}
}
}
Future<void> _logout() async {
await WebDAVService.instance.clearCredentials();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已退出登录,下次进入会要求重新配置')),
);
}
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: const Text('设置'),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
_sectionTitle('缓存'),
ListTile(
leading: const Icon(Icons.cleaning_services_outlined,
color: Color(0xFF7C9A9E)),
title: const Text('元数据缓存'),
subtitle: Text(
'标签 $_songRows 首 · 封面 $_artworkCount 张 '
'(${_formatBytes(_artworkBytes)}) · 临时文件 ${_formatBytes(_tempBytes)}',
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: _clearMetadataCache,
),
_sectionTitle('诊断'),
ListTile(
leading: const Icon(Icons.bug_report_outlined,
color: Color(0xFF7C9A9E)),
title: const Text('导出诊断信息'),
subtitle: const Text(
'版本、连接状态、缓存统计、当前播放与最近错误',
style: TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.ios_share, color: Colors.grey),
onTap: _exportDiagnostics,
),
_sectionTitle('连接'),
ListTile(
leading: const Icon(Icons.cloud_outlined,
color: Color(0xFF7C9A9E)),
title: Text(WebDAVService.instance.baseUrl ?? '(未配置)'),
subtitle: Text(
'用户 ${WebDAVService.instance.username ?? '-'} · '
'${WebDAVService.instance.isConnected ? '已连接' : '未连接'}',
style: const TextStyle(fontSize: 12),
),
),
ListTile(
leading: const Icon(Icons.wifi_tethering,
color: Color(0xFF7C9A9E)),
title: const Text('测试连接'),
onTap: _testConnection,
),
ListTile(
leading:
const Icon(Icons.logout, color: Colors.redAccent),
title: const Text('退出登录'),
subtitle: const Text(
'清除本机保存的 WebDAV 账号',
style: TextStyle(fontSize: 12),
),
onTap: _logout,
),
_sectionTitle('关于'),
ListTile(
leading:
const Icon(Icons.info_outline, color: Color(0xFF7C9A9E)),
title: const Text('清听'),
subtitle: const Text(
'版本 $kAppVersion ($kAppBuild)\n'
'播放内核 media_kit (libmpv, LGPL/GPL) · UI Flutter (BSD)',
style: TextStyle(fontSize: 12),
),
),
const SizedBox(height: 24),
],
),
);
}
Widget _sectionTitle(String text) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 20, 4, 8),
child: Text(
text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFFB8D4D0),
letterSpacing: 1.2,
),
),
);
}
}
+150 -13
View File
@@ -1,9 +1,17 @@
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';
import '../constants/ui_constants.dart'; import '../constants/ui_constants.dart';
import '../base/base_state.dart'; import '../base/base_state.dart';
import '../controllers/playback_controller.dart'; import '../controllers/playback_controller.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
import '../services/favorite_events.dart';
import '../services/metadata_events.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;
@@ -20,6 +28,14 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
String _errorMessage = ''; String _errorMessage = '';
String _currentPath = '/'; String _currentPath = '/';
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
Map<String, Map<String, dynamic>> _metaByPath = {};
String? _lastSyncedSongPath;
StreamSubscription<MetadataUpdate>? _metaSub;
/// 已收藏的歌曲路径
Set<String> _favoritePaths = {};
// 安全解码 // 安全解码
String _safeDecode(String input) { String _safeDecode(String input) {
try { try {
@@ -33,9 +49,70 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
void initState() { void initState() {
super.initState(); super.initState();
_currentPath = widget.currentPath; _currentPath = widget.currentPath;
AudioService().addListener(_onPlaybackChanged);
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
_loadDirectory(); _loadDirectory();
} }
@override
void dispose() {
_metaSub?.cancel();
AudioService().removeListener(_onPlaybackChanged);
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() {
final path = AudioService().currentSong?.id;
if (path == null || path == _lastSyncedSongPath) return;
_lastSyncedSongPath = path;
_syncMetadataFor(path);
}
Future<void> _syncMetadataFor(String path) async {
if (!_items.any((item) => item.path == path)) return;
final row = await SongDatabase().getSongByPath(path);
if (row == null || !mounted) return;
final title = (row['title'] as String?)?.trim() ?? '';
if (title.isEmpty) return;
safeSetState(() {
_metaByPath = {..._metaByPath, path: row};
});
}
/// 收藏 / 取消收藏:整行点击是播放,右侧红心专门做收藏
Future<void> _toggleFavorite(String path) async {
final wasFavorite = _favoritePaths.contains(path);
try {
await SongDatabase().toggleFavorite(path);
FavoriteEvents().publish(path);
if (!mounted) return;
safeSetState(() {
final next = {..._favoritePaths};
if (wasFavorite) {
next.remove(path);
} else {
next.add(path);
}
_favoritePaths = next;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('收藏失败: $e')),
);
}
}
}
// ============================================================ // ============================================================
// 加载目录 // 加载目录
// ============================================================ // ============================================================
@@ -48,11 +125,40 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
try { try {
final items = final items =
await WebDAVService.instance.listDirectory(path: _currentPath); await WebDAVService.instance.listDirectory(path: _currentPath);
// 一次性把本目录下所有文件的元数据查出来,列表直接用标签显示
final paths = items
.where((item) => !item.isDirectory)
.map((item) => item.path)
.toList();
var meta = <String, Map<String, dynamic>>{};
try {
meta = await SongDatabase().getSongsByPaths(paths);
} catch (e) {
debugPrint('⚠️ [WebDAVList] 批量读取元数据失败: $e');
}
var favorites = <String>{};
try {
final favRows = await SongDatabase().getFavorites();
favorites = favRows
.map((row) => row['song_id'] as String?)
.whereType<String>()
.toSet();
} catch (e) {
debugPrint('⚠️ [WebDAVList] 读取收藏失败: $e');
}
if (mounted) { if (mounted) {
safeSetState(() { safeSetState(() {
_items = items; _items = items;
_metaByPath = meta;
_favoritePaths = favorites;
_isLoading = false; _isLoading = false;
}); });
// 如果当前正在播的歌就在这个目录里,补一次同步
_lastSyncedSongPath = null;
_onPlaybackChanged();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -175,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),
@@ -262,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),
@@ -295,15 +410,28 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
final sizeStr = item.size != null final sizeStr = item.size != null
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB' ? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
: ''; : '';
// 有标签就用标签,没有才退回文件名(例如新歌尚未抓取元数据)
final meta = _metaByPath[item.path];
final metaTitle = (meta?['title'] as String?)?.trim() ?? '';
final metaArtist = (meta?['artist'] as String?)?.trim() ?? '';
final hasMeta = metaTitle.isNotEmpty;
final titleText = hasMeta ? metaTitle : displayName;
final subtitleText = [
if (hasMeta && metaArtist.isNotEmpty && metaArtist != '未知艺术家')
metaArtist,
if (sizeStr.isNotEmpty) sizeStr,
].join(' · ');
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: const Icon( visualDensity: VisualDensity.compact,
Icons.audiotrack, leading: SongArtwork(
color: Color(0xFFB8D4D0), filePath: meta?['artwork_path'] as String?,
size: 28, size: 44,
fallbackIcon: Icons.audiotrack,
), ),
title: Text( title: Text(
displayName, titleText,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@@ -313,17 +441,26 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text( subtitle: Text(
sizeStr, subtitleText,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[500], color: Colors.grey[500],
), ),
), ),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.play_arrow, color: Color(0xFFB8D4D0)), tooltip: '收藏',
onPressed: () => _playSong(item), icon: Icon(
_favoritePaths.contains(item.path)
? Icons.favorite
: Icons.favorite_border,
color: _favoritePaths.contains(item.path)
? const Color(0xFFE05C5C)
: Colors.grey,
),
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(
+7 -2
View File
@@ -116,9 +116,13 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
try { try {
final controls = _buildControls(playing); final controls = _buildControls(playing);
// audio_service 侧映射:idle -> STATE_NONE,ready -> PLAYING/PAUSED。
// 之前只按 duration>0 判断,直播流/时长未解析出来时会被系统判定为"未在播放"
// (播放设备选择页、部分车机与耳机都会读到这个状态)。
final prepared = playing || duration.inMilliseconds > 0;
final state = audio_service.PlaybackState( final state = audio_service.PlaybackState(
controls: controls, controls: controls,
processingState: duration.inMilliseconds > 0 processingState: prepared
? audio_service.AudioProcessingState.ready ? audio_service.AudioProcessingState.ready
: audio_service.AudioProcessingState.idle, : audio_service.AudioProcessingState.idle,
playing: playing, playing: playing,
@@ -242,7 +246,8 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
@override @override
Future<void> play() async { Future<void> play() async {
debugPrint('▶️ [handler] play() called'); debugPrint('▶️ [handler] play() called');
await _playback.resume(); // 走 AudioService 的统一入口:冷启动首次播放需要先 open + seek
await AudioService().playOrResume();
} }
@override @override
+611 -78
View File
@@ -7,9 +7,13 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart' as media_kit; import 'package:media_kit/media_kit.dart' as media_kit;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'playback_service.dart'; import 'playback_service.dart';
import 'network_watcher.dart';
import 'prefetch_service.dart';
import '../metadata/metadata_service.dart'; 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/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';
@@ -28,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() => {
@@ -80,6 +89,81 @@ class AudioService extends ChangeNotifier {
bool _hasStartedCurrentPlayback = false; bool _hasStartedCurrentPlayback = false;
bool _isUserSeeking = false; bool _isUserSeeking = false;
/// 切歌请求序号:只有最新一次请求有权解锁、写状态。
int _switchEpoch = 0;
/// 卡死判定阈值 / 节流
static const Duration _stallThreshold = Duration(seconds: 20);
static const Duration _startStallThreshold = Duration(seconds: 10);
DateTime? _lastStallRecoveryAt;
/// 最近一次进入 buffering 的时间(用于卡死判定,不能用计时器重置)
DateTime? _lastBufferingAt;
/// 网络切换去抖
Timer? _networkDebounce;
/// 进度看门狗:某些卡死不会触发 buffering 事件(例如 CDN 半开连接),
/// 用「播放中进度长时间不动」兜底。
Timer? _progressTimer;
Duration _lastObservedPosition = Duration.zero;
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、
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
DateTime? _trackStartAt;
DateTime? _playReadyAt;
int _prefetchedGeneration = -1;
/// 元数据回退到文件名时置位,播到 80% 再试一次
bool _metadataNeedsRetry = false;
int _metadataRetryGeneration = -1;
bool _firstAudioReported = false;
/// 冷启动恢复后尚未真正打开媒体:首次点击播放走 _playCurrent(含 seek)
bool _restoredPendingOpen = false;
/// 播放中定时持久化位置(冷启动能回到准确位置)
DateTime _lastPositionSaveAt = DateTime.now();
/// 打开失败后的快速重试(每首歌最多一次)
int _openRetryGeneration = -1;
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
String? _lastPlaybackError;
String? get lastPlaybackError => _lastPlaybackError;
int _playbackGeneration = 0; int _playbackGeneration = 0;
// ---- 待恢复的播放进度 ---- // ---- 待恢复的播放进度 ----
@@ -216,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) {
@@ -252,19 +344,35 @@ class AudioService extends ChangeNotifier {
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
Future<void> _playCurrent() async { Future<void> _playCurrent() async {
if (_isChangingTrack) {
debugPrint('⚠️ [AudioService] track switch already running');
return;
}
if (_currentIndex < 0 || _currentIndex >= _queue.length) { if (_currentIndex < 0 || _currentIndex >= _queue.length) {
stopPlay(); stopPlay();
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 就直接丢弃请求。
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
final epoch = ++_switchEpoch;
_restoredPendingOpen = false;
_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++;
@@ -282,9 +390,34 @@ class AudioService extends ChangeNotifier {
return; return;
} }
try {
_trackStartAt = DateTime.now();
_playReadyAt = null;
_firstAudioReported = false;
_metadataNeedsRetry = false;
await _playWithHeaders(song.url!); await _playWithHeaders(song.url!);
_playReadyAt = DateTime.now();
} catch (e) {
// 播放失败(超时/401/网络中断):必须走到 finally 解锁,
// 并把错误记下来,供上层提示或下一首重试。
_lastPlaybackError = '播放失败: $e';
debugPrint('❌ [AudioService] play failed: $e');
notifyListeners();
return;
}
if (epoch != _switchEpoch) {
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
return;
}
await _waitForPlaybackStarted(); await _waitForPlaybackStarted();
if (epoch != _switchEpoch) {
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
return;
}
// ⭐ 如果有待恢复的进度,执行 seek // ⭐ 如果有待恢复的进度,执行 seek
if (_pendingSeekPosition != null && if (_pendingSeekPosition != null &&
_pendingSeekPosition!.inMilliseconds > 0) { _pendingSeekPosition!.inMilliseconds > 0) {
@@ -298,16 +431,331 @@ class AudioService extends ChangeNotifier {
_syncPlayerStateDelayed(); _syncPlayerStateDelayed();
if (_hasStartedCurrentPlayback) { if (_hasStartedCurrentPlayback) {
_lastPlaybackError = null;
_onSongChanged?.call(song); _onSongChanged?.call(song);
_loadMetadataForCurrentSong(generation); // 不在起播瞬间抓元数据:此时 mpv 正在填缓冲区,
// 2MB 的标签请求会和播放流抢带宽,实测会把"下一首"拖到 60s+。
_scheduleMetadataWhenSettled(generation, epoch);
} }
// 切歌完成后保存状态 // 切歌完成后保存状态
await savePlaybackState(); await savePlaybackState();
} finally { } finally {
// 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
if (epoch == _switchEpoch) {
_isChangingTrack = false; _isChangingTrack = false;
} }
} }
}
/// 等播放真正稳定后再抓元数据。
///
/// 判定条件比"能出声"更严格:进度至少 5s、且连续 2s 不处于 buffering。
/// 原因:公网链路上新歌起播后 mpv 还在持续填缓冲,
/// 这时候插一个 2MB 的标签下载会直接把缓冲打回 0(实测下一首要等 60s+)。
/// 最多等 30s;等不到就放弃这次抓取(下次播放会再试)。
Future<bool> _waitForPlaybackSettled(int epoch,
{Duration timeout = const Duration(seconds: 30)}) async {
final player = PlaybackService().player;
final deadline = DateTime.now().add(timeout);
var quietChecks = 0;
while (DateTime.now().isBefore(deadline)) {
if (epoch != _switchEpoch) return false;
final ready = player.state.playing &&
!player.state.buffering &&
player.state.position >= const Duration(seconds: 5);
if (ready) {
quietChecks++;
// 每 500ms 采一次,连续 4 次即稳定 2 秒
if (quietChecks >= 4) return true;
} else {
quietChecks = 0;
}
await Future.delayed(const Duration(milliseconds: 500));
}
return false;
}
void _scheduleMetadataWhenSettled(int generation, int epoch) {
// 故意不 await:不能阻塞切歌流程和状态保存
() async {
try {
final settled = await _waitForPlaybackSettled(epoch);
if (!settled || epoch != _switchEpoch) {
debugPrint('⏸️ [AudioService] 播放未稳定,跳过本次元数据抓取(避免抢带宽)');
_metadataNeedsRetry = true; // 留到 80% 再试
return;
}
await _loadMetadataForCurrentSong(generation);
} catch (e) {
debugPrint('⚠️ [AudioService] 延迟元数据抓取失败: $e');
}
}();
}
/// 播到 80% 时,对"元数据回退到文件名"的歌再抓一次。
///
/// 原因:反代在等上游首包超时时会直接回 502(实测 10.3s),
/// 起播阶段抓取很容易撞上;到 80% 时链路通常已经热了,重试成功率更高。
/// 每首歌最多重试一次。
void _maybeRetryMetadata(Duration position) {
if (!_metadataNeedsRetry) return;
final duration = durationNotifier.value;
if (duration.inMilliseconds <= 0) return;
if (position.inMilliseconds < duration.inMilliseconds * 0.8) return;
if (_metadataRetryGeneration == _playbackGeneration) return;
_metadataRetryGeneration = _playbackGeneration;
_metadataNeedsRetry = false;
debugPrint('🔁 [AudioService] 播放到 80%,重试一次元数据抓取');
_loadMetadataForCurrentSong(_playbackGeneration);
}
/// 播到 60% 时预热下一首(HEAD + Range 0-0,不下载正文)。
///
/// 日志实测起播延迟 2~3s 都在"等首批数据",提前完成 DNS/TLS/反代握手
/// 可以把这段时间压掉一部分;失败完全静默,不影响当前播放。
void _maybePrefetchNext(Duration position) {
final duration = durationNotifier.value;
if (duration.inSeconds < 60) return;
if (position.inMilliseconds < duration.inMilliseconds * 0.6) return;
if (_prefetchedGeneration == _playbackGeneration) return;
final next = _peekNextSong();
final url = next?.url;
if (url == null || url.isEmpty) return;
_prefetchedGeneration = _playbackGeneration;
debugPrint('🔥 [AudioService] 预热下一首:${next!.title}');
PrefetchService().warm(url);
}
/// 只推算下一首,不改变队列/索引(next() 会改)。
Song? _peekNextSong() {
if (_queue.isEmpty) return null;
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return null;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
return _queue[_shuffledIndices[nextIdx]];
}
final nextIdx = (_currentIndex + 1) % _queue.length;
return _queue[nextIdx];
}
/// 进度看门狗:播放中进度 30s 不动、且近期出现过缓冲,判定为卡死。
///
/// 为什么不能只看 `buffering`:弱网下它会在 true/false 之间反复抖动,
/// 每次 true 若都重置计时器,阈值永远不会到(实测公网链路抖动 60s+ 不恢复)。
/// 为什么不能只看进度:用户暂停时进度也不动,会误触发。
/// 两个条件同时满足即可区分。
void _checkPlaybackProgress() {
if (_isChangingTrack) {
// 切歌中不参与判断,重置基准
_lastObservedPosition = positionNotifier.value;
_lastProgressAt = DateTime.now();
return;
}
final now = DateTime.now();
final pos = positionNotifier.value;
if (pos != _lastObservedPosition) {
_lastObservedPosition = pos;
_lastProgressAt = now;
// 每 10s 持久化一次进度:进程被系统回收后,冷启动能回到准确位置
if (now.difference(_lastPositionSaveAt) >= const Duration(seconds: 10)) {
_lastPositionSaveAt = now;
savePlaybackState();
}
return;
}
final player = PlaybackService().player;
// 起播看门狗:playing=true 但 position 一直是 0,且没有任何 buffering/error 事件。
// 这种情况 mpv 不会自己恢复,通用 20s 规则又太慢,单独用 10s 阈值。
final stuckAtStart = pos == Duration.zero &&
_trackStartAt != null &&
now.difference(_trackStartAt!) >= _startStallThreshold &&
player.state.playing;
if (stuckAtStart) {
debugPrint('🩺 [AudioService] 起播 '
'${_startStallThreshold.inSeconds}s 仍停在 0s,触发重连');
_trackStartAt = now; // 避免下一次 tick 立刻重复触发
_recoverFromStall(force: true);
return;
}
final stalledFor = now.difference(_lastProgressAt);
final recentlyBuffering = _lastBufferingAt != null &&
now.difference(_lastBufferingAt!) < const Duration(seconds: 20);
// playing==true 也是卡死的充分条件(暂停时 playing 为 false)
if (stalledFor >= _stallThreshold &&
(player.state.playing || recentlyBuffering)) {
debugPrint('🩺 [AudioService] 进度停在 $pos 已 ${stalledFor.inSeconds}s,'
'触发自愈');
_lastProgressAt = now;
_recoverFromStall(force: true);
}
}
/// 打开失败(ffurl_read / Failed to open)后 1.5s 立即重试一次。
///
/// 实测:切歌后 mpv 在 5~6s 时放弃打开,旧逻辑要等起播看门狗
/// (5s 一跳 × 10s 阈值,最坏 20s)才重连,表现为十几秒的前摇。
/// 每首歌最多快速重试一次,避免失败风暴。
void _scheduleOpenRetry() {
if (_currentSong == null) return;
if (_isChangingTrack) return;
if (_openRetryGeneration == _playbackGeneration) return;
_openRetryGeneration = _playbackGeneration;
// 缓冲余量决定重试节奏:
// 余量充足 -> 慢一点重试,期间 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 (_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);
});
}
/// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。
///
/// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。
Future<void> _recoverFromStall({bool force = false}) async {
if (_isChangingTrack) return;
final song = _currentSong;
if (song == null) return;
final url = song.url;
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();
if (!force &&
_lastStallRecoveryAt != null &&
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
return;
}
_lastStallRecoveryAt = now;
// 恢复前记下代次:await 期间用户可能已经切歌,
// 不能把旧歌重新打开(否则就是"点了新歌却切回旧歌")。
final recoveryGeneration = _playbackGeneration;
final resumeAt = _lastKnownPosition > Duration.zero
? _lastKnownPosition
: positionNotifier.value;
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
_isRecovering = true;
try {
_trackStartAt = DateTime.now();
_playReadyAt = null;
_firstAudioReported = false;
await _playWithHeaders(url);
if (_playbackGeneration != recoveryGeneration) {
debugPrint('⏭️ [AudioService] 恢复期间已切歌,放弃本次恢复');
return;
}
_playReadyAt = DateTime.now();
if (resumeAt > Duration.zero) {
await PlaybackService().seek(resumeAt);
positionNotifier.value = resumeAt;
_lastKnownPosition = resumeAt;
}
debugPrint('✅ [AudioService] 自动恢复完成');
} catch (e) {
debugPrint('❌ [AudioService] 自动恢复失败: $e');
_lastPlaybackError = '缓冲卡住,自动恢复失败';
notifyListeners();
} finally {
_isRecovering = false;
}
}
/// 网络切换(Wi-Fi 换 AP、Wi-Fi <-> 移动数据)后的主动重连。
///
/// 长连接会随出口 IP 变化失效,mpv 不保证会自己重连。
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
void _onNetworkChanged() {
_networkDebounce?.cancel();
// 记录去抖开始时的歌:2 秒内用户可能已经切歌,
// 那时再去重连"上一首"就是异常切换(日志里出现过 78s 重连旧歌)。
final expectedSongId = _currentSong?.id;
final expectedGeneration = _playbackGeneration;
_networkDebounce = Timer(const Duration(seconds: 2), () {
if (_currentSong == null) return;
if (_isChangingTrack) return;
if (_currentSong?.id != expectedSongId ||
_playbackGeneration != expectedGeneration) {
debugPrint('🌐 [AudioService] 网络变化期间已切歌,忽略本次重连');
return;
}
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
_recoverFromStall(force: true);
});
}
Future<void> _waitForPlaybackStarted() async { Future<void> _waitForPlaybackStarted() async {
final player = PlaybackService().player; final player = PlaybackService().player;
@@ -351,9 +799,8 @@ class AudioService extends ChangeNotifier {
return {}; return {};
} }
String _generateSongKey(String url, int fileSize, int modifiedTime) { String _generateSongKey(String url) {
final raw = '$url|$fileSize|$modifiedTime'; return songKeyFor(url);
return raw.hashCode.toString();
} }
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
@@ -405,12 +852,23 @@ class AudioService extends ChangeNotifier {
} }
if (metadata.isNotEmpty) { if (metadata.isNotEmpty) {
// 来源是文件名 = 元数据没读到(常见于反代 502 / 超时)
// 记下来,播到 80% 时再试一次,避免这首歌永远只有文件名
if (metadata.source == 'filename') {
_metadataNeedsRetry = true;
debugPrint('⚠️ [AudioService] 元数据回退到文件名,将在 80% 处重试一次');
} else {
_metadataNeedsRetry = false;
}
final updatedSong = Song( final updatedSong = Song(
id: song.id, id: song.id,
title: metadata.title.isNotEmpty ? metadata.title : song.title, title: metadata.title.isNotEmpty ? metadata.title : song.title,
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;
@@ -442,13 +900,12 @@ class AudioService extends ChangeNotifier {
final file = await provider.getFile(url); final file = await provider.getFile(url);
if (file != null && await file.exists()) { if (file != null && await file.exists()) {
final stat = await file.stat(); final stat = await file.stat();
songKey = _generateSongKey( songKey = _generateSongKey(url);
url, stat.size, stat.modified.millisecondsSinceEpoch);
} else { } else {
songKey = url.hashCode.toString(); songKey = songKeyForUrl(url);
} }
} catch (e) { } catch (e) {
songKey = url.hashCode.toString(); songKey = songKeyForUrl(url);
} }
final db = SongDatabase(); final db = SongDatabase();
@@ -461,7 +918,7 @@ class AudioService extends ChangeNotifier {
try { try {
final cacheDir = await getTemporaryDirectory(); final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp'; final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
final cacheFile = File(cachePath); final cacheFile = File(cachePath);
if (await cacheFile.exists()) { if (await cacheFile.exists()) {
await cacheFile.delete(); await cacheFile.delete();
@@ -494,11 +951,7 @@ class AudioService extends ChangeNotifier {
return; return;
} }
if (_isChangingTrack) { // 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
debugPrint('⚠️ [next] REJECTED: track switch in progress');
return;
}
if (_playMode == PlayMode.shuffle) { if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return; if (_shuffledIndices.isEmpty) return;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length; final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
@@ -516,12 +969,7 @@ class AudioService extends ChangeNotifier {
Future<void> previous() async { Future<void> previous() async {
if (_queue.isEmpty) return; if (_queue.isEmpty) return;
if (_isChangingTrack) { // 同 next():最新请求胜出,不拒绝
debugPrint(
'⚠️ [AudioService] previous() ignored: track switch in progress');
return;
}
if (_playMode == PlayMode.shuffle) { if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return; if (_shuffledIndices.isEmpty) return;
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length; final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
@@ -550,10 +998,33 @@ class AudioService extends ChangeNotifier {
if (_isPlaying) { if (_isPlaying) {
PlaybackService().pause(); PlaybackService().pause();
} else { savePlaybackState();
PlaybackService().resume(); return;
} }
// 保存状态(包括进度)
playOrResume();
}
/// 统一的"继续播放"入口:UI 按钮、通知栏、耳机键都走这里。
///
/// 冷启动恢复后音频尚未加载,此时 resume() 是在空播放器上调用,
/// 表现就是"点了没反应"。这种情况改走 _playCurrent(open + seek + play)。
Future<void> playOrResume() async {
if (_currentSong == null) return;
if (_isPlaying) return;
// 用户手动点播放:重置退避计数,允许立即重试
_recoveryAttempts = 0;
_nextRecoveryAt = null;
if (_restoredPendingOpen) {
_restoredPendingOpen = false;
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
await _playCurrent();
return;
}
await PlaybackService().resume();
savePlaybackState(); savePlaybackState();
} }
@@ -646,6 +1117,47 @@ 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 的播放位置:这是"真正出声"的时刻,
// 与 play() 发起时间相减即可算出起播延迟。
if (!_firstAudioReported && position > Duration.zero) {
_firstAudioReported = true;
final readyAt = _playReadyAt;
if (readyAt != null) {
final ms = DateTime.now().difference(readyAt).inMilliseconds;
debugPrint('🔊 [AudioService] 起播耗时 ${ms}ms(open 完成 -> 出声)');
_playReadyAt = null;
}
}
_maybeRetryMetadata(position);
_maybePrefetchNext(position);
positionNotifier.value = position; positionNotifier.value = position;
}), }),
); );
@@ -665,6 +1177,47 @@ class AudioService extends ChangeNotifier {
}), }),
); );
// mpv 的错误(401 / 连接失败 / 解码失败)以前没有任何订阅,失败是静默的
_subscriptions.add(
player.stream.error.listen((error) {
debugPrint('❌ [AudioService] player error: $error');
_lastPlaybackError = error;
notifyListeners();
// 打开失败(ffurl_read / Failed to open)时立刻安排一次重试,
// 不要等 10~20s 的起播看门狗。
if (error.contains('Failed to open') ||
error.contains('ffurl_read')) {
_scheduleOpenRetry();
}
}),
);
// 缓冲状态:只记录"最近一次进入缓冲的时间"。
// 不能用 Timeout 重置计时:弱网下 buffering 会 true/false 反复抖动,
// 每次 true 都重置的话,30s 阈值永远不会到。
_subscriptions.add(
player.stream.buffering.listen((buffering) {
debugPrint('🌊 [AudioService] buffering=$buffering');
if (buffering) {
_lastBufferingAt = DateTime.now();
}
}),
);
// 进度看门狗:播放中进度 30s 不动、且近期发生过缓冲 → 判定卡死并自愈。
// 用"进度 + 最近缓冲"两个条件,既不会在暂停时误触发,
// 也能覆盖 buffering 反复抖动、始终起不来的情况。
_progressTimer?.cancel();
_lastProgressAt = DateTime.now();
_progressTimer = Timer.periodic(
const Duration(seconds: 5),
(_) => _checkPlaybackProgress(),
);
// 网络变化监听:换 Wi-Fi / 切移动数据后长连接会失效,主动重连
NetworkWatcher().onChanged = _onNetworkChanged;
NetworkWatcher().start();
// ⭐ 完整生命周期校验的 completed 监听 // ⭐ 完整生命周期校验的 completed 监听
_subscriptions.add( _subscriptions.add(
player.stream.completed.listen((_) { player.stream.completed.listen((_) {
@@ -838,8 +1391,13 @@ class AudioService extends ChangeNotifier {
debugPrint( debugPrint(
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}'); '♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
// ⭐ 关键:加载音频但不自动播放 // 冷启动只恢复 UI 状态,不联网预加载。
await _loadRestoredPlayback(); // 进程被系统回收后再回来时,预加载(play→seek→pause)会拖慢首屏,
// 还会和用户点播放的操作打架,表现为"假死十几秒"。
_restoredPendingOpen = true;
_isPlaying = false;
positionNotifier.value = _pendingSeekPosition ?? Duration.zero;
notifyListeners();
return true; return true;
} catch (e) { } catch (e) {
@@ -848,44 +1406,19 @@ class AudioService extends ChangeNotifier {
} }
} }
/// ⭐ 加载恢复的播放状态(加载音频,不自动播放) // 说明:冷启动不再预加载音频(原 _loadRestoredPlayback)。
Future<void> _loadRestoredPlayback() async { // 恢复只做 UI 状态,首次播放时由 togglePlay → _playCurrent 完成 open + seek。
if (_queue.isEmpty || _currentIndex < 0) return;
final song = _queue[_currentIndex];
if (song.url == null || song.url!.isEmpty) return;
// 启动监听
_startListening();
// 加载音频(带 headers)
final headers = await _getAuthHeadersForUrl(song.url!);
await PlaybackService().play(song.url!, headers: headers);
// 等待播放器准备好
await _waitForPlaybackStarted();
// 如果有待恢复的进度,执行 seek
if (_pendingSeekPosition != null &&
_pendingSeekPosition!.inMilliseconds > 0) {
final pos = _pendingSeekPosition!;
_pendingSeekPosition = null;
await PlaybackService().seek(pos);
positionNotifier.value = pos;
debugPrint('🎯 [AudioService] restored position: $pos');
}
// ⭐ 加载完成后立即暂停(用户点击播放按钮后才继续)
await PlaybackService().pause();
_isPlaying = false;
_hasStartedCurrentPlayback = true;
notifyListeners();
debugPrint('🎵 [AudioService] restored playback loaded and paused');
}
@override @override
void dispose() { void dispose() {
// 先保存再清理 // 先保存再清理
_progressTimer?.cancel();
_progressTimer = null;
_networkDebounce?.cancel();
_networkDebounce = null;
_autoRetryTimer?.cancel();
_autoRetryTimer = null;
NetworkWatcher().stop();
savePlaybackState(); savePlaybackState();
_stopListening(); _stopListening();
positionNotifier.dispose(); positionNotifier.dispose();
+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));
}
}
+68
View File
@@ -0,0 +1,68 @@
// lib/services/network_watcher.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
/// 监听本机网络接口的地址变化。
///
/// 为什么不直接依赖 mpv 自己恢复:HTTP 流是长连接,Wi-Fi 切换 / 移动数据切换
/// 会换掉出口 IP,旧连接直接失效。mpv 不一定会重连,通常表现为
/// "进度不动、缓冲一直转"。所以在网络变化时由上层主动重开当前歌曲。
///
/// 实现方式:每 10s 枚举一次 IPv4 地址,签名变化即视为网络切换。
/// 相比平台 Channel 的方案,它不需要新增权限或插件,
/// 且能覆盖"同一个 Wi-Fi 换了 AP / 换了出口"这类 transport 类型不变的情况。
class NetworkWatcher {
static final NetworkWatcher _instance = NetworkWatcher._internal();
factory NetworkWatcher() => _instance;
NetworkWatcher._internal();
Timer? _timer;
String? _lastSignature;
bool _ticking = false;
/// 网络签名变化时回调(已在内部做过去抖)
void Function()? onChanged;
void start({Duration interval = const Duration(seconds: 3)}) {
if (_timer != null) return;
_timer = Timer.periodic(interval, (_) => _tick());
_tick();
debugPrint('🌐 [NetworkWatcher] 已启动(每 ${interval.inSeconds}s 检查一次)');
}
void stop() {
_timer?.cancel();
_timer = null;
_lastSignature = null;
debugPrint('🌐 [NetworkWatcher] 已停止');
}
Future<void> _tick() async {
if (_ticking) return;
_ticking = true;
try {
final interfaces = await NetworkInterface.list(
type: InternetAddressType.IPv4,
includeLoopback: false,
);
final signature = interfaces
.map((i) => '${i.name}:${i.addresses.map((a) => a.address).join(',')}')
.join('|');
final previous = _lastSignature;
_lastSignature = signature;
// 首次只记录基线,不触发
if (previous != null && previous != signature) {
debugPrint('🌐 [NetworkWatcher] 检测到网络变化');
onChanged?.call();
}
} catch (e) {
debugPrint('⚠️ [NetworkWatcher] 枚举网络接口失败: $e');
} finally {
_ticking = false;
}
}
}
+89 -16
View File
@@ -1,20 +1,32 @@
// lib/services/playback_service.dart // lib/services/playback_service.dart
import 'dart:async';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:flutter/foundation.dart';
// ============================================================
// PlaybackService - 播放引擎封装
// 职责:管理 Player 生命周期,不涉及业务逻辑
// ============================================================
class PlaybackService { class PlaybackService {
static final PlaybackService _instance = PlaybackService._internal(); static final PlaybackService _instance = PlaybackService._internal();
factory PlaybackService() => _instance; factory PlaybackService() => _instance;
PlaybackService._internal(); PlaybackService._internal() {
// ⭐ 给单例本身打上 ID
_serviceId = DateTime.now().microsecondsSinceEpoch.toString();
debugPrint('🎯 [PlaybackService] 单例创建, serviceId=$_serviceId');
}
/// 弱网/源站抖动时,mpv 的 open 可能长时间不返回。
/// 不设上限的话 _playCurrent() 的 finally 永远不执行,
/// _isChangingTrack 会永久为 true,整个切歌链路假死。
static const Duration _openTimeout = Duration(seconds: 20);
static const Duration _controlTimeout = Duration(seconds: 5);
Player? _player; Player? _player;
bool _initialized = false; bool _initialized = false;
late final String _serviceId; // ⭐ 服务实例 ID
// ---------- Getter ---------- // ---------- Getter ----------
Player get player { Player get player {
debugPrint(
'🎯 [PlaybackService] get player called, serviceId=$_serviceId, initialized=$_initialized, player=${_player?.hashCode}');
if (!_initialized) init(); if (!_initialized) init();
return _player!; return _player!;
} }
@@ -23,48 +35,105 @@ class PlaybackService {
// ---------- 初始化 ---------- // ---------- 初始化 ----------
void init() { void init() {
if (_initialized) return; if (_initialized) {
debugPrint(
'⚠️ [PlaybackService] init() 被重复调用, 跳过, serviceId=$_serviceId, player=${_player?.hashCode}');
return;
}
_player = Player(); _player = Player();
_initialized = true;
// 可以在这里配置初始参数 // 拥塞网络(公司共享出口)下加大缓冲:多存一点余量,
// _player.setVolume(1.0); // 减少"播到一半断流 -> 重连"的次数。代价是起播稍慢。
// 通过 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;
debugPrint(
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
} }
// ---------- 播放 ---------- // ---------- 播放 ----------
Future<void> play(String url, {Map<String, String>? headers}) async { Future<void> play(String url, {Map<String, String>? headers}) async {
debugPrint(
'▶️ [PlaybackService] play() called, serviceId=$_serviceId, player=${_player?.hashCode}, url=${_shortUrl(url)}');
if (!_initialized) init(); if (!_initialized) init();
final media = (headers != null && headers.isNotEmpty) final media = (headers != null && headers.isNotEmpty)
? Media(url, httpHeaders: headers) ? Media(url, httpHeaders: headers)
: Media(url); : Media(url);
await _player!.open(media); try {
await _player!.play(); final sw = Stopwatch()..start();
await _player!.open(media).timeout(_openTimeout);
final openMs = sw.elapsedMilliseconds;
await _player!.play().timeout(_controlTimeout);
debugPrint('⏱️ [PlaybackService] open 完成 ${openMs}ms,'
'play 完成 ${sw.elapsedMilliseconds}ms');
} on TimeoutException {
debugPrint(
'❌ [PlaybackService] open timeout (${_openTimeout.inSeconds}s): ${_shortUrl(url)}');
await _safeStop();
rethrow;
} catch (e) {
debugPrint('❌ [PlaybackService] play failed: $e');
await _safeStop();
rethrow;
}
}
/// 失败时尽量把播放器拉回可用的空闲状态,避免下一次 open 继续卡住。
Future<void> _safeStop() async {
try {
await _player?.stop().timeout(const Duration(seconds: 3));
} catch (_) {
// 忽略:stop 本身失败也不应影响上层解锁
}
}
/// 日志里不打完整 URL(WebDAV 路径很长,且可能带签名参数)
String _shortUrl(String url) {
final hash = url.hashCode.toRadixString(16);
return url.length <= 48 ? url : '${url.substring(0, 48)}…#$hash';
} }
// ---------- 控制 ---------- // ---------- 控制 ----------
Future<void> pause() async { Future<void> pause() async {
debugPrint(
'⏸️ [PlaybackService] pause() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.pause(); await _player!.pause().timeout(_controlTimeout);
} }
} }
Future<void> resume() async { Future<void> resume() async {
debugPrint(
'▶️ [PlaybackService] resume() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.play(); await _player!.play().timeout(_controlTimeout);
} }
} }
Future<void> stop() async { Future<void> stop() async {
debugPrint(
'⏹️ [PlaybackService] stop() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.stop(); await _player!.stop().timeout(_controlTimeout);
} }
} }
Future<void> seek(Duration position) async { Future<void> seek(Duration position) async {
debugPrint(
'⏩ [PlaybackService] seek() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.seek(position); await _player!.seek(position).timeout(_controlTimeout);
} }
} }
@@ -76,6 +145,8 @@ class PlaybackService {
// ---------- 释放 ---------- // ---------- 释放 ----------
void dispose() { void dispose() {
debugPrint(
'🗑️ [PlaybackService] dispose() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
_player!.dispose(); _player!.dispose();
_player = null; _player = null;
@@ -83,8 +154,10 @@ class PlaybackService {
} }
} }
// ---------- 重新初始化(用于 Hot Restart 场景) ---------- // ---------- 重新初始化 ----------
void reinitialize() { void reinitialize() {
debugPrint(
'🔄 [PlaybackService] reinitialize() called, serviceId=$_serviceId');
dispose(); dispose();
init(); init();
} }
-32
View File
@@ -1,32 +0,0 @@
import 'package:media_kit/media_kit.dart';
class PlayerService {
static final PlayerService _instance = PlayerService._internal();
factory PlayerService() => _instance;
PlayerService._internal();
Player? _player;
Player get player {
return _player ??= Player();
}
void play(String url) {
player.open(Media(url));
}
void pause() => player.pause();
void resume() => player.play();
void stop() => player.stop();
void dispose() {
_player?.dispose();
_player = null;
}
// 状态监听...
}
+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);
}
}
+68
View File
@@ -0,0 +1,68 @@
// lib/services/prefetch_service.dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'webdav_service.dart';
/// 下一首预热。
///
/// 目的:在用户真正切歌之前,提前完成 DNS 解析、TLS 握手、反代/CDN 的首包,
/// 让 mpv 起播时少等一会儿。
///
/// 只发 HEAD + `Range: bytes=0-0`,不下载正文,带宽开销可以忽略;
/// 失败也完全静默,不影响当前播放。
class PrefetchService {
static final PrefetchService _instance = PrefetchService._internal();
factory PrefetchService() => _instance;
PrefetchService._internal();
final Dio _dio = Dio();
String? _lastUrl;
bool _busy = false;
Future<void> warm(String url) async {
if (url.isEmpty) return;
if (_busy) return;
if (_lastUrl == url) return;
_busy = true;
_lastUrl = url;
final sw = Stopwatch()..start();
try {
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) return;
final response = await _dio.head(
url,
options: Options(
headers: {
...headers,
'Range': 'bytes=0-0',
},
receiveTimeout: const Duration(seconds: 8),
sendTimeout: const Duration(seconds: 5),
// HEAD 返回 4xx/5xx 也不抛异常,交给日志记录
validateStatus: (_) => true,
),
);
debugPrint('🔥 [Prefetch] 预热完成 ${sw.elapsedMilliseconds}ms,'
'status=${response.statusCode}');
} catch (e) {
debugPrint('⚠️ [Prefetch] 预热失败(忽略): $e');
} finally {
_busy = false;
// 允许同一首歌在下一轮重新预热(比如重播)
if (_lastUrl == url) {
// 保留 _lastUrl 做去重,下一次换歌时会被覆盖
}
}
}
/// 切歌/停止时清掉去重状态
void reset() {
_lastUrl = 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);
}
+18
View File
@@ -0,0 +1,18 @@
// lib/utils/song_key.dart
import 'dart:convert';
import 'package:crypto/crypto.dart';
/// 歌曲缓存键(song_key)。
///
/// 只用 URL:元数据读取是"分阶段取头部"的,第一次可能只取 128KB、
/// 第二次补到 512KB。如果键里带文件大小,两次会得到不同的键,缓存立刻失效;
/// URL 在这些阶段之间是稳定的。
///
/// 代价:同一个 URL 换了文件内容时不会自动失效,由"清除单曲缓存 /
/// forceRefresh"兜底。
String songKeyFor(String url) {
return md5.convert(utf8.encode(url)).toString();
}
/// 语义化别名,用于临时文件命名等场景。
String songKeyForUrl(String url) => songKeyFor(url);
+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')),
);
}
}
-116
View File
@@ -1,116 +0,0 @@
// lib/widgets/full_player_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
class FullPlayerPage extends StatelessWidget {
const FullPlayerPage({super.key});
@override
Widget build(BuildContext context) {
final service = context.watch<AudioService>();
final song = service.currentSong!;
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: const Icon(Icons.playlist_play_outlined),
onPressed: () {},
),
],
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 280,
height: 280,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF2A3332),
boxShadow: [
BoxShadow(
color: const Color(0xFF7C9A9E).withOpacity(0.2),
blurRadius: 60,
spreadRadius: 10,
),
],
),
child:
const Icon(Icons.music_note, size: 80, color: Colors.white24),
),
const SizedBox(height: 60),
Text(
song.title,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
song.artist,
style: TextStyle(fontSize: 16, color: Colors.grey[400]),
),
const SizedBox(height: 40),
Slider(
value: 0.3,
onChanged: (v) {},
activeColor: const Color(0xFFB8D4D0),
inactiveColor: Colors.grey[800],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.skip_previous, size: 32),
color: Colors.white60,
),
const SizedBox(width: 20),
CircleAvatar(
radius: 32,
backgroundColor: const Color(0xFF7C9A9E),
child: IconButton(
icon: Icon(
service.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.black87,
size: 32,
),
onPressed: () => context.read<AudioService>().togglePlay(),
),
),
const SizedBox(width: 20),
IconButton(
onPressed: () {},
icon: const Icon(Icons.skip_next, size: 32),
color: Colors.white60,
),
],
),
const SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.volume_up_outlined),
color: Colors.white38,
),
],
)
],
),
),
);
}
}
+15 -1
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,
) )
: (song.artworkPath != null &&
song.artworkPath!.isNotEmpty &&
File(song.artworkPath!)
.existsSync())
? DecorationImage(
image: FileImage(
File(song.artworkPath!)),
fit: BoxFit.cover,
)
: null, : 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,
+72
View File
@@ -0,0 +1,72 @@
// lib/widgets/song_artwork.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
/// 歌曲封面:优先进内存字节,其次本地封面文件,最后回退图标。
///
/// 列表页一次会构建几十行,这里用 `existsSync` 判断一次即可,
/// 图片本身由 Flutter 按需解码。
class SongArtwork extends StatelessWidget {
final Uint8List? bytes;
final String? filePath;
final double size;
final IconData fallbackIcon;
final Color? fallbackColor;
const SongArtwork({
super.key,
this.bytes,
this.filePath,
this.size = 44,
this.fallbackIcon = Icons.music_note,
this.fallbackColor,
});
@override
Widget build(BuildContext context) {
Widget child;
if (bytes != null && bytes!.isNotEmpty) {
child = Image.memory(
bytes!,
width: size,
height: size,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => _fallback(),
);
} else if (filePath != null &&
filePath!.isNotEmpty &&
File(filePath!).existsSync()) {
child = Image.file(
File(filePath!),
width: size,
height: size,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => _fallback(),
);
} else {
child = _fallback();
}
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(width: size, height: size, child: child),
);
}
Widget _fallback() {
return Container(
width: size,
height: size,
color: const Color(0xFF232928),
child: Icon(
fallbackIcon,
size: size * 0.55,
color: fallbackColor ?? const Color(0xFFB8D4D0),
),
);
}
}
+32 -32
View File
@@ -13,10 +13,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "4.0.9" version: "4.2.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -101,18 +101,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.2" version: "1.1.3"
code_assets: code_assets:
dependency: transitive dependency: transitive
description: description:
name: code_assets name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.2.1" version: "2.0.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -220,10 +220,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: hooks name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.0.2" version: "2.2.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -244,10 +244,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "4.8.0" version: "4.9.2"
intl: intl:
dependency: transitive dependency: transitive
description: description:
@@ -268,10 +268,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: jni_flutter name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.2" version: "1.0.3"
jni_util: jni_util:
dependency: transitive dependency: transitive
description: description:
@@ -284,10 +284,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: js name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.6.7" version: "0.7.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -412,10 +412,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: mime name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -428,10 +428,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: objective_c name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "9.5.0" version: "9.6.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@@ -588,18 +588,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pub_semver name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.2.0" version: "2.2.1"
record_use: record_use:
dependency: transitive dependency: transitive
description: description:
name: record_use name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.6.0" version: "1.1.1"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@@ -628,18 +628,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.4.27" version: "2.4.28"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.5.6" version: "2.5.7"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@@ -729,10 +729,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.12.1" version: "1.12.2"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -817,10 +817,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "15.2.0" version: "15.3.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -849,10 +849,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: yaml name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.1.3" version: "3.1.4"
sdks: sdks:
dart: ">=3.12.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0" flutter: ">=3.44.0"
+2 -2
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.0+1 version: 1.1.0-dev.1+2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
@@ -36,7 +36,7 @@ dependencies:
provider: ^6.1.2 provider: ^6.1.2
dio: ^5.4.0 # HTTP 客户端 dio: ^5.4.0 # HTTP 客户端
xml: ^6.5.0 xml: ^6.5.0
audio_service: ^0.18.19 audio_service: 0.18.19
audio_session: ^0.1.21 audio_session: ^0.1.21
permission_handler: ^11.3.1 permission_handler: ^11.3.1
flutter_cache_manager: ^3.3.1 flutter_cache_manager: ^3.3.1
+3
View File
@@ -684,3 +684,6 @@ await AudioService.start(
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。 > **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。 两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。
大樱子留:
26.10.22-23:22 多实例问题已修复,接下来会将代码合并到main分支,大肥鱼立功一件,有时候harness确实很有意义
+140
View File
@@ -0,0 +1,140 @@
// test/metadata_normalizer_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:qingting_player/metadata/metadata_model.dart';
import 'package:qingting_player/metadata/metadata_normalizer.dart';
void main() {
final normalizer = MetadataNormalizer();
RawMetadata raw({
String title = '',
String artist = '',
String album = '',
int year = 0,
}) =>
RawMetadata(title: title, artist: artist, album: album, year: year);
group('normalize 字段来源', () {
test('标签齐全时三个字段都标记为来自标签', () {
final n = normalizer.normalize(
raw(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦'),
fileName: 'track01.mp3',
filePath: '/音乐/周杰伦/track01.mp3',
);
expect(n.title, '夜曲');
expect(n.artist, '周杰伦');
expect(n.album, '十一月的萧邦');
expect(n.titleFromTag, isTrue);
expect(n.artistFromTag, isTrue);
expect(n.albumFromTag, isTrue);
});
test('没有标签时回退到文件名与目录', () {
final n = normalizer.normalize(
raw(),
fileName: '晴天.mp3',
filePath: '/音乐/周杰伦/晴天.mp3',
);
expect(n.title, '晴天');
expect(n.artist, '周杰伦');
expect(n.titleFromTag, isFalse);
expect(n.artistFromTag, isFalse);
});
});
group('evaluate 置信度分级', () {
NormalizedMetadata norm({
required String title,
required String artist,
String album = '',
bool titleTag = true,
bool artistTag = true,
bool albumTag = false,
}) =>
NormalizedMetadata(
title: title,
artist: artist,
album: album,
titleFromTag: titleTag,
artistFromTag: artistTag,
albumFromTag: albumTag,
);
test('标题 + 艺术家 + 专辑 → 0.90', () {
final c = normalizer.evaluate(
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
const [],
);
expect(c.confidence, closeTo(0.90, 0.001));
});
test('只有标题和艺术家 → 0.80', () {
final c = normalizer.evaluate(norm(title: '夜曲', artist: '周杰伦'), const []);
expect(c.confidence, closeTo(0.80, 0.001));
});
test('只有文件名(无标签)→ 0.35,低于阈值', () {
final c = normalizer.evaluate(
norm(title: 'track01', artist: '未知艺术家', titleTag: false, artistTag: false),
const [],
);
expect(c.confidence, closeTo(0.35, 0.001));
expect(c.confidence < 0.6, isTrue);
});
test('历史一致性加权:同艺人 ≥2 条 → +0.05', () {
final history = [
{'artist': '周杰伦', 'album': ''},
{'artist': '周杰伦', 'album': ''},
];
final c = normalizer.evaluate(
norm(title: '夜曲', artist: '周杰伦'),
history,
);
expect(c.confidence, closeTo(0.85, 0.001));
expect(c.evidence['artist_history'], 0.05);
});
test('证据可解释:每一项加分都有记录', () {
final c = normalizer.evaluate(
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
const [],
);
expect(c.evidence.containsKey('base'), isTrue);
expect(c.evidence.containsKey('title_from_tag'), isTrue);
expect(c.evidence.containsKey('artist_from_tag'), isTrue);
expect(c.evidence.containsKey('album'), isTrue);
});
});
group('decide 阈值', () {
test('高于阈值时 source=normalized', () {
final c = normalizer.evaluate(
const NormalizedMetadata(
title: '夜曲',
artist: '周杰伦',
titleFromTag: true,
artistFromTag: true,
),
const [],
);
final f = normalizer.decide(c);
expect(f.source, 'normalized');
expect(f.title, '夜曲');
});
test('低于阈值时 source=pending', () {
final c = normalizer.evaluate(
const NormalizedMetadata(
title: 'track01',
artist: '未知艺术家',
titleFromTag: false,
artistFromTag: false,
),
const [],
);
final f = normalizer.decide(c);
expect(f.source, 'pending');
});
});
}
+124
View File
@@ -0,0 +1,124 @@
// test/metadata_stability_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:qingting_player/metadata/metadata_model.dart';
import 'package:qingting_player/metadata/metadata_stability.dart';
void main() {
CandidateMetadata candidate({double confidence = 0.8}) => CandidateMetadata(
metadata: const NormalizedMetadata(title: '夜曲', artist: '周杰伦'),
confidence: confidence,
);
group('MetadataStability', () {
test('首次见到 → pending,次数 1', () {
final d = MetadataStability.decide(
previous: null,
candidate: candidate(),
title: '夜曲',
artist: '周杰伦',
);
expect(d.status, 'pending');
expect(d.reason, 'first_seen');
expect(d.validationCount, 1);
});
test('第二次结果一致且置信度达标 → verified', () {
final d = MetadataStability.decide(
previous: {
'title': '夜曲',
'artist': '周杰伦',
'confidence': 0.8,
'validation_count': 1,
},
candidate: candidate(),
title: '夜曲',
artist: '周杰伦',
);
expect(d.status, 'verified');
expect(d.reason, 'stable');
expect(d.validationCount, 2);
});
test('标题或艺术家冲突 → 次数重置为 1,保持 pending', () {
final d = MetadataStability.decide(
previous: {
'title': '夜曲',
'artist': '周杰伦',
'confidence': 0.9,
'validation_count': 5,
},
candidate: candidate(),
title: '完全不同的歌',
artist: '别的歌手',
);
expect(d.status, 'pending');
expect(d.reason, 'conflict');
expect(d.validationCount, 1);
});
test('置信度不足 → pending,但次数继续累计', () {
final d = MetadataStability.decide(
previous: {
'title': '夜曲',
'artist': '周杰伦',
'confidence': 0.4,
'validation_count': 3,
},
candidate: candidate(confidence: 0.5),
title: '夜曲',
artist: '周杰伦',
);
expect(d.status, 'pending');
expect(d.reason, 'low_confidence');
expect(d.validationCount, 4);
});
test('一致但只验证过一次 → pending', () {
final d = MetadataStability.decide(
previous: {
'title': '夜曲',
'artist': '周杰伦',
'confidence': 0.8,
'validation_count': 0,
},
candidate: candidate(),
title: '夜曲',
artist: '周杰伦',
);
expect(d.status, 'pending');
expect(d.reason, 'need_second_validation');
expect(d.validationCount, 1);
});
test('字段缺失不算冲突(旧记录没有艺术家)', () {
final d = MetadataStability.decide(
previous: {
'title': '夜曲',
'artist': '',
'confidence': 0.7,
'validation_count': 1,
},
candidate: candidate(),
title: '夜曲',
artist: '周杰伦',
);
expect(d.status, 'verified');
expect(d.validationCount, 2);
});
test('大小写与首尾空格不影响一致性判断', () {
final d = MetadataStability.decide(
previous: {
'title': ' Night Song ',
'artist': 'JAY',
'confidence': 0.8,
'validation_count': 1,
},
candidate: candidate(),
title: 'night song',
artist: 'jay',
);
expect(d.status, 'verified');
});
});
}
+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,
);
});
});
}
+24
View File
@@ -0,0 +1,24 @@
// test/song_key_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:qingting_player/utils/song_key.dart';
void main() {
group('songKeyFor', () {
test('同一 URL 多次计算结果一致(跨进程稳定)', () {
const url = 'https://example.com/dav/music/a.flac';
expect(songKeyFor(url), songKeyFor(url));
});
test('与探测大小无关:不同阶段的头部请求得到同一个键', () {
// 键只依赖 URL,因此 128KB / 512KB 两次探测不会产生两个缓存键
const url = 'https://example.com/dav/music/b.flac';
final key = songKeyFor(url);
expect(key, songKeyForUrl(url));
expect(key.length, 32); // md5 十六进制
});
test('不同 URL 得到不同键', () {
expect(songKeyFor('https://x/a.flac'), isNot(songKeyFor('https://x/b.flac')));
});
});
}
+143
View File
@@ -0,0 +1,143 @@
#Requires -Version 5.1
<#
.SYNOPSIS
清听真机自检:安装最新 debug APK,冷启动,抓 logcat,校验 libmpv 是否只有单实例。
.DESCRIPTION
判定依据:
- 冷启动后 "PlaybackService 单例创建" 只出现一次;
- 日志里出现过的 serviceId 只有 1 个;
- media_kit Player 的 hashCode 只有 1 个。
.EXAMPLE
powershell -ExecutionPolicy Bypass -File tools\device-check.ps1
powershell -ExecutionPolicy Bypass -File tools\device-check.ps1 -DeviceId 192.168.3.2:36569
#>
[CmdletBinding()]
param(
[string]$DeviceId = '',
[string]$Apk = '',
[int]$WaitSeconds = 12,
# 默认校验 debug 包(_dev 后缀),与 release 包并存安装
[string]$Package = 'com.lxh.qingting_player.dev'
)
$ErrorActionPreference = 'Continue' # adb 会往 stderr 写正常提示,用 Stop 会误判
$root = Split-Path -Parent $PSScriptRoot
if (-not $Apk) { $Apk = Join-Path $root 'build\app\outputs\flutter-apk\app-debug.apk' }
$pkg = $Package
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
function Write-Bad($msg) { Write-Host " !! $msg" -ForegroundColor Red }
if (-not (Get-Command adb -ErrorAction SilentlyContinue)) {
throw '未找到 adb,请把 platform-tools 加入 PATH'
}
if (-not (Test-Path $Apk)) {
throw "APK 不存在:$Apk(先执行 gradlew assembleDebug)"
}
# ---------- 选择设备 ----------
# 同一台手机可能同时存在多条 adb 通道(如无线调试 + mDNS),按 serialno 去重
$devices = @(adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\sdevice$' } | ForEach-Object { ($_ -split '\s+')[0] })
if ($DeviceId) {
if ($devices -notcontains $DeviceId) { throw "设备 $DeviceId 不在线,当前:$($devices -join ', ')" }
} elseif ($devices.Count -eq 0) {
throw '没有在线设备,请先连接手机并授权 USB/无线调试'
} elseif ($devices.Count -eq 1) {
$DeviceId = $devices[0]
} else {
$bySerial = @{}
foreach ($entry in $devices) {
$serial = (& adb -s $entry shell getprop ro.serialno 2>$null | Out-String).Trim()
if ($serial) { $bySerial[$serial] = $entry }
}
if ($bySerial.Count -eq 1) {
$DeviceId = @($bySerial.Values)[0]
Write-Host " (同一设备的多条 adb 通道,使用 $DeviceId)" -ForegroundColor DarkGray
} else {
throw "检测到多台设备,请用 -DeviceId 指定:$($devices -join ', ')"
}
}
$adb = { param($args) & adb -s $DeviceId @args }
$model = (& adb -s $DeviceId shell getprop ro.product.model).Trim()
$android = (& adb -s $DeviceId shell getprop ro.build.version.release).Trim()
Write-Step "设备 $model (Android $android) [$DeviceId]"
# ---------- 安装 ----------
Write-Step "安装 $([IO.Path]::GetFileName($Apk))"
$install = & adb -s $DeviceId install -r $Apk 2>&1
if (($install -join "`n") -notmatch 'Success') { Write-Bad ($install -join "`n"); exit 1 }
Write-Ok '安装成功'
# ---------- 冷启动 ----------
Write-Step '冷启动并抓取日志'
$logPath = Join-Path $root 'build\device-check-last.log'
$creates = 0
# Flyme 有时不会立刻杀掉带前台服务的进程,导致抓不到启动日志:确认进程消失 + 最多重试两次
for ($attempt = 1; $attempt -le 2; $attempt++) {
& adb -s $DeviceId shell am force-stop $pkg | Out-Null
$deadline = (Get-Date).AddSeconds(10)
while ((& adb -s $DeviceId shell pidof $pkg) -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 300
}
# 进程还在说明被系统冻结/保活,直接等到超时也无意义,先给它一点时间解冻
Start-Sleep -Seconds 1
& adb -s $DeviceId logcat -c
# 解析真正的启动组件再启动:applicationId 带 .dev 后缀时,
# "$pkg/.MainActivity" 会把 .MainActivity 拼到 .dev 上导致找不到类;
# 而 "am start -p $pkg" 在部分 ROM 上又解析不到,所以直接问 PackageManager。
$component = (& adb -s $DeviceId shell cmd package resolve-activity --brief $pkg 2>&1 | Select-Object -Last 1).Trim()
& adb -s $DeviceId shell am start -W -n $component 2>&1 | Out-Null
# 第一次按参数等待;第二次重试时多等一会儿(Flyme 解冻可能很慢)
$waitThisTime = if ($attempt -eq 1) { $WaitSeconds } else { [Math]::Max($WaitSeconds, 20) }
Start-Sleep -Seconds $waitThisTime
& adb -s $DeviceId logcat -d -v time > $logPath
# 注意:PS 5.1 的 > 重定向写出的是 UTF-16LE(带 BOM),读取时不要强制 utf8
$log = Get-Content $logPath
$text = $log -join "`n"
$creates = ([regex]::Matches($text, '\[PlaybackService\] 单例创建')).Count
if ($creates -ge 1) { break }
Write-Host " (第 $attempt 次没抓到冷启动日志,进程可能没退干净,重试)" -ForegroundColor DarkYellow
}
# ---------- 判定 ----------
Write-Step '实例检查'
$serviceIds = @([regex]::Matches($text, 'serviceId=(\d+)') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
$players = @([regex]::Matches($text, 'Player 已创建.*?player=(\d+)') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
$failed = $false
if ($creates -eq 1) { Write-Ok "PlaybackService 单例创建 x$creates" } else { Write-Bad "PlaybackService 单例创建 x$creates(应为 1)"; $failed = $true }
if ($serviceIds.Count -eq 1) { Write-Ok "serviceId 唯一:$($serviceIds[0])" } else { Write-Bad "出现 $($serviceIds.Count) 个 serviceId:$($serviceIds -join ', ')"; $failed = $true }
if ($players.Count -eq 1) { Write-Ok "Player 唯一:$($players[0])" } else { Write-Bad "出现 $($players.Count) 个 Player:$($players -join ', ')"; $failed = $true }
# ---------- 系统侧 ----------
Write-Step '系统 MediaSession'
$sessionIds = @(& adb -s $DeviceId shell dumpsys media_session 2>&1 |
Select-String ("media-session " + [regex]::Escape($pkg) + "/media-session/(\d+)") |
ForEach-Object { $_.Matches[0].Groups[1].Value } | Sort-Object -Unique)
if ($sessionIds.Count -eq 1) { Write-Ok "MediaSession 唯一:$($sessionIds[0])" }
elseif ($sessionIds.Count -eq 0) { Write-Host ' -- 还没有 MediaSession(未开始播放,属正常)' -ForegroundColor DarkGray }
else { Write-Bad "出现 $($sessionIds.Count) 个 MediaSession:$($sessionIds -join ', ')"; $failed = $true }
# ---------- 崩溃 / 加载失败 ----------
Write-Step '崩溃与原生库'
$bad = $log | Select-String 'FATAL EXCEPTION|UnsatisfiedLinkError|Cannot load MediaKitAndroidHelper|Could not load'
if ($bad) { $bad | Select-Object -First 5 | ForEach-Object { Write-Bad $_.Line.Trim() }; $failed = $true } else { Write-Ok '无崩溃、无 so 加载失败' }
# ---------- 结果 ----------
Write-Host ''
if ($failed) {
Write-Host 'RESULT: FAIL —— 仍存在多实例或异常,完整日志:' -ForegroundColor Red
Write-Host " $logPath"
exit 1
}
Write-Host 'RESULT: PASS —— 单引擎单实例' -ForegroundColor Green
Write-Host "日志:$logPath"
Write-Host ''
Write-Host '还需要你手动确认(脚本无法代替耳朵和眼睛):' -ForegroundColor Yellow
Write-Host ' 1. 放一首歌,确认只有一路声音(没有重影/叠音)'
Write-Host ' 2. 按通知栏暂停/播放,确认播放页状态同步变化'
Write-Host ' 3. 拖通知栏进度条,确认播放页进度跟着走'