Compare commits

31 Commits
Author SHA1 Message Date
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
lxh2875931338 40d4af322b 开始进入修复阶段 2026-09-02 21:42:42 +08:00
39 changed files with 2484 additions and 709 deletions
+16
View File
@@ -35,3 +35,19 @@ desktop.ini
/tmp/
*.tmp
.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.
version:
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
channel: "stable"
project_type: app
@@ -13,26 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- 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
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
# User provided section
+1 -2
View File
@@ -8,11 +8,10 @@ GeneratedPluginRegistrant.java
.cxx/
.kotlin
/build
.settings
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
.settings
+54 -25
View File
@@ -1,11 +1,24 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
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 {
namespace = "com.lxh.qingting_player"
compileSdk = 36
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -14,49 +27,65 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
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
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
versionName = flutter.versionName
// 仅打包 arm64-v8a
// 与 main 分支保持一致:仅打包 arm64-v8a
ndk {
abiFilters.addAll(listOf("arm64-v8a"))
}
}
// 显式指定 jniLibs 源目录
sourceSets {
getByName("main") {
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 {
// AGP 9 不再允许在 AndroidManifest 里写 android:extractNativeLibs,
// 改为在这里声明,效果等价于原先的“强制提取 .so”
packaging {
jniLibs {
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 {
debug {
// 测试包与正式包并存安装:com.lxh.qingting_player.dev
// 说明:Android 包名每一段必须以字母开头,所以没法直接写 "_dev",
// 这里用 ".dev" 后缀,等价于给 debug 打上 dev 标记。
applicationIdSuffix = ".dev"
resValue("string", "app_name", "清听 Dev")
}
release {
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
resValue("string", "app_name", "清听")
// 有 key.properties 就用正式签名,否则回退 debug(仅本机调试用)
signingConfig = if (hasReleaseKeystore) {
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();
}
+16 -15
View File
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.lxh.qingting_player">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- package 由 build.gradle.kts 的 namespace / applicationId 决定;AGP 9 已忽略这里的 package 属性 -->
<!-- 权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -12,11 +12,9 @@
<application
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="清听"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:foregroundServiceType="mediaPlayback"
android:extractNativeLibs="true"> <!-- ⭐ 强制提取 .so,避免压缩后加载失败 -->
android:foregroundServiceType="mediaPlayback">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -34,8 +32,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- AudioService -->
<!-- ⭐ AudioService(不加 meta-data) -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
@@ -45,8 +42,9 @@
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<!-- MediaButtonReceiver -->
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<!-- ⭐ MediaButtonReceiver -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
@@ -55,7 +53,7 @@
</intent-filter>
</receiver>
<!-- FileProvider for artwork(已配置,保持不变) -->
<!-- ⭐ FileProvider -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -70,12 +68,15 @@
android:name="flutterEmbedding"
android:value="2" />
</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>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -4,38 +4,26 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
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.plugin.common.MethodChannel
import java.io.File
import android.content.Context
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"
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)
requestAudioFocus()
setHighRefreshRate()
}
@@ -63,7 +51,7 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// FileProvider MethodChannel for sharing artwork via content:// URI
// FileProvider MethodChannel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "getContentUri") {
+2 -1
View File
@@ -3,6 +3,7 @@ android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
# 本机 Kotlin 守护进程连不上(fallback 编译),且增量缓存易损坏,沿用 main 分支原有的设置
kotlin.incremental=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
zipStoreBase=GRADLE_USER_HOME
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 {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
-32
View File
@@ -1,32 +0,0 @@
// lib/audio/player_controller.dart
import '../services/playback_service.dart'; // ⭐ 修正路径
class PlayerController {
static final PlayerController _instance = PlayerController._internal();
factory PlayerController() => _instance;
PlayerController._internal();
final PlaybackService _playback = PlaybackService();
// ---- 状态流(只读) ----
Stream<bool> get playingStream => _playback.player.stream.playing;
Stream<Duration> get positionStream => _playback.player.stream.position;
Stream<Duration> get durationStream => _playback.player.stream.duration;
Stream<void> get completedStream => _playback.player.stream.completed;
// ---- 当前状态快照 ----
bool get isPlaying => _playback.player.state.playing;
Duration get position => _playback.player.state.position;
Duration get duration => _playback.player.state.duration;
// ---- 控制命令 ----
Future<void> play() => _playback.resume();
Future<void> pause() => _playback.pause();
Future<void> stop() => _playback.stop();
Future<void> seek(Duration position) => _playback.seek(position);
// ---- 加载歌曲 ----
void load(String url, {Map<String, String>? headers}) {
_playback.play(url, headers: headers);
}
}
+36 -1
View File
@@ -27,7 +27,7 @@ class SongDatabase {
_log('📂 数据库路径: $path');
return await openDatabase(
path,
version: 4, // 升级版本号
version: 6, // v6:song_key 改为纯 URL 的 md5(配合分阶段探测)
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -226,6 +226,14 @@ class SongDatabase {
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('✅ 数据库升级完成');
}
@@ -248,6 +256,33 @@ class SongDatabase {
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 {
_logQuery('songs', 'getByHash', args: {'content_hash': contentHash});
final db = await database;
+2
View File
@@ -54,6 +54,8 @@ void main() async {
try {
_audioHandler = AudioPlayerHandler();
debugPrint('⏱️ T0.62 AudioPlayerHandler created');
// ⭐ 将 Handler 注入到 AudioService
AudioService().setHandler(_audioHandler!);
} catch (e, st) {
debugPrint('❌ AudioPlayerHandler failed: $e');
debugPrint('$st');
+83 -15
View File
@@ -1,60 +1,128 @@
// lib/metadata/file_provider.dart
import 'dart:io';
import 'package:flutter/foundation.dart'; // ⭐ 添加
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:dio/dio.dart';
import '../services/webdav_service.dart';
import '../utils/song_key.dart';
abstract class FileProvider {
Future<File?> getFile(String url);
/// [maxBytes] 是只取文件头部的上限(本地文件实现会忽略它)。
Future<File?> getFile(String url, {int maxBytes = 512 * 1024});
}
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();
@override
Future<File?> getFile(String url) async {
Future<File?> getFile(String url, {int maxBytes = defaultMaxBytes}) async {
try {
// 1. 获取缓存目录
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);
// 2. 如果缓存存在且未过期,直接返回
// 1. 命中本地缓存(头部文件同样按 24h 过期)
if (await cacheFile.exists()) {
final stat = await cacheFile.stat();
// 缓存 1 小时内有效(86400 秒)
if (DateTime.now().difference(stat.modified).inSeconds < 86400) {
if (stat.size > 0 &&
DateTime.now().difference(stat.modified).inSeconds < 86400) {
return cacheFile;
}
}
// 3. 获取认证头
// 2. 认证头
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) return null;
// 4. 只下载前 1MB
await _dio.download(
// 3. 取消上一次未完成的下载
_activeToken?.cancel('superseded');
final token = CancelToken();
_activeToken = token;
// 4. 只请求前 2MB
final response = await _dio.get<ResponseBody>(
url,
cachePath,
cancelToken: token,
options: Options(
headers: headers,
receiveTimeout: const Duration(seconds: 10),
headers: {
...headers,
'Range': 'bytes=0-${maxBytes - 1}',
},
responseType: ResponseType.stream,
receiveTimeout: const Duration(seconds: 15),
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;
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
debugPrint('↩️ [WebDAVFileProvider] 下载已取消(切歌触发)');
return null;
}
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
return null;
} catch (e) {
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
return null;
}
}
/// 主动取消进行中的元数据下载(切歌时可由上层调用)。
static void cancelActive() {
_activeToken?.cancel('superseded');
_activeToken = null;
}
}
class LocalFileProvider implements FileProvider {
@override
Future<File?> getFile(String url) async {
Future<File?> getFile(String url, {int maxBytes = 512 * 1024}) async {
final file = File(url);
if (await file.exists()) {
return file;
+9
View File
@@ -57,6 +57,12 @@ class NormalizedMetadata {
final Duration? duration;
final Uint8List? artwork;
/// 字段来源:true 表示来自标签,false 表示从文件名/路径推断。
/// 置信度模型需要区分这两者——"标签里的标题"和"文件名当标题"价值完全不同。
final bool titleFromTag;
final bool artistFromTag;
final bool albumFromTag;
const NormalizedMetadata({
this.title = '',
this.artist = '',
@@ -70,6 +76,9 @@ class NormalizedMetadata {
this.totalDisc = 0,
this.duration,
this.artwork,
this.titleFromTag = false,
this.artistFromTag = false,
this.albumFromTag = false,
});
bool get isEmpty =>
+96 -14
View File
@@ -4,21 +4,22 @@ import 'metadata_model.dart';
class MetadataNormalizer {
NormalizedMetadata normalize(RawMetadata raw,
{String fileName = '', String filePath = ''}) {
final title = raw.title.trim();
final artist = raw.artist.trim();
final tagTitle = raw.title.trim();
final tagArtist = raw.artist.trim();
final tagAlbum = raw.album.trim();
final finalTitle =
title.isNotEmpty ? title : _inferTitleFromFileName(fileName);
final finalArtist =
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath);
final finalArtist2 = finalArtist.isNotEmpty
? finalArtist
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家');
tagTitle.isNotEmpty ? tagTitle : _inferTitleFromFileName(fileName);
final artistFromPath = _inferArtistFromPath(filePath);
final finalArtist = tagArtist.isNotEmpty
? tagArtist
: (raw.performers.isNotEmpty ? raw.performers.first : artistFromPath);
return NormalizedMetadata(
title: finalTitle,
artist: finalArtist2,
album: raw.album,
artist: finalArtist,
album: tagAlbum,
genres: raw.genres,
performers: raw.performers,
year: raw.year,
@@ -28,6 +29,9 @@ class MetadataNormalizer {
totalDisc: raw.totalDisc,
duration: raw.duration,
artwork: raw.artwork,
titleFromTag: tagTitle.isNotEmpty,
artistFromTag: tagArtist.isNotEmpty || raw.performers.isNotEmpty,
albumFromTag: tagAlbum.isNotEmpty,
);
}
@@ -49,16 +53,94 @@ class MetadataNormalizer {
return '未知艺术家';
}
/// 置信度评估(元数据引擎第 2 步)。
///
/// 设计原则:
/// 1. 标签 >= 推断:来自标签的字段永远比文件名/目录推断更可信;
/// 2. 字段齐全度:标题、艺术家、专辑依次加分;
/// 3. 历史一致性:同一艺人/专辑已有多条可信记录时额外加权,
/// 这是"自学习"的入口——库越大,判断越稳;
/// 4. 可解释:每个加分项都写进 evidence,方便日志与后续调参。
///
/// 分数区间大致:
/// 0.35 只有文件名
/// 0.55 标题来自标签、艺术家靠推断
/// 0.80 标题 + 艺术家都来自标签
/// 0.90 标题 + 艺术家 + 专辑
/// 0.95 以上再加年份/封面/历史一致性
CandidateMetadata evaluate(
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(
metadata: normalized,
confidence: 0.8,
evidence: {'normalized': 0.8},
confidence: double.parse(confidence.toStringAsFixed(2)),
evidence: evidence,
);
}
/// 按阈值决定最终元数据。
/// 低于阈值时仍返回数据(用于展示),但 source 标成 pending,
/// 调用方据此累计 validation_count、下次播放重新确认。
FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) {
return FinalMetadata(
title: candidate.metadata.title,
@@ -76,7 +158,7 @@ class MetadataNormalizer {
totalDisc: candidate.metadata.totalDisc,
duration: candidate.metadata.duration,
artwork: candidate.metadata.artwork,
source: 'normalized',
source: candidate.confidence >= threshold ? 'normalized' : 'pending',
);
}
}
+92 -37
View File
@@ -6,9 +6,11 @@ import '../database/song_database.dart';
import 'metadata_model.dart';
import 'metadata_reader.dart';
import 'metadata_normalizer.dart';
import 'metadata_stability.dart';
import 'metadata_cache.dart';
import 'file_provider.dart';
import '../utils/artwork_helper.dart';
import '../utils/song_key.dart';
class MetadataService {
static final MetadataService _instance = MetadataService._internal();
@@ -20,9 +22,8 @@ class MetadataService {
final MetadataCache _memoryCache = MetadataCache();
final SongDatabase _db = SongDatabase();
String _generateSongKey(String url, int fileSize, int modifiedTime) {
final raw = '$url|$fileSize|$modifiedTime';
return raw.hashCode.toString();
String _generateSongKey(String url) {
return songKeyFor(url);
}
FileProvider _getProvider(String url) {
@@ -58,25 +59,39 @@ class MetadataService {
}
}
// 2. 获取文件
// 2. 分阶段取头部:先 128KB 拿 title/artist,读不到再补到 512KB。
// 弱网下首包更小,标签能更快出来,也少占带宽。
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()) {
debugPrint('⚠️ [MetadataService] file not available, using fallback');
return _fallbackFromFileName(fileName);
}
final stat = await file.stat();
final songKey =
_generateSongKey(url, stat.size, stat.modified.millisecondsSinceEpoch);
var raw = await _reader.readRawMetadata(file);
if (raw.title.isEmpty && raw.artist.isEmpty) {
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) {
final dbSong = await _db.getSong(songKey);
dbSong = await _db.getSong(songKey);
if (dbSong != null) {
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(
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
@@ -105,57 +120,97 @@ class MetadataService {
await _memoryCache.put(fileId, metadata);
return metadata;
}
debugPrint('🔁 [MetadataService] 记录未验证'
'(conf=${confidence.toStringAsFixed(2)}, status=$status),重新确认');
}
}
// 4. 读取原始 metadata
final raw = await _reader.readRawMetadata(file);
// 5. 标准化
// 4. 标准化(raw 已在上面分阶段读取)
final normalized = _normalizer.normalize(
raw,
fileName: fileName,
filePath: url,
);
// 6. 评估
// 6. 评估(含历史一致性)
final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
final candidate = _normalizer.evaluate(normalized, history);
final finalMetadata = _normalizer.decide(candidate);
debugPrint(
'📢 [MetadataService] finalMetadata.artwork is ${finalMetadata.artwork != null ? 'not null (${finalMetadata.artwork!.length} bytes)' : 'null'}');
// 7. 保存封面图到本地
String? artworkPath;
if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) {
// 7. 与已有记录合并(一致性 + 次数由状态机决定)
final prevConfidence = (dbSong?['confidence'] as num?)?.toDouble() ?? 0.0;
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 =
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
}
// 8. 保存到 SQLite
await _db.insertSong({
final status = decision.status;
debugPrint('🧭 [MetadataService] 状态机: ${decision.reason} → $status'
'(validations=$validationCount)');
final row = <String, dynamic>{
'song_key': songKey,
'remote_path': url,
// remote_path 必须存应用内路径(fileId / song.id),不能存完整 URL
'remote_path': fileId,
'file_size': stat.size,
'modified_time': stat.modified.millisecondsSinceEpoch,
'title': finalMetadata.title,
'artist': finalMetadata.artist,
'album': finalMetadata.album,
'genre': finalMetadata.genre,
'title': useNew ? finalMetadata.title : (dbSong?['title'] ?? finalMetadata.title),
'artist':
useNew ? finalMetadata.artist : (dbSong?['artist'] ?? finalMetadata.artist),
'album': useNew ? finalMetadata.album : (dbSong?['album'] ?? finalMetadata.album),
'genre': useNew ? finalMetadata.genre : (dbSong?['genre'] ?? finalMetadata.genre),
'artwork_path': artworkPath,
'confidence': candidate.confidence,
'validation_count': 1,
'first_scan': DateTime.now().millisecondsSinceEpoch,
'confidence': effectiveConfidence,
'validation_count': validationCount,
'first_scan':
dbSong?['first_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);
}
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. 内存缓存
await _memoryCache.put(fileId, finalMetadata);
await _memoryCache.put(fileId, merged);
debugPrint(
'✅ [MetadataService] saved: ${finalMetadata.title} - ${finalMetadata.artist} (confidence: ${candidate.confidence})');
return finalMetadata;
debugPrint('📊 [MetadataService] $status '
'conf=${effectiveConfidence.toStringAsFixed(2)} '
'validations=$validationCount '
'evidence=${candidate.evidence}');
debugPrint('✅ [MetadataService] saved: ${merged.title} - ${merged.artist}');
return merged;
}
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;
}
}
+87 -26
View File
@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../services/webdav_service.dart';
import '../services/playback_service.dart';
import '../widgets/magnetic_scroll_physics.dart';
import 'webdav_setup_page.dart';
import 'settings_page.dart';
import '../database/song_database.dart';
import 'webdav_file_list_page.dart';
import '../constants/ui_constants.dart';
import '../base/base_state.dart';
@@ -34,11 +35,10 @@ class SongItem {
: fileName.replaceAll(RegExp(r'\.[^.]*$'), '');
String get displaySubtitle {
if (metadataState == 'success' && artist != null) {
if (metadataState == 'success' && artist != null && artist!.isNotEmpty) {
return artist!;
} else {
return sourceTag;
}
return sourceTag.isNotEmpty ? sourceTag : '未知艺术家';
}
}
@@ -236,7 +236,7 @@ class _HomePageState extends BaseState<HomePage> {
safeSetState(() => _isLoading = true);
try {
await WebDAVService.instance.loadCredentials();
_favorites = [];
await _loadFavoritesFromDb();
} catch (e) {
_favorites = [];
} finally {
@@ -244,18 +244,62 @@ 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,
sourceTag: folderName.isNotEmpty ? '($folderName)' : '',
metadataState: hasTitle ? 'success' : 'unknown',
));
}
_favorites = items;
}
void _playSong(SongItem song) async {
try {
final url = WebDAVService.instance.getFileUrl(song.path);
final audioService = context.read<AudioService>();
audioService.playSong(Song(
id: song.path,
title: song.displayTitle,
artist: song.displaySubtitle,
url: url,
));
// 先校验登录,再开始播放。
// 以前这里是:playSong() 不 await,紧跟着又 PlaybackService().play() 一次,
// 同一个 Player 会被 open 两次,media_kit 串行化时第二次会顶掉第一次,
// 表现就是"媒体加载了但没有声音"。
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) {
@@ -270,7 +314,16 @@ class _HomePageState extends BaseState<HomePage> {
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,
url: url,
));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -363,7 +416,14 @@ class _HomePageState extends BaseState<HomePage> {
),
IconButton(
icon: const Icon(Icons.menu, color: Colors.white54),
onPressed: () {},
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const SettingsPage(),
),
);
},
),
],
),
@@ -406,6 +466,7 @@ class _HomePageState extends BaseState<HomePage> {
builder: (_) => const WebDAVFileListPage(),
),
);
await _loadFavoritesFromDb();
_resetSnapState();
safeSetState(() {});
} else {
@@ -664,12 +725,11 @@ class _HomePageState extends BaseState<HomePage> {
delegate: SliverChildBuilderDelegate(
(context, index) {
final song = _favorites[index];
final itemProgress =
(_listReveal * 2 - index / 5)
.clamp(0.0, 1.0);
// 收藏项不做滚动渐显:_listReveal 在首屏为 0,
// 会让条目完全透明(看起来"收藏了但不显示")。
return AnimatedOpacity(
opacity: itemProgress,
opacity: 1.0,
duration: const Duration(milliseconds: 150),
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -703,13 +763,14 @@ class _HomePageState extends BaseState<HomePage> {
trailing: IconButton(
icon: const Icon(
Icons.favorite,
color: Color(0xFFB8D4D0),
color: Color(0xFFE05C5C),
size: 20,
),
onPressed: () {
safeSetState(() {
_favorites.removeAt(index);
});
onPressed: () async {
await SongDatabase()
.removeFavorite(song.path);
await _loadFavoritesFromDb();
if (mounted) safeSetState(() {});
},
),
onTap: () => _playSong(song),
+47 -5
View File
@@ -2,6 +2,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../database/song_database.dart';
import '../widgets/song_artwork.dart';
class PlaylistPage extends StatefulWidget {
const PlaylistPage({super.key});
@@ -17,6 +19,9 @@ class _PlaylistPageState extends State<PlaylistPage> {
int _scrollGeneration = 0;
bool _hasScrolledToCurrent = false;
/// path -> 封面文件路径(来自 songs 表)
Map<String, String> _artworkByPath = {};
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
// 实际约 72-76px,取 74px 作为稳定值
static const double _itemExtent = 74.0;
@@ -28,13 +33,46 @@ class _PlaylistPageState extends State<PlaylistPage> {
@override
void initState() {
super.initState();
_loadArtworkPaths();
AudioService().addListener(_onAudioChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
_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
void dispose() {
AudioService().removeListener(_onAudioChanged);
_scrollController.dispose();
super.dispose();
}
@@ -271,11 +309,15 @@ class _PlaylistPageState extends State<PlaylistPage> {
horizontal: 8,
vertical: 4,
),
leading: Icon(
isCurrent ? Icons.play_arrow : Icons.music_note,
color:
isCurrent ? const Color(0xFFB8D4D0) : Colors.grey[600],
size: 24,
leading: SongArtwork(
bytes: song.artwork,
filePath: _artworkByPath[song.id],
size: 48,
fallbackIcon:
isCurrent ? Icons.play_arrow : Icons.music_note,
fallbackColor: isCurrent
? const Color(0xFFB8D4D0)
: Colors.grey[600],
),
title: Text(
song.title,
+356 -48
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});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('设置')),
body: ListView(
children: [
const SizedBox(height: 20),
_buildTile(
icon: Icons.folder_open_outlined,
title: '本地文件 (SAF)',
subtitle: '通过系统文件选择器导入',
onTap: () {
// 🟢 这里粘贴你旧项目的 SAF 逻辑
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待接入 SAF 逻辑')),
);
},
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
bool _loading = true;
int _songRows = 0;
int _metadataRows = 0;
int _favoriteRows = 0;
int _artworkCount = 0;
int _artworkBytes = 0;
int _tempCount = 0;
int _tempBytes = 0;
@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),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
_buildTile(
icon: Icons.cloud_outlined,
title: 'WebDAV 云盘',
subtitle: '连接你的 NAS 或网盘',
onTap: () {
// 🟢 这里调用你现成的 WebDAV 通道
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待接入 WebDAV 逻辑')),
);
},
),
_buildTile(
icon: Icons.equalizer_outlined,
title: '均衡器',
subtitle: '简单低音增强 (后续)',
onTap: () {},
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('清理', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
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')));
}
}
}
Future<void> _testConnection() async {
try {
await WebDAVService.instance.listDirectory(path: '/');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('连接正常')),
);
}
} 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,
),
),
);
}
}
Widget _buildTile(
{required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap}) {
return ListTile(
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,
);
}
+117 -8
View File
@@ -4,6 +4,9 @@ import '../services/webdav_service.dart';
import '../constants/ui_constants.dart';
import '../base/base_state.dart';
import '../controllers/playback_controller.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
import '../widgets/song_artwork.dart';
class WebDAVFileListPage extends StatefulWidget {
final String currentPath;
@@ -20,6 +23,13 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
String _errorMessage = '';
String _currentPath = '/';
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
Map<String, Map<String, dynamic>> _metaByPath = {};
String? _lastSyncedSongPath;
/// 已收藏的歌曲路径
Set<String> _favoritePaths = {};
// 安全解码
String _safeDecode(String input) {
try {
@@ -33,9 +43,59 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
void initState() {
super.initState();
_currentPath = widget.currentPath;
AudioService().addListener(_onPlaybackChanged);
_loadDirectory();
}
@override
void dispose() {
AudioService().removeListener(_onPlaybackChanged);
super.dispose();
}
/// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行
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);
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 +108,40 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
try {
final items =
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) {
safeSetState(() {
_items = items;
_metaByPath = meta;
_favoritePaths = favorites;
_isLoading = false;
});
// 如果当前正在播的歌就在这个目录里,补一次同步
_lastSyncedSongPath = null;
_onPlaybackChanged();
}
} catch (e) {
if (mounted) {
@@ -295,15 +384,27 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
final sizeStr = item.size != null
? '${(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(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
leading: const Icon(
Icons.audiotrack,
color: Color(0xFFB8D4D0),
size: 28,
leading: SongArtwork(
filePath: meta?['artwork_path'] as String?,
size: 44,
fallbackIcon: Icons.audiotrack,
),
title: Text(
displayName,
titleText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
@@ -313,15 +414,23 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
sizeStr,
subtitleText,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
trailing: IconButton(
icon: const Icon(Icons.play_arrow, color: Color(0xFFB8D4D0)),
onPressed: () => _playSong(item),
tooltip: '收藏',
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),
);
+143 -93
View File
@@ -4,49 +4,62 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
import '../audio/player_controller.dart';
import '../audio/playback_state_manager.dart';
import '../services/audio_service.dart';
import '../utils/file_provider_utils.dart';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'dart:convert';
import 'playback_service.dart';
import 'audio_service.dart';
import '../utils/file_provider_utils.dart';
/// 音频服务处理器:连接 media_kit 播放引擎与 Android 系统媒体会话
/// 职责:发布播放状态到系统通知栏,接收系统媒体按钮事件
/// 它不拥有 Player 实例,所有播放控制均委托给 PlaybackService
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
final PlayerController _player = PlayerController();
final PlaybackStateManager _state = PlaybackStateManager();
final PlaybackService _playback = PlaybackService();
// 当前媒体信息
String? _currentId;
String? _currentTitle;
String? _currentArtist;
Duration _currentPosition = Duration.zero;
DateTime _lastPublishTime = DateTime.now();
static const Duration _publishInterval = Duration(milliseconds: 500);
// ⭐ 缓存 artwork 文件路径,避免重复写入
String? _currentArtworkPath;
bool _isPublishing = false;
AudioPlayerHandler() {
_player.playingStream.listen((playing) {
_state.updatePlaying(playing);
_publishState();
_bindToPlayer();
}
// ═══════════════════════════════════════════════════════════════
// 绑定播放器状态流
// ═══════════════════════════════════════════════════════════════
void _bindToPlayer() {
final player = _playback.player;
player.stream.playing.listen((playing) {
_publishPlaybackState(
playing: playing,
position: _currentPosition,
duration: player.state.duration,
);
});
_player.positionStream.listen((position) {
player.stream.position.listen((position) {
_currentPosition = position;
_state.updatePosition(position);
final now = DateTime.now();
if (now.difference(_lastPublishTime) >= _publishInterval) {
_lastPublishTime = now;
// ⭐ 只保留 _publishStateOnly()
_publishStateOnly();
_publishPlaybackState(
playing: player.state.playing,
position: position,
duration: player.state.duration,
onlyPosition: true,
);
}
});
_player.durationStream.listen((duration) {
debugPrint('🎯 [durationStream] duration=$duration');
_state.updateDuration(duration);
player.stream.duration.listen((duration) {
if (_currentId != null && _currentTitle != null) {
_updateMediaItem(
id: _currentId!,
@@ -55,45 +68,97 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
duration: duration,
);
}
_publishState();
_publishPlaybackState(
playing: player.state.playing,
position: _currentPosition,
duration: duration,
);
});
}
// ---- 发布状态 ----
void _publishState() {
final state = _state.playbackState;
playbackState.add(audio_service.PlaybackState(
controls: state.controls,
processingState: state.processingState,
playing: state.playing,
androidCompactActionIndices: state.androidCompactActionIndices,
updatePosition: _currentPosition,
updateTime: DateTime.now(),
systemActions: const {
audio_service.MediaAction.seek,
},
));
// ═══════════════════════════════════════════════════════════════
// 外部同步接口(由 AudioService 调用)
// ═══════════════════════════════════════════════════════════════
void syncState(Song? song) {
if (song == null) {
_currentId = null;
_currentTitle = null;
_currentArtist = null;
return;
}
_updateMediaItem(
id: song.id,
title: song.title,
artist: song.artist,
artwork: song.artwork,
);
final player = _playback.player;
_publishPlaybackState(
playing: player.state.playing,
position: _currentPosition,
duration: player.state.duration,
);
}
void _publishStateOnly() {
final current = playbackState.value;
// ═══════════════════════════════════════════════════════════════
// 状态发布
// ═══════════════════════════════════════════════════════════════
void _publishPlaybackState({
required bool playing,
required Duration position,
required Duration duration,
bool onlyPosition = false,
}) {
if (_isPublishing) return;
_isPublishing = true;
playbackState.add(audio_service.PlaybackState(
controls: current.controls.isNotEmpty
? current.controls
: _state.playbackState.controls,
processingState: _state.playbackState.processingState,
playing: current.playing,
androidCompactActionIndices: current.androidCompactActionIndices,
updatePosition: _currentPosition,
updateTime: DateTime.now(),
systemActions: const {
audio_service.MediaAction.seek,
},
));
try {
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(
controls: controls,
processingState: prepared
? audio_service.AudioProcessingState.ready
: audio_service.AudioProcessingState.idle,
playing: playing,
updatePosition: position,
updateTime: DateTime.now(),
systemActions: const {audio_service.MediaAction.seek},
);
playbackState.add(state);
} finally {
_isPublishing = false;
}
}
// ---- 更新媒体信息 ----
List<audio_service.MediaControl> _buildControls(bool playing) {
return [
const audio_service.MediaControl(
androidIcon: 'drawable/ic_previous',
label: '上一曲',
action: audio_service.MediaAction.skipToPrevious,
),
audio_service.MediaControl(
androidIcon: playing ? 'drawable/ic_pause' : 'drawable/ic_play',
label: playing ? '暂停' : '播放',
action: audio_service.MediaAction.playPause,
),
const audio_service.MediaControl(
androidIcon: 'drawable/ic_next',
label: '下一曲',
action: audio_service.MediaAction.skipToNext,
),
];
}
// ═══════════════════════════════════════════════════════════════
// 媒体信息更新
// ═══════════════════════════════════════════════════════════════
void _updateMediaItem({
required String id,
required String title,
@@ -101,21 +166,15 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
Duration? duration,
Uint8List? artwork,
}) {
debugPrint(
'📢 [handler] _updateMediaItem: artwork is ${artwork != null ? 'not null (${artwork.length} bytes)' : 'null'}');
_currentId = id;
_currentTitle = title;
_currentArtist = artist;
final position = _player.position;
debugPrint('📢 [handler] updateMediaItem: $title - $artist');
final position = _playback.player.state.position;
// ⭐ 异步处理 artwork(不阻塞主流程)
_handleArtwork(id, artwork).then((artUri) {
// 如果 artUri 变化,重新推送 MediaItem
final current = mediaItem.value;
if (current != null && current.artUri != artUri) {
debugPrint('📢 [handler] updating artUri: $artUri');
mediaItem.add(audio_service.MediaItem(
id: current.id,
title: current.title,
@@ -127,38 +186,28 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
}
});
// 先推送不带封面图的 MediaItem(让 UI 尽快显示)
mediaItem.add(audio_service.MediaItem(
id: id,
title: title,
artist: artist,
duration: duration ?? _player.duration,
duration: duration ?? _playback.player.state.duration,
extras: {'position': position.inMilliseconds},
));
}
/// 处理封面图:保存到本地并生成 content URI
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
if (artwork == null || artwork.isEmpty) {
_currentArtworkPath = null;
return null;
}
if (artwork == null || artwork.isEmpty) return null;
try {
final dir = await getApplicationDocumentsDirectory();
final artworkDir = Directory('${dir.path}/artworks');
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
// 使用 md5 生成安全的文件名
final bytes = utf8.encode(id);
final digest = md5.convert(bytes);
final fileName = '$digest.jpg';
final path = '${artworkDir.path}/$fileName';
final file = File(path);
// 检查文件是否已存在
if (await file.exists()) {
final existingBytes = await file.readAsBytes();
if (existingBytes.length == artwork.length &&
@@ -167,12 +216,8 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
return await FileProviderUtils.getContentUri(file);
}
}
// 写入新文件
await file.writeAsBytes(artwork);
_currentArtworkPath = path;
debugPrint('📢 [handler] artwork saved: $path');
return await FileProviderUtils.getContentUri(file);
} catch (e) {
debugPrint('⚠️ [handler] artwork handling failed: $e');
@@ -180,50 +225,55 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
}
}
// ---- 外部接口 ----
void updateNotification({
required String id,
required String title,
required String artist,
Uint8List? artwork,
}) {
_updateMediaItem(
id: id,
title: title,
artist: artist,
artwork: artwork,
_updateMediaItem(id: id, title: title, artist: artist, artwork: artwork);
final player = _playback.player;
_publishPlaybackState(
playing: player.state.playing,
position: _currentPosition,
duration: player.state.duration,
);
_publishState();
}
// ---- 控制命令 ----
// ═══════════════════════════════════════════════════════════════
// audio_service 控制命令(全部委托给 PlaybackService)
// ═══════════════════════════════════════════════════════════════
@override
Future<void> play() async {
debugPrint('▶️ [handler] play() called');
_player.play();
_publishState();
// 走 AudioService 的统一入口:冷启动首次播放需要先 open + seek
await AudioService().playOrResume();
}
@override
Future<void> pause() async {
debugPrint('⏸️ [handler] pause() called');
_player.pause();
_publishState();
await _playback.pause();
}
@override
Future<void> stop() async {
debugPrint('⏹️ [handler] stop() called');
_player.stop();
_publishState();
await _playback.stop();
}
@override
Future<void> seek(Duration position) async {
debugPrint('⏩ [handler] seek() called: $position');
await _player.seek(position);
await _playback.seek(position);
_currentPosition = position;
_publishStateOnly();
final player = _playback.player;
_publishPlaybackState(
playing: player.state.playing,
position: position,
duration: player.state.duration,
onlyPosition: true,
);
}
@override
@@ -251,14 +301,14 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
await skipToPrevious();
break;
case audio_service.MediaButton.media:
if (_player.isPlaying) {
if (_playback.player.state.playing) {
await pause();
} else {
await play();
}
break;
default:
if (_player.isPlaying) {
if (_playback.player.state.playing) {
await pause();
} else {
await play();
+439 -67
View File
@@ -7,12 +7,16 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart' as media_kit;
import 'package:path_provider/path_provider.dart';
import 'playback_service.dart';
import 'network_watcher.dart';
import 'prefetch_service.dart';
import '../metadata/metadata_service.dart';
import '../database/song_database.dart';
import '../utils/artwork_helper.dart';
import '../utils/song_key.dart';
import '../repositories/playlist_repository.dart';
import '../models/playlist.dart';
import 'webdav_service.dart';
import 'audio_player_handler.dart';
enum PlayMode {
sequential,
@@ -62,6 +66,9 @@ class AudioService extends ChangeNotifier {
// ---- 当前播放的歌单 ID ----
String? _currentPlaylistId;
// ⭐ 插入位置:在现有成员变量之后,方法之前
AudioPlayerHandler? _handler; // ⭐ 添加这一行
// ---- 高频进度 ----
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
@@ -76,6 +83,51 @@ class AudioService extends ChangeNotifier {
bool _hasStartedCurrentPlayback = 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 的 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;
// ---- 待恢复的播放进度 ----
@@ -115,6 +167,11 @@ class AudioService extends ChangeNotifier {
_onSongChanged = callback;
}
void setHandler(AudioPlayerHandler handler) {
// ⭐ 添加这个方法
_handler = handler;
}
void togglePlayMode() {
switch (_playMode) {
case PlayMode.sequential:
@@ -243,16 +300,15 @@ class AudioService extends ChangeNotifier {
// ════════════════════════════════════════════════════════════
Future<void> _playCurrent() async {
if (_isChangingTrack) {
debugPrint('⚠️ [AudioService] track switch already running');
return;
}
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
stopPlay();
return;
}
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
final epoch = ++_switchEpoch;
_restoredPendingOpen = false;
_isChangingTrack = true;
_handlingCompletion = false;
_hasStartedCurrentPlayback = false;
@@ -264,6 +320,8 @@ class AudioService extends ChangeNotifier {
final song = _queue[_currentIndex];
_currentSong = song;
_handler?.syncState(song);
_startListening();
notifyListeners();
@@ -271,9 +329,34 @@ class AudioService extends ChangeNotifier {
return;
}
await _playWithHeaders(song.url!);
try {
_trackStartAt = DateTime.now();
_playReadyAt = null;
_firstAudioReported = false;
_metadataNeedsRetry = false;
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();
if (epoch != _switchEpoch) {
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
return;
}
// ⭐ 如果有待恢复的进度,执行 seek
if (_pendingSeekPosition != null &&
_pendingSeekPosition!.inMilliseconds > 0) {
@@ -287,17 +370,254 @@ class AudioService extends ChangeNotifier {
_syncPlayerStateDelayed();
if (_hasStartedCurrentPlayback) {
_lastPlaybackError = null;
_onSongChanged?.call(song);
_loadMetadataForCurrentSong(generation);
// 不在起播瞬间抓元数据:此时 mpv 正在填缓冲区,
// 2MB 的标签请求会和播放流抢带宽,实测会把"下一首"拖到 60s+。
_scheduleMetadataWhenSettled(generation, epoch);
}
// 切歌完成后保存状态
await savePlaybackState();
} finally {
_isChangingTrack = false;
// 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
if (epoch == _switchEpoch) {
_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;
debugPrint('🔁 [AudioService] 打开失败,1.5s 后自动重试');
Timer(const Duration(milliseconds: 1500), () {
if (_currentSong == null) return;
if (_isChangingTrack) return;
_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;
final now = DateTime.now();
if (!force &&
_lastStallRecoveryAt != null &&
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
return;
}
_lastStallRecoveryAt = now;
final resumeAt = positionNotifier.value;
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
try {
_trackStartAt = DateTime.now();
_playReadyAt = null;
_firstAudioReported = false;
await _playWithHeaders(url);
_playReadyAt = DateTime.now();
if (resumeAt > Duration.zero) {
await PlaybackService().seek(resumeAt);
positionNotifier.value = resumeAt;
}
debugPrint('✅ [AudioService] 自动恢复完成');
} catch (e) {
debugPrint('❌ [AudioService] 自动恢复失败: $e');
_lastPlaybackError = '缓冲卡住,自动恢复失败';
notifyListeners();
}
}
/// 网络切换(Wi-Fi 换 AP、Wi-Fi <-> 移动数据)后的主动重连。
///
/// 长连接会随出口 IP 变化失效,mpv 不保证会自己重连。
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
void _onNetworkChanged() {
_networkDebounce?.cancel();
_networkDebounce = Timer(const Duration(seconds: 2), () {
if (_currentSong == null) return;
if (_isChangingTrack) return;
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
_recoverFromStall(force: true);
});
}
Future<void> _waitForPlaybackStarted() async {
final player = PlaybackService().player;
@@ -340,9 +660,8 @@ class AudioService extends ChangeNotifier {
return {};
}
String _generateSongKey(String url, int fileSize, int modifiedTime) {
final raw = '$url|$fileSize|$modifiedTime';
return raw.hashCode.toString();
String _generateSongKey(String url) {
return songKeyFor(url);
}
// ════════════════════════════════════════════════════════════
@@ -394,6 +713,15 @@ class AudioService extends ChangeNotifier {
}
if (metadata.isNotEmpty) {
// 来源是文件名 = 元数据没读到(常见于反代 502 / 超时)
// 记下来,播到 80% 时再试一次,避免这首歌永远只有文件名
if (metadata.source == 'filename') {
_metadataNeedsRetry = true;
debugPrint('⚠️ [AudioService] 元数据回退到文件名,将在 80% 处重试一次');
} else {
_metadataNeedsRetry = false;
}
final updatedSong = Song(
id: song.id,
title: metadata.title.isNotEmpty ? metadata.title : song.title,
@@ -431,13 +759,12 @@ class AudioService extends ChangeNotifier {
final file = await provider.getFile(url);
if (file != null && await file.exists()) {
final stat = await file.stat();
songKey = _generateSongKey(
url, stat.size, stat.modified.millisecondsSinceEpoch);
songKey = _generateSongKey(url);
} else {
songKey = url.hashCode.toString();
songKey = songKeyForUrl(url);
}
} catch (e) {
songKey = url.hashCode.toString();
songKey = songKeyForUrl(url);
}
final db = SongDatabase();
@@ -450,7 +777,7 @@ class AudioService extends ChangeNotifier {
try {
final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
final cacheFile = File(cachePath);
if (await cacheFile.exists()) {
await cacheFile.delete();
@@ -483,11 +810,7 @@ class AudioService extends ChangeNotifier {
return;
}
if (_isChangingTrack) {
debugPrint('⚠️ [next] REJECTED: track switch in progress');
return;
}
// 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
@@ -505,12 +828,7 @@ class AudioService extends ChangeNotifier {
Future<void> previous() async {
if (_queue.isEmpty) return;
if (_isChangingTrack) {
debugPrint(
'⚠️ [AudioService] previous() ignored: track switch in progress');
return;
}
// 同 next():最新请求胜出,不拒绝
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return;
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
@@ -539,14 +857,35 @@ class AudioService extends ChangeNotifier {
if (_isPlaying) {
PlaybackService().pause();
} else {
PlaybackService().resume();
savePlaybackState();
return;
}
// 保存状态(包括进度)
playOrResume();
}
/// 统一的"继续播放"入口:UI 按钮、通知栏、耳机键都走这里。
///
/// 冷启动恢复后音频尚未加载,此时 resume() 是在空播放器上调用,
/// 表现就是"点了没反应"。这种情况改走 _playCurrent(open + seek + play)。
Future<void> playOrResume() async {
if (_currentSong == null) return;
if (_isPlaying) return;
if (_restoredPendingOpen) {
_restoredPendingOpen = false;
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
await _playCurrent();
return;
}
await PlaybackService().resume();
savePlaybackState();
}
void stopPlay() {
// 停止底层播放器
PlaybackService().stop(); // 新增
_currentSong = null;
_isPlaying = false;
positionNotifier.value = Duration.zero;
@@ -554,7 +893,6 @@ class AudioService extends ChangeNotifier {
bufferedNotifier.value = Duration.zero;
_stopListening();
notifyListeners();
// 停止时也保存一次
savePlaybackState();
}
@@ -634,6 +972,19 @@ class AudioService extends ChangeNotifier {
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
return;
}
// 首次拿到 >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;
}),
);
@@ -653,6 +1004,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 监听
_subscriptions.add(
player.stream.completed.listen((_) {
@@ -821,11 +1213,18 @@ class AudioService extends ChangeNotifier {
_currentSong = song;
_onSongChanged?.call(song);
_handler?.syncState(song); // ⭐ 添加这一行
debugPrint(
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
// ⭐ 关键:加载音频但不自动播放
await _loadRestoredPlayback();
// 冷启动只恢复 UI 状态,不联网预加载。
// 进程被系统回收后再回来时,预加载(play→seek→pause)会拖慢首屏,
// 还会和用户点播放的操作打架,表现为"假死十几秒"。
_restoredPendingOpen = true;
_isPlaying = false;
positionNotifier.value = _pendingSeekPosition ?? Duration.zero;
notifyListeners();
return true;
} catch (e) {
@@ -834,44 +1233,17 @@ class AudioService extends ChangeNotifier {
}
}
/// ⭐ 加载恢复的播放状态(加载音频,不自动播放)
Future<void> _loadRestoredPlayback() async {
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');
}
// 说明:冷启动不再预加载音频(原 _loadRestoredPlayback)。
// 恢复只做 UI 状态,首次播放时由 togglePlay → _playCurrent 完成 open + seek。
@override
void dispose() {
// 先保存再清理
_progressTimer?.cancel();
_progressTimer = null;
_networkDebounce?.cancel();
_networkDebounce = null;
NetworkWatcher().stop();
savePlaybackState();
_stopListening();
positionNotifier.dispose();
+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;
}
}
}
+75 -16
View File
@@ -1,20 +1,32 @@
// lib/services/playback_service.dart
import 'dart:async';
import 'package:media_kit/media_kit.dart';
import 'package:flutter/foundation.dart';
// ============================================================
// PlaybackService - 播放引擎封装
// 职责:管理 Player 生命周期,不涉及业务逻辑
// ============================================================
class PlaybackService {
static final PlaybackService _instance = PlaybackService._internal();
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;
bool _initialized = false;
late final String _serviceId; // ⭐ 服务实例 ID
// ---------- Getter ----------
Player get player {
debugPrint(
'🎯 [PlaybackService] get player called, serviceId=$_serviceId, initialized=$_initialized, player=${_player?.hashCode}');
if (!_initialized) init();
return _player!;
}
@@ -23,48 +35,91 @@ class PlaybackService {
// ---------- 初始化 ----------
void init() {
if (_initialized) return;
if (_initialized) {
debugPrint(
'⚠️ [PlaybackService] init() 被重复调用, 跳过, serviceId=$_serviceId, player=${_player?.hashCode}');
return;
}
_player = Player();
_initialized = true;
// 可以在这里配置初始参数
// _player.setVolume(1.0);
debugPrint(
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
}
// ---------- 播放 ----------
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();
final media = (headers != null && headers.isNotEmpty)
? Media(url, httpHeaders: headers)
: Media(url);
await _player!.open(media);
await _player!.play();
try {
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 {
debugPrint(
'⏸️ [PlaybackService] pause() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) {
await _player!.pause();
await _player!.pause().timeout(_controlTimeout);
}
}
Future<void> resume() async {
debugPrint(
'▶️ [PlaybackService] resume() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) {
await _player!.play();
await _player!.play().timeout(_controlTimeout);
}
}
Future<void> stop() async {
debugPrint(
'⏹️ [PlaybackService] stop() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) {
await _player!.stop();
await _player!.stop().timeout(_controlTimeout);
}
}
Future<void> seek(Duration position) async {
debugPrint(
'⏩ [PlaybackService] seek() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) {
await _player!.seek(position);
await _player!.seek(position).timeout(_controlTimeout);
}
}
@@ -76,6 +131,8 @@ class PlaybackService {
// ---------- 释放 ----------
void dispose() {
debugPrint(
'🗑️ [PlaybackService] dispose() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) {
_player!.dispose();
_player = null;
@@ -83,8 +140,10 @@ class PlaybackService {
}
}
// ---------- 重新初始化(用于 Hot Restart 场景) ----------
// ---------- 重新初始化 ----------
void reinitialize() {
debugPrint(
'🔄 [PlaybackService] reinitialize() called, serviceId=$_serviceId');
dispose();
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;
}
// 状态监听...
}
+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;
}
}
+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);
-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,
),
],
)
],
),
),
);
}
}
+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
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.9"
version: "4.2.0"
args:
dependency: transitive
description:
@@ -101,18 +101,18 @@ packages:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
version: "1.1.3"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
version: "2.0.0"
collection:
dependency: transitive
description:
@@ -220,10 +220,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
version: "2.2.0"
http:
dependency: transitive
description:
@@ -244,10 +244,10 @@ packages:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.8.0"
version: "4.9.2"
intl:
dependency: transitive
description:
@@ -268,10 +268,10 @@ packages:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
version: "1.0.3"
jni_util:
dependency: transitive
description:
@@ -284,10 +284,10 @@ packages:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.7"
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -412,10 +412,10 @@ packages:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
version: "2.1.0"
nested:
dependency: transitive
description:
@@ -428,10 +428,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.5.0"
version: "9.6.0"
package_config:
dependency: transitive
description:
@@ -588,18 +588,18 @@ packages:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
version: "2.2.1"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
version: "1.1.1"
rxdart:
dependency: transitive
description:
@@ -628,18 +628,18 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.27"
version: "2.4.28"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
@@ -729,10 +729,10 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
version: "1.12.2"
stream_channel:
dependency: transitive
description:
@@ -817,10 +817,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.2.0"
version: "15.3.0"
web:
dependency: transitive
description:
@@ -849,10 +849,10 @@ packages:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
version: "3.1.4"
sdks:
dart: ">=3.12.0 <4.0.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
# 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.
version: 1.1.0+1
version: 1.1.0-dev.1+2
environment:
sdk: ^3.0.0
@@ -36,7 +36,7 @@ dependencies:
provider: ^6.1.2
dio: ^5.4.0 # HTTP 客户端
xml: ^6.5.0
audio_service: ^0.18.19
audio_service: 0.18.19
audio_session: ^0.1.21
permission_handler: ^11.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');
});
});
}
+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. 拖通知栏进度条,确认播放页进度跟着走'