Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:stopWithTask="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
Expand Down
301 changes: 164 additions & 137 deletions lib/features/minimal/presentation/minimal_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand All @@ -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();
});
}

Expand All @@ -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;
Comment on lines +94 to +120
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.
}
} catch (e) {
AppLogger.warning('从本地歌单获取歌曲失败,回退到搜索', tag: 'Minimal');
}
Comment on lines +102 to 122
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.
}

// ── 优先级 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');
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 +125 to +151
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.

// 需要通过 getVideoInfo 获取 cid(搜索结果不包含 pages/cid)
final videoInfo = await parseRepo.getVideoInfo(video.bvid);
if (videoInfo.pages.isEmpty) {
throw Exception('视频 ${video.bvid} 没有可用分P');
if (tracks.isEmpty) throw Exception('未找到可播放的音乐视频');

// 随机起点
final startIndex = random.nextInt(tracks.length);
await playerNotifier.playTrackList(tracks, startIndex);
if (!mounted) return;
setState(() => _isLoading = false);
} catch (e, st) {
AppLogger.error('极简模式初始化失败', tag: 'Minimal', error: e, stackTrace: st);
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.
});
}
final page = videoInfo.pages.first;

return AudioTrack(
songId: 0,
bvid: videoInfo.bvid,
cid: page.cid,
title: videoInfo.title,
artist: videoInfo.owner,
coverUrl: videoInfo.coverUrl,
duration: Duration(seconds: page.duration),
);
}

/// 双击退出极简模式,回到原版主页
/// 双击退出极简模式
void _exitMinimalMode() {
context.go(AppRoutes.home);
}

@override
Widget build(BuildContext context) {
// ★ watch playerState:1) 保持 provider 存活 2) 自动刷新 UI(切歌/封面)
final playerState = ref.watch(playerNotifierProvider);
final currentTrack = playerState.currentTrack;
Comment on lines +177 to +179
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.

return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onDoubleTap: _exitMinimalMode,
behavior: HitTestBehavior.opaque,
child: SizedBox.expand(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isLoading)
const CircularProgressIndicator(color: Colors.white54),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_statusText,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 20,
height: 1.6,
child: Stack(
fit: StackFit.expand,
children: [
// ── 毛玻璃呼吸背景 ──
MinimalBackground(coverUrl: currentTrack?.coverUrl),

// ── 前景内容 ──
SafeArea(
child: Column(
children: [
// ── 顶部操作栏(隐蔽的退出按钮) ──
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(16),
child: IconButton(
onPressed: _exitMinimalMode,
icon: Icon(
Icons.close_rounded,
color: Colors.white.withValues(alpha: 0.3),
size: 24,
),
tooltip: '退出极简模式',
),
),
),

// ── 居中歌曲信息面板 ──
Expanded(
child: Center(
child: _errorMessage != null
? Text(
_errorMessage!,
style: const TextStyle(
color: Colors.white54,
fontSize: 16,
),
)
: SongInfoPanel(
track: currentTrack,
isLoading: _isLoading,
),
),
),

// ── 底部提示 ──
Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Text(
'双击屏幕退出',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.2),
fontSize: 12,
),
),
),
),
],
),
const SizedBox(height: 48),
if (!_isLoading)
const Text(
'双击屏幕退出极简模式',
style: TextStyle(color: Colors.white24, fontSize: 13),
),
],
),
),
],
),
),
);
Expand Down
Loading
Loading