Skip to content

feat: 优化极简播放模式#30

Merged
huan-yp merged 1 commit intoGlowLED:mainfrom
Davidwadesmith:feat/minimal-mode
Mar 21, 2026
Merged

feat: 优化极简播放模式#30
huan-yp merged 1 commit intoGlowLED:mainfrom
Davidwadesmith:feat/minimal-mode

Conversation

@Davidwadesmith
Copy link
Contributor

📖 概述

新增「极简模式」功能(内部代号:给我一首歌)。用户在设置中开启后,下次启动 App 将直接进入极简播放界面——全屏毛玻璃动效背景、自动从指定歌单随机抽歌、Shuffle 连播,打造沉浸式盲盒听歌体验。双击屏幕即可退出,回到原版主页。

✨ 功能亮点

  • 一键开启: 设置页新增极简模式开关 + 本地歌单选择器(不指定则回退到 B站搜索)
  • 队列连播: 整个歌单作为播放队列塞入 PlayerNotifier,开启 PlayMode.shuffle,利用原有的 _onTrackCompleted()next() 自动续播,无需定时器
  • 动态 UI: 歌曲封面高斯模糊背景 + 6s 呼吸缩放动画 + AnimatedSwitcher 切歌过渡
  • 精细化生命周期:
    • 锁屏 → 音乐继续(不干预,audio_service 前台服务维持会话)
    • 切后台 → 音乐继续(同上,无法可靠区分锁屏与切后台,优先保障锁屏体验)
    • 划掉应用 → pause() + audioHandler.stop() + stopWithTask="true" 双保险,彻底释放资源

🏗️ 工程质量:最小侵入原则

本 PR 严格遵循 Minimal Viable Changes 原则,所有新功能像插件一样挂载,原有架构零破坏:

维度 说明
新增文件(4 个) minimal_screen.dartminimal_background.dartsong_info_panel.dartminimal_mode_section.dart
原有文件改动 main.dart +7 行、app_router.dart +15 行、settings_notifier.dart +34 行、settings_screen.dart +3 行
绝不触碰 PlayerNotifier / PlayerRepositoryImpl / BusicAudioHandler / ParseRepositoryImpl 等核心类的内部逻辑
状态管理 100% 复用 Riverpod,未引入任何新依赖
播放管线 全部委托 playSongFromPlaylist() / playTrackList() 原生方法

📦 附带修复

  • Android 构建: 升级 AGP 8.9.1 / Kotlin 2.1.0 / Gradle 8.11.1(androidx.browser:1.9.0 要求 AGP ≥ 8.9.1)
  • AudioService 残留: AndroidManifest 的 <service> 增加 android:stopWithTask="true",修复划掉 App 后音频服务不停止的问题

✅ 检查清单

  • dart format . — 通过
  • flutter analyze --no-fatal-infos — 0 error / 0 warning
  • flutter test — 无新增失败
  • Release APK 构建成功(90.2MB)
  • 真机验证:队列连播 ✓ | 锁屏继续 ✓ | 划掉销毁 ✓

- 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
Copilot AI review requested due to automatic review settings March 21, 2026 10:22
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +94 to +120
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;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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).

Copilot uses AI. Check for mistakes.
Comment on lines +102 to 122
// ── 优先级 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');
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +151
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');
}
}
Copy link

Copilot AI Mar 21, 2026

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 uses AI. Check for mistakes.
if (!mounted) return;
setState(() {
_isLoading = false;
_errorMessage = '加载失败,双击退出重试';
Copy link

Copilot AI Mar 21, 2026

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.

Suggested change
_errorMessage = '加载失败,双击退出重试';
_errorMessage = '加载失败,双击退出';

Copilot uses AI. Check for mistakes.
));
} catch (e) {
// 单个视频解析失败不阻塞整体
AppLogger.warning('跳过视频 ${video.bvid}', tag: 'Minimal');
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
AppLogger.warning('跳过视频 ${video.bvid}', tag: 'Minimal');
AppLogger.warning('跳过视频 ${video.bvid}: $e', tag: 'Minimal');

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +179
// ★ watch playerState:1) 保持 provider 存活 2) 自动刷新 UI(切歌/封面)
final playerState = ref.watch(playerNotifierProvider);
final currentTrack = playerState.currentTrack;
Copy link

Copilot AI Mar 21, 2026

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.

Suggested change
// ★ 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));

Copilot uses AI. Check for mistakes.
@huan-yp huan-yp merged commit 0fc3deb into GlowLED:main Mar 21, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants