Conversation
- Load full playlist as queue with shuffle mode instead of single song - Add MinimalBackground: gaussian blur cover + breathing scale/opacity animation - Add SongInfoPanel: cover art + song info with AnimatedSwitcher transitions - Fix lifecycle: no intervention on lock screen/background, only full stop on detached - Add android:stopWithTask=true to AudioService for clean process exit
There was a problem hiding this comment.
Pull request overview
本次 PR 为 App 增加/完善「极简播放模式」页面:进入后以封面毛玻璃呼吸背景沉浸展示歌曲信息,并在初始化时构建可连播的播放队列;同时补充 Android 端划掉应用时停止音频服务的兜底配置。
Changes:
- 新增极简模式 UI 组件:毛玻璃呼吸背景与歌曲信息面板(封面/歌名/歌手 + 切歌过渡)。
- 重构极简模式页面初始化逻辑:优先从本地歌单构建队列并启用 Shuffle,否则回退到 B 站搜索结果队列。
- AndroidManifest 为 AudioService 增加
stopWithTask="true",改善划掉任务后的服务清理行为。
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| lib/features/minimal/presentation/minimal_screen.dart | 初始化队列 + 生命周期处理 + 前景 UI(背景/信息面板/退出交互) |
| lib/features/minimal/presentation/widgets/minimal_background.dart | 封面图模糊 + 呼吸缩放/透明度动画背景 |
| lib/features/minimal/presentation/widgets/song_info_panel.dart | 歌曲封面/标题/歌手展示与 AnimatedSwitcher 切换效果 |
| android/app/src/main/AndroidManifest.xml | 为 AudioService 增加 android:stopWithTask="true" |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Future<void> _initQueue() async { | ||
| try { | ||
| final track = await _resolveRandomTrack(parseRepo); | ||
| if (!mounted) return; | ||
| final random = Random(); | ||
| final playerNotifier = ref.read(playerNotifierProvider.notifier); | ||
|
|
||
| // 获取音频流 URL | ||
| final streamInfo = await parseRepo.getAudioStream( | ||
| track.bvid, | ||
| track.cid, | ||
| quality: _preferredQuality, | ||
| ); | ||
| if (!mounted) return; | ||
|
|
||
| final playableTrack = track.copyWith( | ||
| streamUrl: streamInfo.url, | ||
| quality: streamInfo.quality, | ||
| ); | ||
| // ── 强制开启 Shuffle 模式,确保连播时随机 ── | ||
| playerNotifier.setMode(PlayMode.shuffle); | ||
|
|
||
| // 调用全局播放器播放 | ||
| await ref.read(playerNotifierProvider.notifier).playTrack(playableTrack); | ||
| if (!mounted) return; | ||
|
|
||
| setState(() { | ||
| _statusText = '🎵 ${playableTrack.title}\n${playableTrack.artist}'; | ||
| _isLoading = false; | ||
| }); | ||
| } catch (e, st) { | ||
| AppLogger.error('极简模式抽歌失败', tag: 'Minimal', error: e, stackTrace: st); | ||
| if (!mounted) return; | ||
| setState(() { | ||
| _statusText = '😢 加载失败,双击退出'; | ||
| _isLoading = false; | ||
| }); | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| const SnackBar(content: Text('网络请求失败,请检查网络后重试')), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /// 获取用户偏好音质 | ||
| int? get _preferredQuality { | ||
| final q = ref.read(settingsNotifierProvider).preferredQuality; | ||
| return q == 0 ? null : q; | ||
| } | ||
|
|
||
| /// 根据优先级获取随机曲目的 AudioTrack(不含 streamUrl) | ||
| /// | ||
| /// 优先级:本地歌单 > 搜索音乐热门 | ||
| Future<AudioTrack> _resolveRandomTrack(ParseRepository parseRepo) async { | ||
| final random = Random(); | ||
|
|
||
| // ── 优先级 1:从设置中指定的本地歌单随机抽歌 ── | ||
| final minimalPlaylistId = await ref | ||
| .read(settingsNotifierProvider.notifier) | ||
| .getMinimalPlaylistId(); | ||
| if (minimalPlaylistId != null && minimalPlaylistId > 0) { | ||
| try { | ||
| // ── 优先级 1:本地歌单 → playSongFromPlaylist(整队列) ── | ||
| final playlistId = await ref | ||
| .read(settingsNotifierProvider.notifier) | ||
| .getMinimalPlaylistId(); | ||
| if (playlistId != null && playlistId > 0) { | ||
| final db = ref.read(databaseProvider); | ||
| final playlistRepo = PlaylistRepositoryImpl(db: db); | ||
| final songs = await playlistRepo.getSongsInPlaylist(minimalPlaylistId); | ||
| final songs = await playlistRepo.getSongsInPlaylist(playlistId); | ||
| if (songs.isNotEmpty) { | ||
| final song = songs[random.nextInt(songs.length)]; | ||
| return AudioTrack( | ||
| songId: song.id, | ||
| bvid: song.bvid, | ||
| cid: song.cid, | ||
| title: song.displayTitle, | ||
| artist: song.displayArtist, | ||
| coverUrl: song.coverUrl, | ||
| localPath: song.localPath, | ||
| duration: Duration(seconds: song.duration), | ||
| quality: song.audioQuality, | ||
| // 随机选一首作为起点,整个歌单作为队列 | ||
| final startSong = songs[random.nextInt(songs.length)]; | ||
| await playerNotifier.playSongFromPlaylist( | ||
| song: startSong, | ||
| songs: songs, | ||
| playlistId: playlistId, | ||
| ); | ||
| if (!mounted) return; | ||
| setState(() => _isLoading = false); | ||
| return; |
There was a problem hiding this comment.
_initQueue() can still call playerNotifier.playSongFromPlaylist / playTrackList after the user has exited this page (only setState is guarded by mounted). That can start playback unexpectedly after navigation. Add a cancellation/mounted check before performing playback side effects (or set a disposed flag in dispose() and bail out early).
| // ── 优先级 1:本地歌单 → playSongFromPlaylist(整队列) ── | ||
| final playlistId = await ref | ||
| .read(settingsNotifierProvider.notifier) | ||
| .getMinimalPlaylistId(); | ||
| if (playlistId != null && playlistId > 0) { | ||
| final db = ref.read(databaseProvider); | ||
| final playlistRepo = PlaylistRepositoryImpl(db: db); | ||
| final songs = await playlistRepo.getSongsInPlaylist(minimalPlaylistId); | ||
| final songs = await playlistRepo.getSongsInPlaylist(playlistId); | ||
| if (songs.isNotEmpty) { | ||
| final song = songs[random.nextInt(songs.length)]; | ||
| return AudioTrack( | ||
| songId: song.id, | ||
| bvid: song.bvid, | ||
| cid: song.cid, | ||
| title: song.displayTitle, | ||
| artist: song.displayArtist, | ||
| coverUrl: song.coverUrl, | ||
| localPath: song.localPath, | ||
| duration: Duration(seconds: song.duration), | ||
| quality: song.audioQuality, | ||
| // 随机选一首作为起点,整个歌单作为队列 | ||
| final startSong = songs[random.nextInt(songs.length)]; | ||
| await playerNotifier.playSongFromPlaylist( | ||
| song: startSong, | ||
| songs: songs, | ||
| playlistId: playlistId, | ||
| ); | ||
| if (!mounted) return; | ||
| setState(() => _isLoading = false); | ||
| return; | ||
| } | ||
| } catch (e) { | ||
| AppLogger.warning('从本地歌单获取歌曲失败,回退到搜索', tag: 'Minimal'); | ||
| } |
There was a problem hiding this comment.
The local-playlist branch no longer catches repository/DB errors. If getSongsInPlaylist() throws, the whole init falls into the outer catch and the B站搜索兜底 path is never attempted. Wrap the local playlist load in a try/catch and continue to the fallback on failure (log the error for diagnostics).
| final parseRepo = ParseRepositoryImpl(biliDio: BiliDio()); | ||
| final searchResult = await parseRepo.searchVideos('音乐'); | ||
| if (searchResult.results.isEmpty) { | ||
| throw Exception('未找到任何音乐视频'); | ||
| } | ||
|
|
||
| // 随机选一个视频 | ||
| final video = videos[random.nextInt(videos.length)]; | ||
| // 将搜索结果全部转为 AudioTrack 队列(streamUrl 留空,由 next 时按需解析) | ||
| final tracks = <AudioTrack>[]; | ||
| for (final video in searchResult.results) { | ||
| try { | ||
| final info = await parseRepo.getVideoInfo(video.bvid); | ||
| if (info.pages.isEmpty) continue; | ||
| final page = info.pages.first; | ||
| tracks.add(AudioTrack( | ||
| songId: 0, | ||
| bvid: info.bvid, | ||
| cid: page.cid, | ||
| title: info.title, | ||
| artist: info.owner, | ||
| coverUrl: info.coverUrl, | ||
| duration: Duration(seconds: page.duration), | ||
| )); | ||
| } catch (e) { | ||
| // 单个视频解析失败不阻塞整体 | ||
| AppLogger.warning('跳过视频 ${video.bvid}', tag: 'Minimal'); | ||
| } | ||
| } |
There was a problem hiding this comment.
The B站兜底 path does await getVideoInfo() sequentially for every search result (default pageSize is 20), which can significantly delay first playback and increase failure surface. Consider limiting the number of items used to build the queue, and/or fetching videoInfo concurrently with a bounded concurrency, or deferring CID resolution until a track is about to play.
| if (!mounted) return; | ||
| setState(() { | ||
| _isLoading = false; | ||
| _errorMessage = '加载失败,双击退出重试'; |
There was a problem hiding this comment.
The error text says “双击退出重试”, but the implemented gesture only exits the screen and does not perform any retry. Either change the copy to match the behavior (e.g. just “双击退出”) or add an explicit retry action.
| _errorMessage = '加载失败,双击退出重试'; | |
| _errorMessage = '加载失败,双击退出'; |
| )); | ||
| } catch (e) { | ||
| // 单个视频解析失败不阻塞整体 | ||
| AppLogger.warning('跳过视频 ${video.bvid}', tag: 'Minimal'); |
There was a problem hiding this comment.
When skipping a video in the fallback loop, the warning log drops the exception details, making it hard to diagnose systemic parse failures. Include the caught error (and optionally stack trace) in the log call.
| AppLogger.warning('跳过视频 ${video.bvid}', tag: 'Minimal'); | |
| AppLogger.warning('跳过视频 ${video.bvid}: $e', tag: 'Minimal'); |
| // ★ watch playerState:1) 保持 provider 存活 2) 自动刷新 UI(切歌/封面) | ||
| final playerState = ref.watch(playerNotifierProvider); | ||
| final currentTrack = playerState.currentTrack; |
There was a problem hiding this comment.
ref.watch(playerNotifierProvider) will rebuild this whole screen on every PlayerState update (notably position updates from the player stream), which can cause unnecessary UI work and jank. Consider watching only currentTrack (e.g., select) and/or using ref.listen to keep the provider alive without rebuilding on position ticks.
| // ★ watch playerState:1) 保持 provider 存活 2) 自动刷新 UI(切歌/封面) | |
| final playerState = ref.watch(playerNotifierProvider); | |
| final currentTrack = playerState.currentTrack; | |
| // ★ watch currentTrack:1) 保持 provider 存活 2) 仅在切歌/封面变化时刷新 UI | |
| final currentTrack = | |
| ref.watch(playerNotifierProvider.select((state) => state.currentTrack)); |
📖 概述
新增「极简模式」功能(内部代号:给我一首歌)。用户在设置中开启后,下次启动 App 将直接进入极简播放界面——全屏毛玻璃动效背景、自动从指定歌单随机抽歌、Shuffle 连播,打造沉浸式盲盒听歌体验。双击屏幕即可退出,回到原版主页。
✨ 功能亮点
PlayerNotifier,开启PlayMode.shuffle,利用原有的_onTrackCompleted()→next()自动续播,无需定时器AnimatedSwitcher切歌过渡audio_service前台服务维持会话)pause()+audioHandler.stop()+stopWithTask="true"双保险,彻底释放资源🏗️ 工程质量:最小侵入原则
本 PR 严格遵循 Minimal Viable Changes 原则,所有新功能像插件一样挂载,原有架构零破坏:
minimal_screen.dart、minimal_background.dart、song_info_panel.dart、minimal_mode_section.dartmain.dart+7 行、app_router.dart+15 行、settings_notifier.dart+34 行、settings_screen.dart+3 行PlayerNotifier/PlayerRepositoryImpl/BusicAudioHandler/ParseRepositoryImpl等核心类的内部逻辑playSongFromPlaylist()/playTrackList()原生方法📦 附带修复
androidx.browser:1.9.0要求 AGP ≥ 8.9.1)<service>增加android:stopWithTask="true",修复划掉 App 后音频服务不停止的问题✅ 检查清单
dart format .— 通过flutter analyze --no-fatal-infos— 0 error / 0 warningflutter test— 无新增失败