From f2b4d1f46d456011a951f1247f6748b929a8b85e Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Tue, 25 Aug 2026 23:09:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=B8=AD=E5=BF=83=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E5=9B=BE=E6=98=BE=E7=A4=BA=20=E9=83=A8=E5=88=86?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=9A=84=E4=BF=A1=E6=81=AF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20=E7=BC=93=E5=AD=98=E7=B3=BB=E7=BB=9F=E5=88=9D=E6=AD=A5?= =?UTF-8?q?=E5=BC=95=E5=85=A5=20=E5=8D=B3=E5=B0=86=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=88=97=E8=A1=A8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 59 +++--- .../com/example/qt_player/MainActivity.kt | 45 +++- android/app/src/main/res/xml/file_paths.xml | 11 + lib/database/song_database.dart | 12 ++ lib/main.dart | 3 + lib/metadata/metadata_cache.dart | 4 + lib/metadata/metadata_reader.dart | 21 +- lib/metadata/metadata_service.dart | 17 +- lib/pages/player_page.dart | 29 ++- lib/services/audio_player_handler.dart | 200 ++++++++++-------- lib/services/audio_service.dart | 103 ++++++--- lib/utils/artwork_helper.dart | 5 +- lib/utils/file_provider_utils.dart | 55 +++++ lib/widgets/global_mini_player.dart | 39 ++-- pubspec.lock | 10 +- pubspec.yaml | 2 + 16 files changed, 454 insertions(+), 161 deletions(-) create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 lib/utils/file_provider_utils.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9c4617e..1abda98 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,51 +1,50 @@ - - - + + + - + - + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + - - + + + android:exported="true" + android:foregroundServiceType="mediaPlayback"> - - - - + + @@ -53,16 +52,28 @@ - + + + + + + - + - - + + + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt b/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt index dd7819e..8097b5c 100644 --- a/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt @@ -3,9 +3,50 @@ package com.lxh.qingting_player import android.os.Build import android.os.Bundle import android.util.Log -import com.ryanheise.audioservice.AudioServiceActivity // ⭐ 改用这个 +import androidx.core.content.FileProvider +import com.ryanheise.audioservice.AudioServiceActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File -class MainActivity : AudioServiceActivity() { // ⭐ 继承 AudioServiceActivity +class MainActivity : AudioServiceActivity() { + + private val CHANNEL = "com.lxh.qingting_player/file_provider" + + // ⭐ 关键:注册 MethodChannel + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + if (call.method == "getContentUri") { + val path = call.argument("path") + if (path != null && path.isNotEmpty()) { + try { + val file = File(path) + if (!file.exists()) { + result.error("FILE_NOT_FOUND", "File does not exist: $path", null) + return@setMethodCallHandler + } + val uri = FileProvider.getUriForFile( + this, + "${packageName}.fileprovider", + file + ) + Log.d("QTPlayer", "📢 [FileProvider] content URI: $uri") + result.success(uri.toString()) + } catch (e: Exception) { + Log.e("QTPlayer", "❌ [FileProvider] error: ${e.message}") + result.error("ERROR", e.message, null) + } + } else { + result.error("INVALID_PATH", "path is null or empty", null) + } + } else { + result.notImplemented() + } + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..7367fc9 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/lib/database/song_database.dart b/lib/database/song_database.dart index 237cd74..af86cb2 100644 --- a/lib/database/song_database.dart +++ b/lib/database/song_database.dart @@ -98,6 +98,18 @@ class SongDatabase { ); } + // ---- 删除 ---- + Future deleteSong(String songKey) async { + final db = await database; + await db.delete('songs', where: 'song_key = ?', whereArgs: [songKey]); + } + + Future deleteCache(String songKey) async { + final db = await database; + await db + .delete('metadata_cache', where: 'song_key = ?', whereArgs: [songKey]); + } + // ---- 缓存 ---- Future insertCache(Map cache) async { final db = await database; diff --git a/lib/main.dart b/lib/main.dart index 8ab6e0d..4cbbf16 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,10 +54,13 @@ void main() async { // ⭐ 注册切歌回调:当歌曲切换时,立即更新通知 AudioService().setOnSongChanged((song) { + debugPrint( + '📢 [main] song.artwork is ${song.artwork != null ? 'not null' : 'null'}'); _audioHandler!.updateNotification( id: song.id, title: song.title, artist: song.artist, + artwork: song.artwork, // ⭐ 传递封面图 ); }); diff --git a/lib/metadata/metadata_cache.dart b/lib/metadata/metadata_cache.dart index 1103bae..685c89a 100644 --- a/lib/metadata/metadata_cache.dart +++ b/lib/metadata/metadata_cache.dart @@ -16,6 +16,10 @@ class MetadataCache { _cache[fileId] = metadata; } + Future remove(String fileId) async { + _cache.remove(fileId); + } + Future>> getHistory(String artist, {int limit = 10}) async { return []; diff --git a/lib/metadata/metadata_reader.dart b/lib/metadata/metadata_reader.dart index 2d579c3..c151868 100644 --- a/lib/metadata/metadata_reader.dart +++ b/lib/metadata/metadata_reader.dart @@ -1,5 +1,6 @@ // lib/metadata/metadata_reader.dart import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:audio_metadata_reader/audio_metadata_reader.dart' as amr; import 'metadata_model.dart'; @@ -7,13 +8,25 @@ import 'metadata_model.dart'; class MetadataReader { Future readRawMetadata(File file) async { try { - // ⭐ getImage: true 读取封面图 final meta = amr.readMetadata(file, getImage: true); - // ⭐ 提取第一张图片 + debugPrint('📢 [MetadataReader] pictures count: ${meta.pictures.length}'); + Uint8List? artwork; - if (meta.pictures != null && meta.pictures!.isNotEmpty) { - artwork = meta.pictures.first.bytes; + if (meta.pictures.isNotEmpty) { + try { + final pic = meta.pictures.first; + // 尝试获取图片数据,兼容不同字段名 + artwork = (pic as dynamic).bytes ?? (pic as dynamic).data; + if (artwork != null) { + debugPrint( + '📢 [MetadataReader] artwork extracted: ${artwork.length} bytes'); + } + } catch (e) { + debugPrint('⚠️ [MetadataReader] artwork extraction failed: $e'); + } + } else { + debugPrint('📢 [MetadataReader] no artwork found'); } return RawMetadata( diff --git a/lib/metadata/metadata_service.dart b/lib/metadata/metadata_service.dart index 23bf673..0039466 100644 --- a/lib/metadata/metadata_service.dart +++ b/lib/metadata/metadata_service.dart @@ -1,6 +1,6 @@ // lib/metadata/metadata_service.dart -import 'dart:io'; // ⭐ 添加这一行 -import 'dart:typed_data'; // ⭐ 如果已经有更好 +import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import '../database/song_database.dart'; import 'metadata_model.dart'; @@ -32,6 +32,17 @@ class MetadataService { return LocalFileProvider(); } + /// 公开给 AudioService 使用(用于清除缓存时获取文件) + FileProvider getProviderForUrl(String url) { + return _getProvider(url); + } + + /// 清除内存缓存 + Future clearCache(String fileId) async { + await _memoryCache.remove(fileId); + debugPrint('🗑️ [MetadataService] memory cache cleared: $fileId'); + } + Future getMetadata({ required String url, required String fileName, @@ -111,6 +122,8 @@ class MetadataService { 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; diff --git a/lib/pages/player_page.dart b/lib/pages/player_page.dart index c629ee6..b8eeabc 100644 --- a/lib/pages/player_page.dart +++ b/lib/pages/player_page.dart @@ -130,9 +130,34 @@ class _PlayerPageState extends State { ), centerTitle: true, actions: [ - IconButton( + PopupMenuButton( icon: const Icon(Icons.more_vert, color: Colors.white54), - onPressed: () {}, + onSelected: (value) async { + if (value == 'clear_cache') { + final service = context.read(); + await service.clearCurrentSongCache(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('缓存已清除,重新加载中...'), + backgroundColor: Colors.orange, + ), + ); + } + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'clear_cache', + child: Row( + children: [ + Icon(Icons.cleaning_services, color: Colors.redAccent), + SizedBox(width: 12), + Text('清除缓存'), + ], + ), + ), + ], ), ], ), diff --git a/lib/services/audio_player_handler.dart b/lib/services/audio_player_handler.dart index 5a91f56..6ed2375 100644 --- a/lib/services/audio_player_handler.dart +++ b/lib/services/audio_player_handler.dart @@ -1,11 +1,15 @@ // lib/services/audio_player_handler.dart -import 'dart:io'; // ⭐ 添加 -import 'dart:typed_data'; // ⭐ 添加 +import 'dart:io'; +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'; class AudioPlayerHandler extends audio_service.BaseAudioHandler { final PlayerController _player = PlayerController(); @@ -15,18 +19,19 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler { String? _currentTitle; String? _currentArtist; - // ⭐ 唯一的位置变量 Duration _currentPosition = Duration.zero; DateTime _lastPublishTime = DateTime.now(); static const Duration _publishInterval = Duration(milliseconds: 500); + // ⭐ 缓存 artwork 文件路径,避免重复写入 + String? _currentArtworkPath; + AudioPlayerHandler() { _player.playingStream.listen((playing) { _state.updatePlaying(playing); _publishState(); }); - // ⭐ 位置更新:直接赋值给 _currentPosition _player.positionStream.listen((position) { _currentPosition = position; _state.updatePosition(position); @@ -34,7 +39,7 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler { final now = DateTime.now(); if (now.difference(_lastPublishTime) >= _publishInterval) { _lastPublishTime = now; - _updateMediaItemPosition(position); + // ⭐ 只保留 _publishStateOnly() _publishStateOnly(); } }); @@ -54,6 +59,7 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler { }); } + // ---- 发布状态 ---- void _publishState() { final state = _state.playbackState; playbackState.add(audio_service.PlaybackState( @@ -71,31 +77,112 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler { void _publishStateOnly() { final current = playbackState.value; - debugPrint( - '📡 [publishStateOnly] position=$_currentPosition, playing=${current.playing}', - ); + '📡 [publishStateOnly] position=$_currentPosition, playing=${current.playing}'); - playbackState.add( - audio_service.PlaybackState( - controls: current.controls.isNotEmpty - ? current.controls - : _state.playbackState.controls, - processingState: _state.playbackState.processingState, - playing: current.playing, - androidCompactActionIndices: current.androidCompactActionIndices, - - // ⭐ 声明支持 seek - systemActions: const { - audio_service.MediaAction.seek, - }, - - updatePosition: _currentPosition, - updateTime: DateTime.now(), - ), - ); + 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, + }, + )); } + // ---- 更新媒体信息 ---- + void _updateMediaItem({ + required String id, + required String title, + required String artist, + 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'); + + // ⭐ 异步处理 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, + artist: current.artist, + duration: current.duration, + artUri: artUri, + extras: current.extras, + )); + } + }); + + // 先推送不带封面图的 MediaItem(让 UI 尽快显示) + mediaItem.add(audio_service.MediaItem( + id: id, + title: title, + artist: artist, + duration: duration ?? _player.duration, + extras: {'position': position.inMilliseconds}, + )); + } + + /// 处理封面图:保存到本地并生成 content URI + Future _handleArtwork(String id, Uint8List? artwork) async { + if (artwork == null || artwork.isEmpty) { + _currentArtworkPath = null; + 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 && + existingBytes.hashCode == artwork.hashCode) { + _currentArtworkPath = path; + 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'); + return null; + } + } + + // ---- 外部接口 ---- void updateNotification({ required String id, required String title, @@ -108,66 +195,10 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler { artist: artist, artwork: artwork, ); + _publishState(); } - void _updateMediaItem({ - required String id, - required String title, - required String artist, - Duration? duration, - Uint8List? artwork, - }) { - _currentId = id; - _currentTitle = title; - _currentArtist = artist; - final position = _player.position; - - debugPrint('📢 [handler] updateMediaItem: $title - $artist'); - - // ⭐ 如果有 artwork,保存为临时文件并设置 artUri - Uri? artUri; - if (artwork != null && artwork.isNotEmpty) { - try { - final tempDir = Directory.systemTemp; - final artPath = '${tempDir.path}/art_${id.hashCode}.jpg'; - final artFile = File(artPath); - artFile.writeAsBytesSync(artwork); - artUri = Uri.file(artPath); - debugPrint('📢 [handler] artwork saved: $artPath'); - } catch (e) { - debugPrint('⚠️ [handler] save artwork failed: $e'); - } - } - - mediaItem.add(audio_service.MediaItem( - id: id, - title: title, - artist: artist, - duration: duration ?? _player.duration, - artUri: artUri, - extras: { - 'position': position.inMilliseconds, - }, - )); - } - - void _updateMediaItemPosition(Duration position) { - final currentMediaItem = mediaItem.value; - if (currentMediaItem != null) { - mediaItem.add(audio_service.MediaItem( - id: currentMediaItem.id, - title: currentMediaItem.title, - artist: currentMediaItem.artist, - duration: currentMediaItem.duration, - artUri: currentMediaItem.artUri, - extras: { - 'position': position.inMilliseconds, - ...?currentMediaItem.extras, - }, - )); - } - } - + // ---- 控制命令 ---- @override Future play() async { debugPrint('▶️ [handler] play() called'); @@ -194,7 +225,6 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler { debugPrint('⏩ [handler] seek() called: $position'); await _player.seek(position); _currentPosition = position; - _updateMediaItemPosition(position); _publishStateOnly(); } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index cea1f84..121e784 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -1,10 +1,13 @@ // lib/services/audio_service.dart import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; +import 'package:path_provider/path_provider.dart'; import 'playback_service.dart'; import '../metadata/metadata_service.dart'; -import 'dart:typed_data'; +import '../database/song_database.dart'; +import '../utils/artwork_helper.dart'; enum PlayMode { sequential, @@ -44,7 +47,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); @@ -55,7 +58,6 @@ class AudioService extends ChangeNotifier { bool _handlingCompletion = false; void Function(Song)? _onSongChanged; - // ⭐ 防 seek 覆盖标志 bool _isUserSeeking = false; // ---- Getter ---- @@ -81,12 +83,10 @@ class AudioService extends ChangeNotifier { } } - // ---- 注册回调 ---- void setOnSongChanged(void Function(Song) callback) { _onSongChanged = callback; } - // ---- 切换播放模式 ---- void togglePlayMode() { switch (_playMode) { case PlayMode.sequential: @@ -102,7 +102,6 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ---- 设置播放队列 ---- void setQueue(List queue, {int startIndex = 0}) { if (queue.isEmpty) { _clearQueue(); @@ -131,7 +130,6 @@ class AudioService extends ChangeNotifier { stopPlay(); } - // ---- 播放指定歌曲 ---- Future playSong(Song song) async { if (_queue.isEmpty || _queue[_currentIndex].id != song.id) { setQueue([song], startIndex: 0); @@ -140,7 +138,6 @@ class AudioService extends ChangeNotifier { } } - // ---- 核心:播放当前歌曲 ---- void _playCurrent() { if (_currentIndex < 0 || _currentIndex >= _queue.length) { stopPlay(); @@ -159,13 +156,16 @@ class AudioService extends ChangeNotifier { PlaybackService().play(song.url!); - // 延迟同步兜底(解决首次加载时 stream 未推送的问题) _syncPlayerStateDelayed(); _loadMetadataForCurrentSong(); } - // ---- 加载 metadata ---- + String _generateSongKey(String url, int fileSize, int modifiedTime) { + final raw = '$url|$fileSize|$modifiedTime'; + return raw.hashCode.toString(); + } + Future _loadMetadataForCurrentSong() async { if (_currentIndex < 0 || _currentIndex >= _queue.length) return; final song = _queue[_currentIndex]; @@ -184,12 +184,13 @@ class AudioService extends ChangeNotifier { title: metadata.title.isNotEmpty ? metadata.title : song.title, artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist, url: song.url, - artwork: metadata.artwork, - // ⭐ 如果需要传递 artwork,可以在这里添加字段 + artwork: metadata.artwork, // ✅ 已有 ); _queue[_currentIndex] = updatedSong; _currentSong = updatedSong; notifyListeners(); + + // ⭐ 传递完整的 updatedSong(包含 artwork) _onSongChanged?.call(updatedSong); } } catch (e) { @@ -197,7 +198,70 @@ class AudioService extends ChangeNotifier { } } - // ---- 下一首 ---- + /// 清除当前歌曲的缓存(metadata + 封面图 + 文件缓存),并强制重新播放 + Future clearCurrentSongCache() async { + if (_currentSong == null) return; + final song = _currentSong!; + final url = song.url ?? ''; + if (url.isEmpty) return; + + debugPrint('🗑️ [AudioService] clearing cache for: ${song.title}'); + + // 1. 获取当前索引 + final currentIndex = _currentIndex; + + // 2. 尝试获取文件信息生成 song_key + String songKey; + try { + final provider = MetadataService().getProviderForUrl(url); + 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); + } else { + songKey = url.hashCode.toString(); + } + } catch (e) { + songKey = url.hashCode.toString(); + } + + // 3. 清除 SQLite 记录 + final db = SongDatabase(); + await db.deleteSong(songKey); + await db.deleteCache(songKey); + debugPrint('🗑️ [AudioService] SQLite records deleted: $songKey'); + + // 4. 删除封面图 + await ArtworkHelper.deleteArtwork(songKey); + debugPrint('🗑️ [AudioService] artwork deleted'); + + // 5. 删除 metadata 临时缓存文件 + try { + final cacheDir = await getTemporaryDirectory(); + final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp'; + final cacheFile = File(cachePath); + if (await cacheFile.exists()) { + await cacheFile.delete(); + debugPrint('🗑️ [AudioService] temp cache file deleted'); + } + } catch (e) { + // 忽略 + } + + // 6. 清除内存缓存 + await MetadataService().clearCache(song.id); + debugPrint('🗑️ [AudioService] memory cache cleared'); + + // 7. 停止并重新播放 + if (currentIndex >= 0 && currentIndex < _queue.length) { + stopPlay(); + // 确保重新播放同一首歌 + _playCurrent(); + debugPrint('🔄 [AudioService] song reloaded'); + } + } + void next() { if (_queue.isEmpty) return; @@ -215,7 +279,6 @@ class AudioService extends ChangeNotifier { _playCurrent(); } - // ---- 上一首 ---- void previous() { if (_queue.isEmpty) return; @@ -241,7 +304,6 @@ class AudioService extends ChangeNotifier { _playCurrent(); } - // ---- 播放/暂停 ---- void togglePlay() { if (_currentSong == null) return; @@ -252,7 +314,6 @@ class AudioService extends ChangeNotifier { } } - // ---- 停止播放 ---- void stopPlay() { _currentSong = null; _isPlaying = false; @@ -263,19 +324,16 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ---- 跳转 ---- void seekTo(Duration position) { _isUserSeeking = true; PlaybackService().seek(position); positionNotifier.value = position; - // 800ms 后重置标志(覆盖 media_kit 的 positionStream 推送窗口) Future.delayed(const Duration(milliseconds: 800), () { _isUserSeeking = false; }); } - // ---- 清空队列 ---- void clearQueue() { _queue.clear(); _currentIndex = -1; @@ -285,7 +343,6 @@ class AudioService extends ChangeNotifier { notifyListeners(); } - // ---- 主动状态同步(供 PlayerPage 调用) ---- void syncPlayerStateNow() { final player = PlaybackService().player; final pos = player.state.position; @@ -306,7 +363,6 @@ class AudioService extends ChangeNotifier { } } - // ---- 延迟同步(兜底) ---- void _syncPlayerStateDelayed() { syncPlayerStateNow(); Future.delayed(const Duration(milliseconds: 200), () { @@ -317,7 +373,6 @@ class AudioService extends ChangeNotifier { }); } - // ---- 监听 media_kit 状态 ---- void _startListening() { if (_listening) return; _listening = true; @@ -335,7 +390,6 @@ class AudioService extends ChangeNotifier { _subscriptions.add( player.stream.position.listen((position) { - // ⭐ 如果是用户主动 seek,忽略这次推送(避免覆盖) if (_isUserSeeking) { debugPrint('🎯 [AudioService] positionStream ignored: user seeking'); return; @@ -374,7 +428,6 @@ class AudioService extends ChangeNotifier { _subscriptions.clear(); } - // ---- 防重入的完成事件处理 ---- void _onPlaybackCompleted() { if (_handlingCompletion) { debugPrint('⚠️ [service] completed ignored: already handling'); diff --git a/lib/utils/artwork_helper.dart b/lib/utils/artwork_helper.dart index c111967..e1b211f 100644 --- a/lib/utils/artwork_helper.dart +++ b/lib/utils/artwork_helper.dart @@ -2,11 +2,11 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:path_provider/path_provider.dart'; +import 'package:flutter/foundation.dart'; class ArtworkHelper { static const String _artworkDir = 'artworks'; - /// 保存封面图到本地 static Future saveArtwork(Uint8List data, String songKey) async { try { final dir = await getApplicationDocumentsDirectory(); @@ -24,7 +24,6 @@ class ArtworkHelper { } } - /// 获取封面图文件 static Future getArtwork(String songKey) async { try { final dir = await getApplicationDocumentsDirectory(); @@ -39,7 +38,6 @@ class ArtworkHelper { } } - /// 删除封面图 static Future deleteArtwork(String songKey) async { try { final dir = await getApplicationDocumentsDirectory(); @@ -47,6 +45,7 @@ class ArtworkHelper { final file = File(path); if (await file.exists()) { await file.delete(); + debugPrint('🗑️ [ArtworkHelper] deleted artwork: $songKey'); } } catch (e) { // ignore diff --git a/lib/utils/file_provider_utils.dart b/lib/utils/file_provider_utils.dart new file mode 100644 index 0000000..dbc3ba4 --- /dev/null +++ b/lib/utils/file_provider_utils.dart @@ -0,0 +1,55 @@ +// lib/utils/file_provider_utils.dart +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; + +class FileProviderUtils { + static const MethodChannel _channel = + MethodChannel('com.lxh.qingting_player/file_provider'); + + /// 生成 content:// URI(Android only) + static Future getContentUri(File file) async { + if (!Platform.isAndroid) { + // 非 Android 平台直接返回 file:// URI + return Uri.file(file.path); + } + + try { + final uriString = await _channel.invokeMethod('getContentUri', { + 'path': file.path, + }); + if (uriString is String && uriString.isNotEmpty) { + return Uri.parse(uriString); + } + return Uri.file(file.path); + } on PlatformException catch (e) { + debugPrint('⚠️ [FileProviderUtils] PlatformException: ${e.message}'); + return Uri.file(file.path); + } catch (e) { + debugPrint('⚠️ [FileProviderUtils] Error: $e'); + return Uri.file(file.path); + } + } + + /// 检查文件是否存在 + static Future fileExists(String path) async { + try { + final file = File(path); + return await file.exists(); + } catch (e) { + return false; + } + } + + /// 删除文件 + static Future deleteFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // ignore + } + } +} diff --git a/lib/widgets/global_mini_player.dart b/lib/widgets/global_mini_player.dart index 5924a17..86fad59 100644 --- a/lib/widgets/global_mini_player.dart +++ b/lib/widgets/global_mini_player.dart @@ -9,9 +9,8 @@ class GlobalMiniPlayer extends StatelessWidget { @override Widget build(BuildContext context) { - // ⭐ 用 Selector 只监听 currentSong(低频变化) + // ⭐ 使用 Selector 监听完整的 Song 对象(包括 artwork) final song = context.select((s) => s.currentSong); - // ⭐ 只监听播放状态(低频变化) final isPlaying = context.select((s) => s.isPlaying); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -19,12 +18,13 @@ class GlobalMiniPlayer extends StatelessWidget { return Stack( clipBehavior: Clip.none, children: [ + // ⭐ 背景高度从 56 改为 60 Positioned( left: 0, right: 0, bottom: 0, child: Container( - height: 56 + bottomPadding, + height: 60 + bottomPadding, color: const Color(0xFF1A1F1E), ), ), @@ -37,7 +37,7 @@ class GlobalMiniPlayer extends StatelessWidget { bottom: Radius.circular(16), ), child: Container( - height: 56, + height: 60, // ⭐ 从 56 改为 60 color: const Color(0xFF1A1F1E), child: Material( color: Colors.transparent, @@ -52,21 +52,34 @@ class GlobalMiniPlayer extends StatelessWidget { child: Row( children: [ const SizedBox(width: 12), + // ⭐ 封面图容器尺寸从 40 改为 44(适配 60px 高度) Container( - width: 40, - height: 40, + width: 44, + height: 44, decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), color: song != null ? const Color(0xFF2A3332) : Colors.grey[800], - borderRadius: BorderRadius.circular(4), - ), - child: Icon( - song != null ? Icons.music_note : Icons.music_off, - color: - song != null ? Colors.white38 : Colors.grey[600], - size: 20, + // ⭐ 如果 song.artwork 存在,显示封面图 + image: song?.artwork != null + ? DecorationImage( + image: MemoryImage(song!.artwork!), + fit: BoxFit.cover, + ) + : null, ), + child: song?.artwork == null + ? Icon( + song != null + ? Icons.music_note + : Icons.music_off, + color: song != null + ? Colors.white38 + : Colors.grey[600], + size: 20, + ) + : null, ), const SizedBox(width: 12), Expanded( diff --git a/pubspec.lock b/pubspec.lock index ed680dc..fe01e94 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: "2329378af63f49b985cb2e110ac784d08374f1e2b1984be77ba9325b1c8cce11" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.1" archive: dependency: transitive description: @@ -114,7 +122,7 @@ packages: source: hosted version: "1.19.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index 0990ae1..da066de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,8 @@ dependencies: audio_metadata_reader: ^1.7.1 path_provider: ^2.1.0 path: ^1.9.0 + android_intent_plus: ^5.1.0 + crypto: ^3.0.3 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.