From 9a60441e04235fde8721dfdc8f6b9bc13b424659 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 23 Aug 2026 19:24:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=AE=9E=E7=8E=B0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=A1=86=E6=9E=B6=E5=85=88=E4=B8=8A=EF=BC=8C=E5=90=8E?= =?UTF-8?q?=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.