From d49349f7a75d89a57a72fb7a7c0b1028458ee3c3 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 23 Aug 2026 19:08:08 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=87=86=E5=A4=87=E5=B0=9D=E8=AF=95metadat?= =?UTF-8?q?a=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/audio/player_controller.dart | 1 - lib/pages/webdav_file_list_page.dart | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/audio/player_controller.dart b/lib/audio/player_controller.dart index e23ffde..df77931 100644 --- a/lib/audio/player_controller.dart +++ b/lib/audio/player_controller.dart @@ -1,5 +1,4 @@ // lib/audio/player_controller.dart -import 'package:media_kit/media_kit.dart'; import '../services/playback_service.dart'; // ⭐ 修正路径 class PlayerController { diff --git a/lib/pages/webdav_file_list_page.dart b/lib/pages/webdav_file_list_page.dart index d01dc02..a3ce3ed 100644 --- a/lib/pages/webdav_file_list_page.dart +++ b/lib/pages/webdav_file_list_page.dart @@ -1,4 +1,5 @@ // lib/pages/webdav_file_list_page.dart +// ignore: unused_import import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; From 6c4e77ca00f5543eb1b55ae7d05298a7acbbe99a Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 23 Aug 2026 19:08:58 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=9D=BF=E5=88=86?= =?UTF-8?q?=E6=94=AF=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index f7e8f9e..39757a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.0.3+1 +version: 1.0.4+1 environment: sdk: ^3.0.0 From 9a60441e04235fde8721dfdc8f6b9bc13b424659 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 23 Aug 2026 19:24:16 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=A1=86=E6=9E=B6=E5=85=88=E4=B8=8A=EF=BC=8C?= =?UTF-8?q?=E5=90=8E=E9=9D=A2=E6=85=A2=E6=85=A2=E6=94=B9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/metadata/metadata_cache.dart | 36 +++++++ lib/metadata/metadata_constants.dart | 16 +++ lib/metadata/metadata_model.dart | 141 ++++++++++++++++++++++++++ lib/metadata/metadata_normalizer.dart | 85 ++++++++++++++++ lib/metadata/metadata_reader.dart | 30 ++++++ lib/metadata/metadata_service.dart | 74 ++++++++++++++ lib/services/audio_service.dart | 40 +++++++- pubspec.lock | 24 +++++ pubspec.yaml | 1 + 9 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 lib/metadata/metadata_cache.dart create mode 100644 lib/metadata/metadata_constants.dart create mode 100644 lib/metadata/metadata_model.dart create mode 100644 lib/metadata/metadata_normalizer.dart create mode 100644 lib/metadata/metadata_reader.dart create mode 100644 lib/metadata/metadata_service.dart diff --git a/lib/metadata/metadata_cache.dart b/lib/metadata/metadata_cache.dart new file mode 100644 index 0000000..80a7dd0 --- /dev/null +++ b/lib/metadata/metadata_cache.dart @@ -0,0 +1,36 @@ +// lib/metadata/metadata_cache.dart +import 'metadata_model.dart'; + +/// 本地缓存接口(先用内存缓存,后续升级为 SQLite) +class MetadataCache { + // 内存缓存:Map + final Map _cache = {}; + + /// 获取缓存 + Future get(String fileId) async { + return _cache[fileId]; + } + + /// 存入缓存 + Future put(String fileId, FinalMetadata metadata) async { + _cache[fileId] = metadata; + } + + /// 更新缓存(同 put) + Future update(String fileId, FinalMetadata metadata) async { + _cache[fileId] = metadata; + } + + /// 根据 artist 获取历史记录(用于证据收集) + /// 实际项目中需要从数据库查询,目前返回空列表 + Future>> getHistory(String artist, + {int limit = 10}) async { + // TODO: 从 SQLite 查询 + return []; + } + + /// 清空缓存 + Future clear() async { + _cache.clear(); + } +} diff --git a/lib/metadata/metadata_constants.dart b/lib/metadata/metadata_constants.dart new file mode 100644 index 0000000..078a973 --- /dev/null +++ b/lib/metadata/metadata_constants.dart @@ -0,0 +1,16 @@ +// lib/metadata/metadata_constants.dart +class MetadataConstants { + /// 首次 metadata 触发延迟(播放开始后等待时间) + static const Duration initialTriggerDelay = Duration(seconds: 20); + + /// 重新确认进度点 + static const double recheckProgress75 = 0.75; + + static const double recheckProgress100 = 1.0; + + /// 置信度阈值 + static const double confidenceThreshold = 0.6; + + /// 最小历史证据数量 + static const int minHistoryCount = 4; +} diff --git a/lib/metadata/metadata_model.dart b/lib/metadata/metadata_model.dart new file mode 100644 index 0000000..fd88358 --- /dev/null +++ b/lib/metadata/metadata_model.dart @@ -0,0 +1,141 @@ +// lib/metadata/metadata_model.dart +import 'dart:typed_data'; + +/// 从音频文件读取的原始元数据(未经任何处理) +class RawMetadata { + final String title; + final String artist; + final String album; + final String albumArtist; + final String composer; + final String genre; + final int year; + final int trackNumber; + final int discNumber; + final Uint8List? artwork; + + const RawMetadata({ + this.title = '', + this.artist = '', + this.album = '', + this.albumArtist = '', + this.composer = '', + this.genre = '', + this.year = 0, + this.trackNumber = 0, + this.discNumber = 0, + this.artwork, + }); + + /// 判断是否为空(没有任何有效信息) + bool get isEmpty => + title.isEmpty && + artist.isEmpty && + album.isEmpty && + albumArtist.isEmpty && + composer.isEmpty && + genre.isEmpty && + year == 0 && + trackNumber == 0 && + discNumber == 0 && + artwork == null; + + bool get isNotEmpty => !isEmpty; +} + +/// 经过标准化处理后的元数据 +class NormalizedMetadata { + final String title; + final String artist; + final String album; + final String albumArtist; + final String composer; + final String genre; + final int year; + final int trackNumber; + final int discNumber; + final Uint8List? artwork; + + const NormalizedMetadata({ + this.title = '', + this.artist = '', + this.album = '', + this.albumArtist = '', + this.composer = '', + this.genre = '', + this.year = 0, + this.trackNumber = 0, + this.discNumber = 0, + this.artwork, + }); + + bool get isEmpty => + title.isEmpty && + artist.isEmpty && + album.isEmpty && + albumArtist.isEmpty && + composer.isEmpty && + genre.isEmpty && + year == 0 && + trackNumber == 0 && + discNumber == 0 && + artwork == null; + + bool get isNotEmpty => !isEmpty; +} + +/// 带置信度的候选元数据 +class CandidateMetadata { + final NormalizedMetadata metadata; + final double confidence; // 0.0 ~ 1.0 + final Map evidence; // 证据来源权重 + + const CandidateMetadata({ + required this.metadata, + required this.confidence, + this.evidence = const {}, + }); +} + +/// 最终确认的元数据 +class FinalMetadata { + final String title; + final String artist; + final String album; + final String albumArtist; + final String composer; + final String genre; + final int year; + final int trackNumber; + final int discNumber; + final Uint8List? artwork; + final String source; // 'file_tag', 'filename', 'cache', 'user_corrected' + + const FinalMetadata({ + this.title = '', + this.artist = '', + this.album = '', + this.albumArtist = '', + this.composer = '', + this.genre = '', + this.year = 0, + this.trackNumber = 0, + this.discNumber = 0, + this.artwork, + this.source = 'unknown', + }); + + bool get isEmpty => + title.isEmpty && + artist.isEmpty && + album.isEmpty && + albumArtist.isEmpty && + composer.isEmpty && + genre.isEmpty && + year == 0 && + trackNumber == 0 && + discNumber == 0 && + artwork == null; + + bool get isNotEmpty => !isEmpty; +} diff --git a/lib/metadata/metadata_normalizer.dart b/lib/metadata/metadata_normalizer.dart new file mode 100644 index 0000000..0e1a02d --- /dev/null +++ b/lib/metadata/metadata_normalizer.dart @@ -0,0 +1,85 @@ +// lib/metadata/metadata_normalizer.dart +import 'metadata_model.dart'; + +class MetadataNormalizer { + /// 标准化原始数据(清洗空格、推断、fallback) + NormalizedMetadata normalize(RawMetadata raw, + {String fileName = '', String filePath = ''}) { + // 1. 修剪空格 + final title = raw.title.trim(); + final artist = raw.artist.trim(); + final album = raw.album.trim(); + final albumArtist = raw.albumArtist.trim(); + + // 2. 如果 title 为空,尝试从文件名推断 + final finalTitle = + title.isNotEmpty ? title : _inferTitleFromFileName(fileName); + + // 3. 如果 artist 为空,尝试从路径推断或使用默认值 + final finalArtist = + artist.isNotEmpty ? artist : _inferArtistFromPath(filePath); + + return NormalizedMetadata( + title: finalTitle, + artist: finalArtist, + album: album, + albumArtist: albumArtist, + composer: raw.composer.trim(), + genre: raw.genre.trim(), + year: raw.year, + trackNumber: raw.trackNumber, + discNumber: raw.discNumber, + artwork: raw.artwork, + ); + } + + String _inferTitleFromFileName(String fileName) { + // 去掉扩展名 + final dotIndex = fileName.lastIndexOf('.'); + if (dotIndex > 0) { + return fileName.substring(0, dotIndex).trim(); + } + return fileName.trim(); + } + + String _inferArtistFromPath(String filePath) { + // 简单推断:尝试从路径中的文件夹名获取 + final parts = filePath.split(RegExp(r'[/\\]')); + if (parts.length >= 2) { + // 倒数第二个文件夹可能是艺术家 + final candidate = parts[parts.length - 2].trim(); + if (candidate.isNotEmpty && !candidate.contains(' ')) { + return candidate; + } + } + return '未知艺术家'; + } + + /// 评估候选元数据(第一阶段:直接接受,不纠错) + CandidateMetadata evaluate( + NormalizedMetadata normalized, List> history) { + // 第一阶段:置信度固定为 0.8(文件名+标签混合) + return CandidateMetadata( + metadata: normalized, + confidence: 0.8, + evidence: {'normalized': 0.8}, + ); + } + + /// 决策是否修正(第一阶段:直接接受) + FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) { + return FinalMetadata( + title: candidate.metadata.title, + artist: candidate.metadata.artist, + album: candidate.metadata.album, + albumArtist: candidate.metadata.albumArtist, + composer: candidate.metadata.composer, + genre: candidate.metadata.genre, + year: candidate.metadata.year, + trackNumber: candidate.metadata.trackNumber, + discNumber: candidate.metadata.discNumber, + artwork: candidate.metadata.artwork, + source: 'normalized', + ); + } +} diff --git a/lib/metadata/metadata_reader.dart b/lib/metadata/metadata_reader.dart new file mode 100644 index 0000000..955221e --- /dev/null +++ b/lib/metadata/metadata_reader.dart @@ -0,0 +1,30 @@ +// lib/metadata/metadata_reader.dart +import 'dart:io'; +import 'metadata_model.dart'; + +/// 底层元数据读取器(封装 audio_metadata_reader) +class MetadataReader { + /// 从本地文件读取原始元数据 + Future readRawMetadata(File file) async { + try { + // TODO: 安装 audio_metadata_reader 后实现 + // final meta = await amr.readMetadata(file, getImage: false); + // return RawMetadata( + // title: meta.title ?? '', + // artist: meta.artist ?? '', + // album: meta.album ?? '', + // albumArtist: meta.albumArtist ?? '', + // composer: meta.composer ?? '', + // genre: meta.genre ?? '', + // year: meta.year ?? 0, + // trackNumber: meta.trackNumber ?? 0, + // discNumber: meta.discNumber ?? 0, + // ); + + // 临时返回空,等待接入 audio_metadata_reader + return const RawMetadata(); + } catch (e) { + return const RawMetadata(); + } + } +} diff --git a/lib/metadata/metadata_service.dart b/lib/metadata/metadata_service.dart new file mode 100644 index 0000000..328b91a --- /dev/null +++ b/lib/metadata/metadata_service.dart @@ -0,0 +1,74 @@ +// lib/metadata/metadata_service.dart +import 'dart:io'; +import 'metadata_model.dart'; +import 'metadata_reader.dart'; +import 'metadata_normalizer.dart'; +import 'metadata_cache.dart'; + +class MetadataService { + static final MetadataService _instance = MetadataService._internal(); + factory MetadataService() => _instance; + MetadataService._internal(); + + final MetadataReader _reader = MetadataReader(); + final MetadataNormalizer _normalizer = MetadataNormalizer(); + final MetadataCache _cache = MetadataCache(); + + /// 获取歌曲的最终元数据(优先缓存,否则读取文件) + Future getMetadata({ + required String filePath, + required String fileName, + required String fileId, + bool forceRefresh = false, + }) async { + // 1. 尝试从缓存读取 + if (!forceRefresh) { + final cached = await _cache.get(fileId); + if (cached != null && cached.isNotEmpty) { + return cached; + } + } + + // 2. 检查文件是否存在 + final file = File(filePath); + if (!await file.exists()) { + // 文件不存在,返回基于文件名的推断 + return _fallbackFromFileName(fileName); + } + + // 3. 从文件读取原始元数据 + final raw = await _reader.readRawMetadata(file); + + // 4. 标准化 + final normalized = _normalizer.normalize( + raw, + fileName: fileName, + filePath: filePath, + ); + + // 5. 评估(第一阶段直接接受) + final history = await _cache.getHistory(normalized.artist, limit: 10); + final candidate = _normalizer.evaluate(normalized, history); + + // 6. 决策 + final finalMetadata = _normalizer.decide(candidate); + + // 7. 写入缓存 + if (finalMetadata.isNotEmpty) { + await _cache.put(fileId, finalMetadata); + } + + return finalMetadata; + } + + /// Fallback:从文件名推断 + FinalMetadata _fallbackFromFileName(String fileName) { + final dotIndex = fileName.lastIndexOf('.'); + final title = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; + return FinalMetadata( + title: title, + artist: '未知艺术家', + source: 'filename', + ); + } +} diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index ff1c0bc..4e983dd 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'playback_service.dart'; +import '../metadata/metadata_service.dart'; // ⭐ 新增 enum PlayMode { sequential, @@ -162,6 +163,44 @@ class AudioService extends ChangeNotifier { } PlaybackService().play(song.url!); + + // ⭐ 异步加载 metadata(不阻塞播放) + _loadMetadataForCurrentSong(); + } + + // ⭐ 新增:加载当前歌曲的 metadata + Future _loadMetadataForCurrentSong() async { + if (_currentIndex < 0 || _currentIndex >= _queue.length) return; + + final song = _queue[_currentIndex]; + if (song.url == null || song.url!.isEmpty) return; + + try { + // 注意:filePath 需要是本地文件路径 + // WebDAV 文件可能需要先缓存到本地 + final metadata = await MetadataService().getMetadata( + filePath: song.url!, + fileName: song.title, + fileId: song.id, + ); + + // 如果 metadata 有效且与当前不同,更新 Song + if (metadata.isNotEmpty) { + final updatedSong = Song( + id: song.id, + title: metadata.title.isNotEmpty ? metadata.title : song.title, + artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist, + url: song.url, + ); + _queue[_currentIndex] = updatedSong; + _currentSong = updatedSong; + notifyListeners(); + _onSongChanged?.call(updatedSong); + } + } catch (e) { + // 读取失败,保持原有信息 + debugPrint('⚠️ [AudioService] metadata load failed: $e'); + } } // ---- 下一首 ---- @@ -277,7 +316,6 @@ class AudioService extends ChangeNotifier { }), ); - // ⭐ 唯一监听 completed 的地方(带防重入) _subscriptions.add( player.stream.completed.listen((_) { _onPlaybackCompleted(); diff --git a/pubspec.lock b/pubspec.lock index 65a8926..8c5e31d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.13.1" + audio_metadata_reader: + dependency: "direct main" + description: + name: audio_metadata_reader + sha256: "79b08282447dfc4b7ff955f9c4eff824d7284c7b021ccfe8204550d8801a08a5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.7.1" audio_service: dependency: "direct main" description: @@ -73,6 +81,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + charset: + dependency: transitive + description: + name: charset + sha256: "27802032a581e01ac565904ece8c8962564b1070690794f0072f6865958ce8b9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" clock: dependency: transitive description: @@ -224,6 +240,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.8.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.3" jni: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 39757a5..f9c92e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: permission_handler: ^11.3.1 flutter_cache_manager: ^3.3.1 sqflite: ^2.3.0 + audio_metadata_reader: ^1.7.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. From d593ce05a15d24181b927ed77c6dc779c748db0c Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 23 Aug 2026 20:29:31 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E6=AF=94=E8=BE=83=E5=A4=A7=EF=BC=8C=E5=9B=A0=E4=B8=BA?= =?UTF-8?q?metadata=E7=9A=84=E5=BC=95=E5=85=A5=E5=AF=BC=E8=87=B4=E5=8E=9F?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E9=83=A8=E5=88=86=E6=92=AD=E6=94=BE=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E4=BF=A1=E6=81=AF=E6=8E=A8=E9=80=81=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/metadata/file_provider.dart | 28 +++++++ lib/metadata/metadata_cache.dart | 9 -- lib/metadata/metadata_model.dart | 110 +++++++++++++------------ lib/metadata/metadata_normalizer.dart | 38 +++++---- lib/metadata/metadata_reader.dart | 43 ++++++---- lib/metadata/metadata_service.dart | 47 +++-------- lib/pages/player_page.dart | 2 + lib/services/audio_player_handler.dart | 1 + lib/services/audio_service.dart | 92 +++++++++++++-------- 9 files changed, 203 insertions(+), 167 deletions(-) create mode 100644 lib/metadata/file_provider.dart diff --git a/lib/metadata/file_provider.dart b/lib/metadata/file_provider.dart new file mode 100644 index 0000000..d184457 --- /dev/null +++ b/lib/metadata/file_provider.dart @@ -0,0 +1,28 @@ +// lib/metadata/file_provider.dart +import 'dart:io'; + +abstract class FileProvider { + /// 获取文件,如果是远程文件则先下载到本地缓存 + Future getFile(String url); +} + +class LocalFileProvider implements FileProvider { + @override + Future getFile(String url) async { + final file = File(url); + if (await file.exists()) { + return file; + } + return null; + } +} + +class WebDAVFileProvider implements FileProvider { + @override + Future getFile(String url) async { + // 🔴 第一阶段:先不实现下载,返回 null + // 这样 WebDAV 文件会 fallback 到文件名 + // 等核心播放逻辑稳定后,再实现真正的下载缓存 + return null; + } +} diff --git a/lib/metadata/metadata_cache.dart b/lib/metadata/metadata_cache.dart index 80a7dd0..1103bae 100644 --- a/lib/metadata/metadata_cache.dart +++ b/lib/metadata/metadata_cache.dart @@ -1,35 +1,26 @@ // lib/metadata/metadata_cache.dart import 'metadata_model.dart'; -/// 本地缓存接口(先用内存缓存,后续升级为 SQLite) class MetadataCache { - // 内存缓存:Map final Map _cache = {}; - /// 获取缓存 Future get(String fileId) async { return _cache[fileId]; } - /// 存入缓存 Future put(String fileId, FinalMetadata metadata) async { _cache[fileId] = metadata; } - /// 更新缓存(同 put) Future update(String fileId, FinalMetadata metadata) async { _cache[fileId] = metadata; } - /// 根据 artist 获取历史记录(用于证据收集) - /// 实际项目中需要从数据库查询,目前返回空列表 Future>> getHistory(String artist, {int limit = 10}) async { - // TODO: 从 SQLite 查询 return []; } - /// 清空缓存 Future clear() async { _cache.clear(); } diff --git a/lib/metadata/metadata_model.dart b/lib/metadata/metadata_model.dart index fd88358..6a192f8 100644 --- a/lib/metadata/metadata_model.dart +++ b/lib/metadata/metadata_model.dart @@ -1,71 +1,74 @@ // lib/metadata/metadata_model.dart import 'dart:typed_data'; -/// 从音频文件读取的原始元数据(未经任何处理) +/// 从音频文件读取的原始元数据 class RawMetadata { final String title; final String artist; final String album; - final String albumArtist; - final String composer; - final String genre; + final List genres; // ⬅️ 改为 List + final List performers; // ⬅️ 新增 final int year; final int trackNumber; + final int trackTotal; final int discNumber; + final int totalDisc; + final Duration? duration; final Uint8List? artwork; const RawMetadata({ this.title = '', this.artist = '', this.album = '', - this.albumArtist = '', - this.composer = '', - this.genre = '', + this.genres = const [], + this.performers = const [], this.year = 0, this.trackNumber = 0, + this.trackTotal = 0, this.discNumber = 0, + this.totalDisc = 0, + this.duration, this.artwork, }); - /// 判断是否为空(没有任何有效信息) bool get isEmpty => title.isEmpty && artist.isEmpty && album.isEmpty && - albumArtist.isEmpty && - composer.isEmpty && - genre.isEmpty && - year == 0 && - trackNumber == 0 && - discNumber == 0 && - artwork == null; + genres.isEmpty && + performers.isEmpty && + year == 0; bool get isNotEmpty => !isEmpty; } -/// 经过标准化处理后的元数据 +/// 标准化后的元数据 class NormalizedMetadata { final String title; final String artist; final String album; - final String albumArtist; - final String composer; - final String genre; + final List genres; + final List performers; final int year; final int trackNumber; + final int trackTotal; final int discNumber; + final int totalDisc; + final Duration? duration; final Uint8List? artwork; const NormalizedMetadata({ this.title = '', this.artist = '', this.album = '', - this.albumArtist = '', - this.composer = '', - this.genre = '', + this.genres = const [], + this.performers = const [], this.year = 0, this.trackNumber = 0, + this.trackTotal = 0, this.discNumber = 0, + this.totalDisc = 0, + this.duration, this.artwork, }); @@ -73,54 +76,43 @@ class NormalizedMetadata { title.isEmpty && artist.isEmpty && album.isEmpty && - albumArtist.isEmpty && - composer.isEmpty && - genre.isEmpty && - year == 0 && - trackNumber == 0 && - discNumber == 0 && - artwork == null; + genres.isEmpty && + performers.isEmpty && + year == 0; bool get isNotEmpty => !isEmpty; } -/// 带置信度的候选元数据 -class CandidateMetadata { - final NormalizedMetadata metadata; - final double confidence; // 0.0 ~ 1.0 - final Map evidence; // 证据来源权重 - - const CandidateMetadata({ - required this.metadata, - required this.confidence, - this.evidence = const {}, - }); -} - /// 最终确认的元数据 class FinalMetadata { final String title; final String artist; final String album; - final String albumArtist; - final String composer; - final String genre; + final String genre; // ⬅️ 简化为单个流派(取第一个) + final List genres; // ⬅️ 保留完整列表 + final List performers; final int year; final int trackNumber; + final int trackTotal; final int discNumber; + final int totalDisc; + final Duration? duration; final Uint8List? artwork; - final String source; // 'file_tag', 'filename', 'cache', 'user_corrected' + final String source; const FinalMetadata({ this.title = '', this.artist = '', this.album = '', - this.albumArtist = '', - this.composer = '', this.genre = '', + this.genres = const [], + this.performers = const [], this.year = 0, this.trackNumber = 0, + this.trackTotal = 0, this.discNumber = 0, + this.totalDisc = 0, + this.duration, this.artwork, this.source = 'unknown', }); @@ -129,13 +121,23 @@ class FinalMetadata { title.isEmpty && artist.isEmpty && album.isEmpty && - albumArtist.isEmpty && - composer.isEmpty && genre.isEmpty && - year == 0 && - trackNumber == 0 && - discNumber == 0 && - artwork == null; + genres.isEmpty && + performers.isEmpty && + year == 0; bool get isNotEmpty => !isEmpty; } + +/// 候选元数据 +class CandidateMetadata { + final NormalizedMetadata metadata; + final double confidence; + final Map evidence; + + const CandidateMetadata({ + required this.metadata, + required this.confidence, + this.evidence = const {}, + }); +} diff --git a/lib/metadata/metadata_normalizer.dart b/lib/metadata/metadata_normalizer.dart index 0e1a02d..0b6694b 100644 --- a/lib/metadata/metadata_normalizer.dart +++ b/lib/metadata/metadata_normalizer.dart @@ -2,39 +2,43 @@ import 'metadata_model.dart'; class MetadataNormalizer { - /// 标准化原始数据(清洗空格、推断、fallback) NormalizedMetadata normalize(RawMetadata raw, {String fileName = '', String filePath = ''}) { // 1. 修剪空格 final title = raw.title.trim(); final artist = raw.artist.trim(); final album = raw.album.trim(); - final albumArtist = raw.albumArtist.trim(); - // 2. 如果 title 为空,尝试从文件名推断 + // 2. 如果 title 为空,从文件名推断 final finalTitle = title.isNotEmpty ? title : _inferTitleFromFileName(fileName); - // 3. 如果 artist 为空,尝试从路径推断或使用默认值 + // 3. 如果 artist 为空,从路径推断或使用默认值 final finalArtist = artist.isNotEmpty ? artist : _inferArtistFromPath(filePath); + // 4. 如果 performers 不为空且 artist 为空,用 performers 的第一个 + final finalArtist2 = finalArtist.isNotEmpty + ? finalArtist + : (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家'); + return NormalizedMetadata( title: finalTitle, - artist: finalArtist, + artist: finalArtist2, album: album, - albumArtist: albumArtist, - composer: raw.composer.trim(), - genre: raw.genre.trim(), + genres: raw.genres, + performers: raw.performers, year: raw.year, trackNumber: raw.trackNumber, + trackTotal: raw.trackTotal, discNumber: raw.discNumber, + totalDisc: raw.totalDisc, + duration: raw.duration, artwork: raw.artwork, ); } String _inferTitleFromFileName(String fileName) { - // 去掉扩展名 final dotIndex = fileName.lastIndexOf('.'); if (dotIndex > 0) { return fileName.substring(0, dotIndex).trim(); @@ -43,10 +47,8 @@ class MetadataNormalizer { } String _inferArtistFromPath(String filePath) { - // 简单推断:尝试从路径中的文件夹名获取 final parts = filePath.split(RegExp(r'[/\\]')); if (parts.length >= 2) { - // 倒数第二个文件夹可能是艺术家 final candidate = parts[parts.length - 2].trim(); if (candidate.isNotEmpty && !candidate.contains(' ')) { return candidate; @@ -55,10 +57,8 @@ class MetadataNormalizer { return '未知艺术家'; } - /// 评估候选元数据(第一阶段:直接接受,不纠错) CandidateMetadata evaluate( NormalizedMetadata normalized, List> history) { - // 第一阶段:置信度固定为 0.8(文件名+标签混合) return CandidateMetadata( metadata: normalized, confidence: 0.8, @@ -66,18 +66,22 @@ class MetadataNormalizer { ); } - /// 决策是否修正(第一阶段:直接接受) FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) { return FinalMetadata( title: candidate.metadata.title, artist: candidate.metadata.artist, album: candidate.metadata.album, - albumArtist: candidate.metadata.albumArtist, - composer: candidate.metadata.composer, - genre: candidate.metadata.genre, + genre: candidate.metadata.genres.isNotEmpty + ? candidate.metadata.genres.first + : '', + genres: candidate.metadata.genres, + performers: candidate.metadata.performers, year: candidate.metadata.year, trackNumber: candidate.metadata.trackNumber, + trackTotal: candidate.metadata.trackTotal, discNumber: candidate.metadata.discNumber, + totalDisc: candidate.metadata.totalDisc, + duration: candidate.metadata.duration, artwork: candidate.metadata.artwork, source: 'normalized', ); diff --git a/lib/metadata/metadata_reader.dart b/lib/metadata/metadata_reader.dart index 955221e..dc80765 100644 --- a/lib/metadata/metadata_reader.dart +++ b/lib/metadata/metadata_reader.dart @@ -1,30 +1,39 @@ // lib/metadata/metadata_reader.dart import 'dart:io'; +import 'package:audio_metadata_reader/audio_metadata_reader.dart' as amr; import 'metadata_model.dart'; -/// 底层元数据读取器(封装 audio_metadata_reader) class MetadataReader { - /// 从本地文件读取原始元数据 Future readRawMetadata(File file) async { try { - // TODO: 安装 audio_metadata_reader 后实现 - // final meta = await amr.readMetadata(file, getImage: false); - // return RawMetadata( - // title: meta.title ?? '', - // artist: meta.artist ?? '', - // album: meta.album ?? '', - // albumArtist: meta.albumArtist ?? '', - // composer: meta.composer ?? '', - // genre: meta.genre ?? '', - // year: meta.year ?? 0, - // trackNumber: meta.trackNumber ?? 0, - // discNumber: meta.discNumber ?? 0, - // ); + final meta = amr.readMetadata(file, getImage: false); - // 临时返回空,等待接入 audio_metadata_reader - return const RawMetadata(); + return RawMetadata( + title: meta.title ?? '', + artist: meta.artist ?? '', + album: meta.album ?? '', + // ⭐ genres 和 performers 是非空 List,直接使用 + genres: meta.genres, + performers: meta.performers, + year: _toInt(meta.year), + trackNumber: _toInt(meta.trackNumber), + trackTotal: _toInt(meta.trackTotal), + discNumber: _toInt(meta.discNumber), + totalDisc: _toInt(meta.totalDisc), + duration: meta.duration, + ); } catch (e) { return const RawMetadata(); } } + + /// 安全转换为 int,处理 Object 类型 + int _toInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + // 有些字段可能是 double 或 num + if (value is num) return value.toInt(); + return 0; + } } diff --git a/lib/metadata/metadata_service.dart b/lib/metadata/metadata_service.dart index 328b91a..1fef191 100644 --- a/lib/metadata/metadata_service.dart +++ b/lib/metadata/metadata_service.dart @@ -1,8 +1,5 @@ // lib/metadata/metadata_service.dart -import 'dart:io'; import 'metadata_model.dart'; -import 'metadata_reader.dart'; -import 'metadata_normalizer.dart'; import 'metadata_cache.dart'; class MetadataService { @@ -10,18 +7,16 @@ class MetadataService { factory MetadataService() => _instance; MetadataService._internal(); - final MetadataReader _reader = MetadataReader(); - final MetadataNormalizer _normalizer = MetadataNormalizer(); final MetadataCache _cache = MetadataCache(); - /// 获取歌曲的最终元数据(优先缓存,否则读取文件) + /// 临时版本:不进行实际文件读取,只返回基于文件名的 fallback Future getMetadata({ - required String filePath, + required String url, required String fileName, required String fileId, bool forceRefresh = false, }) async { - // 1. 尝试从缓存读取 + // 1. 检查缓存(如果有) if (!forceRefresh) { final cached = await _cache.get(fileId); if (cached != null && cached.isNotEmpty) { @@ -29,39 +24,17 @@ class MetadataService { } } - // 2. 检查文件是否存在 - final file = File(filePath); - if (!await file.exists()) { - // 文件不存在,返回基于文件名的推断 - return _fallbackFromFileName(fileName); + // 2. 暂时不读取文件,直接 fallback + final fallback = _fallbackFromFileName(fileName); + + // 3. 缓存结果(即使 fallback 也缓存,避免频繁调用) + if (fallback.isNotEmpty) { + await _cache.put(fileId, fallback); } - // 3. 从文件读取原始元数据 - final raw = await _reader.readRawMetadata(file); - - // 4. 标准化 - final normalized = _normalizer.normalize( - raw, - fileName: fileName, - filePath: filePath, - ); - - // 5. 评估(第一阶段直接接受) - final history = await _cache.getHistory(normalized.artist, limit: 10); - final candidate = _normalizer.evaluate(normalized, history); - - // 6. 决策 - final finalMetadata = _normalizer.decide(candidate); - - // 7. 写入缓存 - if (finalMetadata.isNotEmpty) { - await _cache.put(fileId, finalMetadata); - } - - return finalMetadata; + return fallback; } - /// Fallback:从文件名推断 FinalMetadata _fallbackFromFileName(String fileName) { final dotIndex = fileName.lastIndexOf('.'); final title = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; diff --git a/lib/pages/player_page.dart b/lib/pages/player_page.dart index f20223a..32386c4 100644 --- a/lib/pages/player_page.dart +++ b/lib/pages/player_page.dart @@ -167,9 +167,11 @@ class _PlayerPageState extends State { } Widget _buildProgressSection(AudioService service, Duration duration) { + debugPrint('🎯 [PlayerPage] duration=$duration'); return ValueListenableBuilder( valueListenable: service.positionNotifier, builder: (context, position, _) { + debugPrint('🎯 [PlayerPage] position=$position, duration=$duration'); final progress = duration.inMilliseconds > 0 ? position.inMilliseconds / duration.inMilliseconds : 0.0; diff --git a/lib/services/audio_player_handler.dart b/lib/services/audio_player_handler.dart index c3e7fb5..5c0784b 100644 --- a/lib/services/audio_player_handler.dart +++ b/lib/services/audio_player_handler.dart @@ -39,6 +39,7 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler { }); _player.durationStream.listen((duration) { + debugPrint('🎯 [durationStream] duration=$duration'); _state.updateDuration(duration); if (_currentId != null && _currentTitle != null) { _updateMediaItem( diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 4e983dd..b69d33b 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'playback_service.dart'; -import '../metadata/metadata_service.dart'; // ⭐ 新增 +import '../metadata/metadata_service.dart'; enum PlayMode { sequential, @@ -30,7 +30,7 @@ class AudioService extends ChangeNotifier { factory AudioService() => _instance; AudioService._internal(); - // ---- 基础状态(低频,触发 UI 重建) ---- + // ---- 基础状态 ---- Song? _currentSong; bool _isPlaying = false; PlayMode _playMode = PlayMode.sequential; @@ -41,7 +41,7 @@ class AudioService extends ChangeNotifier { List _shuffledIndices = []; int _shuffledIndex = -1; - // ---- ⭐ 高频进度(用 ValueNotifier,不触发全局重建) ---- + // ---- 高频进度 ---- final ValueNotifier positionNotifier = ValueNotifier(Duration.zero); final ValueNotifier durationNotifier = ValueNotifier(Duration.zero); final ValueNotifier bufferedNotifier = ValueNotifier(Duration.zero); @@ -49,13 +49,10 @@ class AudioService extends ChangeNotifier { bool _listening = false; final List _subscriptions = []; - // ⭐ 防重入标志 bool _handlingCompletion = false; - - // ⭐ 歌曲切换回调(用于通知 Handler 更新 MediaItem) void Function(Song)? _onSongChanged; - // ---- Getter(高频字段不走 ChangeNotifier) ---- + // ---- Getter ---- Song? get currentSong => _currentSong; bool get isPlaying => _isPlaying; PlayMode get playMode => _playMode; @@ -63,7 +60,6 @@ class AudioService extends ChangeNotifier { int get currentIndex => _currentIndex; bool get hasQueue => _queue.isNotEmpty; - // ---- 兼容旧代码:提供 getter 返回 ValueNotifier 的值 ---- Duration get position => positionNotifier.value; Duration get duration => durationNotifier.value; Duration get bufferedPosition => bufferedNotifier.value; @@ -79,12 +75,10 @@ class AudioService extends ChangeNotifier { } } - // ---- 注册歌曲切换回调 ---- void setOnSongChanged(void Function(Song) callback) { _onSongChanged = callback; } - // ---- 切换播放模式 ---- void togglePlayMode() { switch (_playMode) { case PlayMode.sequential: @@ -100,7 +94,6 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ---- 设置播放队列 ---- void setQueue(List queue, {int startIndex = 0}) { if (queue.isEmpty) { _clearQueue(); @@ -129,7 +122,6 @@ class AudioService extends ChangeNotifier { stopPlay(); } - // ---- 播放指定歌曲 ---- Future playSong(Song song) async { if (_queue.isEmpty || _queue[_currentIndex].id != song.id) { setQueue([song], startIndex: 0); @@ -138,6 +130,9 @@ class AudioService extends ChangeNotifier { } } + // ============================================================ + // 核心播放逻辑 + // ============================================================ void _playCurrent() { if (_currentIndex < 0 || _currentIndex >= _queue.length) { stopPlay(); @@ -147,13 +142,8 @@ class AudioService extends ChangeNotifier { final song = _queue[_currentIndex]; _currentSong = song; - // ⭐ 切歌时触发回调(通知 Handler 更新 MediaItem) - _onSongChanged?.call(song); - - // ⭐ 重置进度(用 ValueNotifier) - positionNotifier.value = Duration.zero; - durationNotifier.value = Duration.zero; - bufferedNotifier.value = Duration.zero; + // ⭐ 不重置任何 Notifier,完全依赖 stream 推送 + // position / duration / buffered 由播放器自然更新 _startListening(); notifyListeners(); @@ -164,27 +154,27 @@ class AudioService extends ChangeNotifier { PlaybackService().play(song.url!); - // ⭐ 异步加载 metadata(不阻塞播放) + // ⭐ 兜底:主动同步一次播放器状态(解决首次加载时 stream 未推送的问题) + _syncPlayerStateDelayed(); + _loadMetadataForCurrentSong(); } - // ⭐ 新增:加载当前歌曲的 metadata + // ============================================================ + // Metadata 加载 + // ============================================================ Future _loadMetadataForCurrentSong() async { if (_currentIndex < 0 || _currentIndex >= _queue.length) return; - final song = _queue[_currentIndex]; if (song.url == null || song.url!.isEmpty) return; try { - // 注意:filePath 需要是本地文件路径 - // WebDAV 文件可能需要先缓存到本地 final metadata = await MetadataService().getMetadata( - filePath: song.url!, + url: song.url!, fileName: song.title, fileId: song.id, ); - // 如果 metadata 有效且与当前不同,更新 Song if (metadata.isNotEmpty) { final updatedSong = Song( id: song.id, @@ -198,12 +188,13 @@ class AudioService extends ChangeNotifier { _onSongChanged?.call(updatedSong); } } catch (e) { - // 读取失败,保持原有信息 debugPrint('⚠️ [AudioService] metadata load failed: $e'); } } - // ---- 下一首 ---- + // ============================================================ + // 播放控制 + // ============================================================ void next() { if (_queue.isEmpty) return; @@ -221,7 +212,6 @@ class AudioService extends ChangeNotifier { _playCurrent(); } - // ---- 上一首 ---- void previous() { if (_queue.isEmpty) return; @@ -247,7 +237,6 @@ class AudioService extends ChangeNotifier { _playCurrent(); } - // ---- 播放/暂停 ---- void togglePlay() { if (_currentSong == null) return; @@ -282,7 +271,38 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ---- 监听 media_kit 状态 ---- + // ============================================================ + // 播放器状态同步(兜底机制) + // ============================================================ + void _syncPlayerStateDelayed() { + _syncPlayerStateNow(); + Future.delayed(const Duration(milliseconds: 200), () { + _syncPlayerStateNow(); + }); + Future.delayed(const Duration(milliseconds: 500), () { + _syncPlayerStateNow(); + }); + } + + void _syncPlayerStateNow() { + final player = PlaybackService().player; + final dur = player.state.duration; + final buf = player.state.buffer; + + if (dur.inMilliseconds > 0 && durationNotifier.value.inMilliseconds == 0) { + durationNotifier.value = dur; + debugPrint('🎯 [AudioService] sync duration: $dur'); + } + + if (buf.inMilliseconds > 0 && bufferedNotifier.value.inMilliseconds == 0) { + bufferedNotifier.value = buf; + debugPrint('🎯 [AudioService] sync buffer: $buf'); + } + } + + // ============================================================ + // media_kit 状态监听 + // ============================================================ void _startListening() { if (_listening) return; _listening = true; @@ -306,7 +326,11 @@ class AudioService extends ChangeNotifier { _subscriptions.add( player.stream.duration.listen((duration) { - durationNotifier.value = duration; + debugPrint('🎯 [AudioService] durationStream: $duration'); + // 只接受 > 0 的值,避免 0 覆盖正确值 + if (duration.inMilliseconds > 0) { + durationNotifier.value = duration; + } }), ); @@ -331,7 +355,9 @@ class AudioService extends ChangeNotifier { _subscriptions.clear(); } - // ⭐ 防重入的完成事件处理 + // ============================================================ + // 播放完成处理 + // ============================================================ void _onPlaybackCompleted() { if (_handlingCompletion) { debugPrint('⚠️ [service] completed ignored: already handling'); From f6eb1b108ac6aa16acb90eb2dc309d11d031e306 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 23 Aug 2026 21:42:37 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=A1=B5=E7=9A=84=E5=AE=9E=E6=97=B6=E6=95=88=E6=9E=9C=E6=9C=89?= =?UTF-8?q?=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player_page.dart | 255 +++++++++++++++++++------------- lib/services/audio_service.dart | 65 ++++---- 2 files changed, 178 insertions(+), 142 deletions(-) diff --git a/lib/pages/player_page.dart b/lib/pages/player_page.dart index 32386c4..68475e9 100644 --- a/lib/pages/player_page.dart +++ b/lib/pages/player_page.dart @@ -43,6 +43,41 @@ class _PlayerPageState extends State { } } + @override + void initState() { + super.initState(); + // ⭐ 页面加载时主动拉取状态(多次重试) + _syncStateWithRetry(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // ⭐ 依赖变化时(如从后台返回)重新同步 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().syncPlayerStateNow(); + } + }); + } + + void _syncStateWithRetry() { + final service = context.read(); + + // 立即同步 + service.syncPlayerStateNow(); + + // 延迟重试 + const delays = [200, 500, 800, 1200]; + for (final delay in delays) { + Future.delayed(Duration(milliseconds: delay), () { + if (mounted) { + service.syncPlayerStateNow(); + } + }); + } + } + @override Widget build(BuildContext context) { final service = context.watch(); @@ -75,7 +110,6 @@ class _PlayerPageState extends State { } final isPlaying = service.isPlaying; - final duration = service.duration; return Scaffold( backgroundColor: const Color(0xFF0E1211), @@ -156,7 +190,7 @@ class _PlayerPageState extends State { ], ), const SizedBox(height: 40), - _buildProgressSection(service, duration), + _buildProgressSection(service), const SizedBox(height: 32), _buildControlButtons(service, isPlaying), const SizedBox(height: 16), @@ -166,120 +200,137 @@ class _PlayerPageState extends State { ); } - Widget _buildProgressSection(AudioService service, Duration duration) { - debugPrint('🎯 [PlayerPage] duration=$duration'); + Widget _buildProgressSection(AudioService service) { return ValueListenableBuilder( valueListenable: service.positionNotifier, builder: (context, position, _) { - debugPrint('🎯 [PlayerPage] position=$position, duration=$duration'); - final progress = duration.inMilliseconds > 0 - ? position.inMilliseconds / duration.inMilliseconds - : 0.0; + return ValueListenableBuilder( + valueListenable: service.durationNotifier, + builder: (context, duration, _) { + // ⭐ 如果 duration 为 0,显示加载指示器 + if (duration.inMilliseconds == 0) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator( + color: Color(0xFFB8D4D0), + backgroundColor: Color(0xFF2A3332), + ), + ); + } - final bufferProgress = duration.inMilliseconds > 0 - ? (service.bufferedNotifier.value.inMilliseconds / - duration.inMilliseconds) - .clamp(0.0, 1.0) - : 0.0; + final progress = duration.inMilliseconds > 0 + ? position.inMilliseconds / duration.inMilliseconds + : 0.0; - final displayProgress = _dragProgress ?? progress; + final bufferProgress = duration.inMilliseconds > 0 + ? (service.bufferedNotifier.value.inMilliseconds / + duration.inMilliseconds) + .clamp(0.0, 1.0) + : 0.0; - return Column( - children: [ - SizedBox( - height: 20, - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final bufferWidth = width * bufferProgress.clamp(0.0, 1.0); - final progressWidth = width * displayProgress.clamp(0.0, 1.0); + final displayProgress = _dragProgress ?? progress; - return Stack( - alignment: Alignment.centerLeft, - children: [ - Container( - width: width, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[800], - borderRadius: BorderRadius.circular(2), - ), - ), - Container( - width: bufferWidth, - height: 4, - decoration: BoxDecoration( - color: const Color(0xFF4A7A7A), - borderRadius: BorderRadius.circular(2), - ), - ), - Container( - width: progressWidth, - height: 4, - decoration: BoxDecoration( - color: const Color(0xFFB8D4D0), - borderRadius: BorderRadius.circular(2), - ), - ), - Positioned.fill( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 0, - activeTrackColor: Colors.transparent, - inactiveTrackColor: Colors.transparent, - thumbColor: Colors.transparent, - overlayColor: Colors.transparent, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 0, + return Column( + children: [ + SizedBox( + height: 20, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final bufferWidth = + width * bufferProgress.clamp(0.0, 1.0); + final progressWidth = + width * displayProgress.clamp(0.0, 1.0); + + return Stack( + alignment: Alignment.centerLeft, + children: [ + Container( + width: width, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(2), ), ), - child: Slider( - value: displayProgress.clamp(0.0, 1.0), - min: 0.0, - max: 1.0, - onChanged: (value) { - setState(() { - _dragProgress = value; - }); - }, - onChangeEnd: (value) { - final newPosition = Duration( - milliseconds: - (value * duration.inMilliseconds).round(), - ); - service.seekTo(newPosition); - setState(() { - _dragProgress = null; - }); - }, + Container( + width: bufferWidth, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFF4A7A7A), + borderRadius: BorderRadius.circular(2), + ), ), - ), - ), - ], - ); - }, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(position), - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], + Container( + width: progressWidth, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFB8D4D0), + borderRadius: BorderRadius.circular(2), + ), + ), + Positioned.fill( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 0, + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + thumbColor: Colors.transparent, + overlayColor: Colors.transparent, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 0, + ), + ), + child: Slider( + value: displayProgress.clamp(0.0, 1.0), + min: 0.0, + max: 1.0, + onChanged: (value) { + setState(() { + _dragProgress = value; + }); + }, + onChangeEnd: (value) { + final newPosition = Duration( + milliseconds: + (value * duration.inMilliseconds) + .round(), + ); + service.seekTo(newPosition); + setState(() { + _dragProgress = null; + }); + }, + ), + ), + ), + ], + ); + }, ), ), - Text( - _formatDuration(duration), - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + Text( + _formatDuration(duration), + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + ], ), ], - ), - ], + ); + }, ); }, ); diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index b69d33b..329af3e 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -130,9 +130,6 @@ class AudioService extends ChangeNotifier { } } - // ============================================================ - // 核心播放逻辑 - // ============================================================ void _playCurrent() { if (_currentIndex < 0 || _currentIndex >= _queue.length) { stopPlay(); @@ -142,9 +139,6 @@ class AudioService extends ChangeNotifier { final song = _queue[_currentIndex]; _currentSong = song; - // ⭐ 不重置任何 Notifier,完全依赖 stream 推送 - // position / duration / buffered 由播放器自然更新 - _startListening(); notifyListeners(); @@ -154,15 +148,12 @@ class AudioService extends ChangeNotifier { PlaybackService().play(song.url!); - // ⭐ 兜底:主动同步一次播放器状态(解决首次加载时 stream 未推送的问题) + // ⭐ 延迟同步兜底 _syncPlayerStateDelayed(); _loadMetadataForCurrentSong(); } - // ============================================================ - // Metadata 加载 - // ============================================================ Future _loadMetadataForCurrentSong() async { if (_currentIndex < 0 || _currentIndex >= _queue.length) return; final song = _queue[_currentIndex]; @@ -192,9 +183,6 @@ class AudioService extends ChangeNotifier { } } - // ============================================================ - // 播放控制 - // ============================================================ void next() { if (_queue.isEmpty) return; @@ -271,38 +259,39 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ============================================================ - // 播放器状态同步(兜底机制) - // ============================================================ - void _syncPlayerStateDelayed() { - _syncPlayerStateNow(); - Future.delayed(const Duration(milliseconds: 200), () { - _syncPlayerStateNow(); - }); - Future.delayed(const Duration(milliseconds: 500), () { - _syncPlayerStateNow(); - }); - } - - void _syncPlayerStateNow() { + // ⭐ 公开方法:供 PlayerPage 主动同步 + void syncPlayerStateNow() { final player = PlaybackService().player; + final pos = player.state.position; final dur = player.state.duration; final buf = player.state.buffer; - if (dur.inMilliseconds > 0 && durationNotifier.value.inMilliseconds == 0) { - durationNotifier.value = dur; - debugPrint('🎯 [AudioService] sync duration: $dur'); - } + debugPrint( + '🎯 [AudioService] syncPlayerStateNow: pos=$pos, dur=$dur, buf=$buf'); - if (buf.inMilliseconds > 0 && bufferedNotifier.value.inMilliseconds == 0) { + // ⭐ 无条件更新(不要只判断 == 0) + if (pos.inMilliseconds >= 0) { + positionNotifier.value = pos; + } + if (dur.inMilliseconds > 0) { + durationNotifier.value = dur; + } + if (buf.inMilliseconds >= 0) { bufferedNotifier.value = buf; - debugPrint('🎯 [AudioService] sync buffer: $buf'); } } - // ============================================================ - // media_kit 状态监听 - // ============================================================ + void _syncPlayerStateDelayed() { + syncPlayerStateNow(); + Future.delayed(const Duration(milliseconds: 200), () { + syncPlayerStateNow(); + }); + Future.delayed(const Duration(milliseconds: 500), () { + syncPlayerStateNow(); + }); + } + + // ---- 监听 ---- void _startListening() { if (_listening) return; _listening = true; @@ -327,7 +316,6 @@ class AudioService extends ChangeNotifier { _subscriptions.add( player.stream.duration.listen((duration) { debugPrint('🎯 [AudioService] durationStream: $duration'); - // 只接受 > 0 的值,避免 0 覆盖正确值 if (duration.inMilliseconds > 0) { durationNotifier.value = duration; } @@ -355,9 +343,6 @@ class AudioService extends ChangeNotifier { _subscriptions.clear(); } - // ============================================================ - // 播放完成处理 - // ============================================================ void _onPlaybackCompleted() { if (_handlingCompletion) { debugPrint('⚠️ [service] completed ignored: already handling');