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/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 new file mode 100644 index 0000000..1103bae --- /dev/null +++ b/lib/metadata/metadata_cache.dart @@ -0,0 +1,27 @@ +// lib/metadata/metadata_cache.dart +import 'metadata_model.dart'; + +class MetadataCache { + final Map _cache = {}; + + Future get(String fileId) async { + return _cache[fileId]; + } + + Future put(String fileId, FinalMetadata metadata) async { + _cache[fileId] = metadata; + } + + Future update(String fileId, FinalMetadata metadata) async { + _cache[fileId] = metadata; + } + + Future>> getHistory(String artist, + {int limit = 10}) async { + 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..6a192f8 --- /dev/null +++ b/lib/metadata/metadata_model.dart @@ -0,0 +1,143 @@ +// lib/metadata/metadata_model.dart +import 'dart:typed_data'; + +/// 从音频文件读取的原始元数据 +class RawMetadata { + final String title; + final String artist; + final String album; + 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.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 && + genres.isEmpty && + performers.isEmpty && + year == 0; + + bool get isNotEmpty => !isEmpty; +} + +/// 标准化后的元数据 +class NormalizedMetadata { + final String title; + final String artist; + final String album; + 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.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 && + genres.isEmpty && + performers.isEmpty && + year == 0; + + bool get isNotEmpty => !isEmpty; +} + +/// 最终确认的元数据 +class FinalMetadata { + final String title; + final String artist; + final String album; + 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; + + const FinalMetadata({ + this.title = '', + this.artist = '', + this.album = '', + 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', + }); + + bool get isEmpty => + title.isEmpty && + artist.isEmpty && + album.isEmpty && + genre.isEmpty && + 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 new file mode 100644 index 0000000..0b6694b --- /dev/null +++ b/lib/metadata/metadata_normalizer.dart @@ -0,0 +1,89 @@ +// lib/metadata/metadata_normalizer.dart +import 'metadata_model.dart'; + +class MetadataNormalizer { + NormalizedMetadata normalize(RawMetadata raw, + {String fileName = '', String filePath = ''}) { + // 1. 修剪空格 + final title = raw.title.trim(); + final artist = raw.artist.trim(); + final album = raw.album.trim(); + + // 2. 如果 title 为空,从文件名推断 + final finalTitle = + title.isNotEmpty ? title : _inferTitleFromFileName(fileName); + + // 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: finalArtist2, + album: album, + 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(); + } + 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) { + 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, + 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 new file mode 100644 index 0000000..dc80765 --- /dev/null +++ b/lib/metadata/metadata_reader.dart @@ -0,0 +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'; + +class MetadataReader { + Future readRawMetadata(File file) async { + try { + final meta = amr.readMetadata(file, getImage: false); + + 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 new file mode 100644 index 0000000..1fef191 --- /dev/null +++ b/lib/metadata/metadata_service.dart @@ -0,0 +1,47 @@ +// lib/metadata/metadata_service.dart +import 'metadata_model.dart'; +import 'metadata_cache.dart'; + +class MetadataService { + static final MetadataService _instance = MetadataService._internal(); + factory MetadataService() => _instance; + MetadataService._internal(); + + final MetadataCache _cache = MetadataCache(); + + /// 临时版本:不进行实际文件读取,只返回基于文件名的 fallback + Future getMetadata({ + required String url, + 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. 暂时不读取文件,直接 fallback + final fallback = _fallbackFromFileName(fileName); + + // 3. 缓存结果(即使 fallback 也缓存,避免频繁调用) + if (fallback.isNotEmpty) { + await _cache.put(fileId, fallback); + } + + return 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/pages/player_page.dart b/lib/pages/player_page.dart index f20223a..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,118 +200,137 @@ class _PlayerPageState extends State { ); } - Widget _buildProgressSection(AudioService service, Duration duration) { + Widget _buildProgressSection(AudioService service) { return ValueListenableBuilder( valueListenable: service.positionNotifier, builder: (context, position, _) { - 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/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'; 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 ff1c0bc..329af3e 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, @@ -29,7 +30,7 @@ class AudioService extends ChangeNotifier { factory AudioService() => _instance; AudioService._internal(); - // ---- 基础状态(低频,触发 UI 重建) ---- + // ---- 基础状态 ---- Song? _currentSong; bool _isPlaying = false; PlayMode _playMode = PlayMode.sequential; @@ -40,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); @@ -48,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; @@ -62,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; @@ -78,12 +75,10 @@ class AudioService extends ChangeNotifier { } } - // ---- 注册歌曲切换回调 ---- void setOnSongChanged(void Function(Song) callback) { _onSongChanged = callback; } - // ---- 切换播放模式 ---- void togglePlayMode() { switch (_playMode) { case PlayMode.sequential: @@ -99,7 +94,6 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ---- 设置播放队列 ---- void setQueue(List queue, {int startIndex = 0}) { if (queue.isEmpty) { _clearQueue(); @@ -128,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); @@ -146,14 +139,6 @@ 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; - _startListening(); notifyListeners(); @@ -162,9 +147,42 @@ class AudioService extends ChangeNotifier { } PlaybackService().play(song.url!); + + // ⭐ 延迟同步兜底 + _syncPlayerStateDelayed(); + + _loadMetadataForCurrentSong(); + } + + Future _loadMetadataForCurrentSong() async { + if (_currentIndex < 0 || _currentIndex >= _queue.length) return; + final song = _queue[_currentIndex]; + if (song.url == null || song.url!.isEmpty) return; + + try { + final metadata = await MetadataService().getMetadata( + url: song.url!, + fileName: song.title, + fileId: song.id, + ); + + 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'); + } } - // ---- 下一首 ---- void next() { if (_queue.isEmpty) return; @@ -182,7 +200,6 @@ class AudioService extends ChangeNotifier { _playCurrent(); } - // ---- 上一首 ---- void previous() { if (_queue.isEmpty) return; @@ -208,7 +225,6 @@ class AudioService extends ChangeNotifier { _playCurrent(); } - // ---- 播放/暂停 ---- void togglePlay() { if (_currentSong == null) return; @@ -243,7 +259,39 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ---- 监听 media_kit 状态 ---- + // ⭐ 公开方法:供 PlayerPage 主动同步 + void syncPlayerStateNow() { + final player = PlaybackService().player; + final pos = player.state.position; + final dur = player.state.duration; + final buf = player.state.buffer; + + debugPrint( + '🎯 [AudioService] syncPlayerStateNow: pos=$pos, dur=$dur, buf=$buf'); + + // ⭐ 无条件更新(不要只判断 == 0) + if (pos.inMilliseconds >= 0) { + positionNotifier.value = pos; + } + if (dur.inMilliseconds > 0) { + durationNotifier.value = dur; + } + if (buf.inMilliseconds >= 0) { + bufferedNotifier.value = buf; + } + } + + void _syncPlayerStateDelayed() { + syncPlayerStateNow(); + Future.delayed(const Duration(milliseconds: 200), () { + syncPlayerStateNow(); + }); + Future.delayed(const Duration(milliseconds: 500), () { + syncPlayerStateNow(); + }); + } + + // ---- 监听 ---- void _startListening() { if (_listening) return; _listening = true; @@ -267,7 +315,10 @@ class AudioService extends ChangeNotifier { _subscriptions.add( player.stream.duration.listen((duration) { - durationNotifier.value = duration; + debugPrint('🎯 [AudioService] durationStream: $duration'); + if (duration.inMilliseconds > 0) { + durationNotifier.value = duration; + } }), ); @@ -277,7 +328,6 @@ class AudioService extends ChangeNotifier { }), ); - // ⭐ 唯一监听 completed 的地方(带防重入) _subscriptions.add( player.stream.completed.listen((_) { _onPlaybackCompleted(); @@ -293,7 +343,6 @@ class AudioService extends ChangeNotifier { _subscriptions.clear(); } - // ⭐ 防重入的完成事件处理 void _onPlaybackCompleted() { if (_handlingCompletion) { debugPrint('⚠️ [service] completed ignored: already handling'); 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.