-
Notifications
You must be signed in to change notification settings - Fork 4
feat: 优化极简播放模式 #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: 优化极简播放模式 #30
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,19 +7,32 @@ import 'package:go_router/go_router.dart'; | |||||||||||||
| import '../../../core/api/bili_dio.dart'; | ||||||||||||||
| import '../../../core/router/app_router.dart'; | ||||||||||||||
| import '../../../core/utils/logger.dart'; | ||||||||||||||
| import '../../../main.dart'; | ||||||||||||||
| import '../../auth/application/auth_notifier.dart'; | ||||||||||||||
| import '../../player/application/player_notifier.dart'; | ||||||||||||||
| import '../../player/domain/models/audio_track.dart'; | ||||||||||||||
| import '../../player/domain/models/play_mode.dart'; | ||||||||||||||
| import '../../playlist/data/playlist_repository_impl.dart'; | ||||||||||||||
| import '../../search_and_parse/data/parse_repository.dart'; | ||||||||||||||
| import '../../search_and_parse/data/parse_repository_impl.dart'; | ||||||||||||||
| import '../../settings/application/settings_notifier.dart'; | ||||||||||||||
| import 'widgets/minimal_background.dart'; | ||||||||||||||
| import 'widgets/song_info_panel.dart'; | ||||||||||||||
|
|
||||||||||||||
| /// 极简模式页面(内部代号:给我一首歌)。 | ||||||||||||||
| /// | ||||||||||||||
| /// 全黑背景,自动随机抽歌并播放。 | ||||||||||||||
| /// 毛玻璃封面背景 + 呼吸动效,自动将歌单塞入播放队列并以 Shuffle 模式连播。 | ||||||||||||||
| /// 双击屏幕退出极简模式,回到原版主页。 | ||||||||||||||
| /// 退到后台时自动停止播放。 | ||||||||||||||
| /// | ||||||||||||||
| /// 生命周期策略(解决 Flutter 无法区分锁屏 vs 切后台的痛点): | ||||||||||||||
| /// - 锁屏 / 切后台 / 回到前台 → **完全不干预播放** | ||||||||||||||
| /// (audio_service 前台服务维持后台音频会话,锁屏继续播放) | ||||||||||||||
| /// - 划掉应用 (`detached`) → 完整停止播放 + 销毁 AudioService 前台服务 | ||||||||||||||
| /// | ||||||||||||||
| /// 为什么不在 paused/resumed 中做任何事: | ||||||||||||||
| /// Flutter 的 AppLifecycleState.paused 在锁屏和切后台时**都**会触发, | ||||||||||||||
| /// 无法可靠区分。若在 paused 中暂停,锁屏听歌会中断; | ||||||||||||||
| /// 若在 resumed 中切歌,解锁后会误切歌。两者都严重影响体验。 | ||||||||||||||
| /// 因此采用"完全不干预"策略,优先保证锁屏连续播放。 | ||||||||||||||
| class MinimalScreen extends ConsumerStatefulWidget { | ||||||||||||||
| const MinimalScreen({super.key}); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -29,17 +42,15 @@ class MinimalScreen extends ConsumerStatefulWidget { | |||||||||||||
|
|
||||||||||||||
| class _MinimalScreenState extends ConsumerState<MinimalScreen> | ||||||||||||||
| with WidgetsBindingObserver { | ||||||||||||||
| String _statusText = '🎵 正在为你播放...'; | ||||||||||||||
| bool _isLoading = true; | ||||||||||||||
| String? _errorMessage; | ||||||||||||||
|
|
||||||||||||||
| @override | ||||||||||||||
| void initState() { | ||||||||||||||
| super.initState(); | ||||||||||||||
| // 监听 App 生命周期,用于即完即走 | ||||||||||||||
| WidgetsBinding.instance.addObserver(this); | ||||||||||||||
| // 进入页面后触发异步抽歌 | ||||||||||||||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||||||||||||||
| _pickAndPlay(); | ||||||||||||||
| _initQueue(); | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -49,173 +60,189 @@ class _MinimalScreenState extends ConsumerState<MinimalScreen> | |||||||||||||
| super.dispose(); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /// 监听生命周期:退到后台或划掉应用时停止播放 | ||||||||||||||
| /// 生命周期监听:仅处理 `detached`(应用被划掉)。 | ||||||||||||||
| /// | ||||||||||||||
| /// paused / hidden / resumed 全部不做任何播放干预,原因: | ||||||||||||||
| /// - Flutter 的 `paused` 在锁屏和切后台时都会触发,无法区分 | ||||||||||||||
| /// - 若在 `paused` 中暂停 → 锁屏时音乐中断(Bug #2) | ||||||||||||||
| /// - 若在 `resumed` 中切歌 → 解锁时误切歌(Bug #2) | ||||||||||||||
| /// - audio_service 前台服务天然维持后台/锁屏音频会话 | ||||||||||||||
| /// | ||||||||||||||
| /// detached 时完整停止:先暂停 media_kit 播放器,再销毁 AudioService | ||||||||||||||
| /// 前台服务(移除通知 + 释放 WakeLock),确保无进程残留。 | ||||||||||||||
| @override | ||||||||||||||
| void didChangeAppLifecycleState(AppLifecycleState state) { | ||||||||||||||
| if (state == AppLifecycleState.paused || | ||||||||||||||
| state == AppLifecycleState.detached) { | ||||||||||||||
| _stopPlayback(); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| if (state != AppLifecycleState.detached) return; | ||||||||||||||
|
|
||||||||||||||
| /// 停止播放 | ||||||||||||||
| void _stopPlayback() { | ||||||||||||||
| // ── 应用被划掉:完整停止播放 + 销毁前台服务 ── | ||||||||||||||
| try { | ||||||||||||||
| ref.read(playerNotifierProvider.notifier).pause(); | ||||||||||||||
| // 通过 audioHandlerProvider 调用 stop(),销毁前台服务(移除通知栏、释放 WakeLock) | ||||||||||||||
| // 配合 AndroidManifest 的 stopWithTask="true" 双保险 | ||||||||||||||
| ref.read(audioHandlerProvider).stop(); | ||||||||||||||
| AppLogger.info('App detached: 已停止播放并销毁音频服务', tag: 'Minimal'); | ||||||||||||||
| } catch (e) { | ||||||||||||||
| AppLogger.error('停止播放失败', tag: 'Minimal', error: e); | ||||||||||||||
| AppLogger.error('detached 清理失败', tag: 'Minimal', error: e); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /// 核心逻辑:抽歌并播放 | ||||||||||||||
| Future<void> _pickAndPlay() async { | ||||||||||||||
| final parseRepo = ParseRepositoryImpl(biliDio: BiliDio()); | ||||||||||||||
|
|
||||||||||||||
| /// 核心初始化:加载歌单 → 塞入完整队列 → 开启 Shuffle 模式 → 播放。 | ||||||||||||||
| /// | ||||||||||||||
| /// 优先级:本地歌单 → B站搜索兜底。 | ||||||||||||||
| /// 所有播放均委托给 [PlayerNotifier] 原生管线 | ||||||||||||||
| /// (自动查本地缓存、WBI 签名 API、Referer Header)。 | ||||||||||||||
| 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; | ||||||||||||||
| } | ||||||||||||||
| } catch (e) { | ||||||||||||||
| AppLogger.warning('从本地歌单获取歌曲失败,回退到搜索', tag: 'Minimal'); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+102
to
122
|
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // ── 优先级 2:搜索 B站 音乐区热门视频 ── | ||||||||||||||
| final searchResult = await parseRepo.searchVideos('音乐'); | ||||||||||||||
| final videos = searchResult.results; | ||||||||||||||
| if (videos.isEmpty) { | ||||||||||||||
| throw Exception('未找到任何音乐视频'); | ||||||||||||||
| } | ||||||||||||||
| // ── 优先级 2:B站搜索兜底 → playTrackList(整列表) ── | ||||||||||||||
| 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'); | ||||||||||||||
|
||||||||||||||
| AppLogger.warning('跳过视频 ${video.bvid}', tag: 'Minimal'); | |
| AppLogger.warning('跳过视频 ${video.bvid}: $e', tag: 'Minimal'); |
Copilot
AI
Mar 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Mar 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 = '加载失败,双击退出'; |
Copilot
AI
Mar 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_initQueue()can still callplayerNotifier.playSongFromPlaylist/playTrackListafter the user has exited this page (onlysetStateis guarded bymounted). That can start playback unexpectedly after navigation. Add a cancellation/mountedcheck before performing playback side effects (or set a disposed flag indispose()and bail out early).