Skip to content

Commit a91d998

Browse files
authored
Merge pull request #29 from Davidwadesmith/feat/minimal-mode
feat: 增加极简播放模式(Give Me A Song)
2 parents 1045b1f + 6a7486d commit a91d998

12 files changed

Lines changed: 425 additions & 8 deletions

File tree

android/app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ android {
3939
keyPassword = keystoreProperties.getProperty("keyPassword")
4040
?: System.getenv("KEY_PASSWORD") ?: ""
4141
storeFile = file(keystoreProperties.getProperty("storeFile")
42-
?: "upload-keystore.jks")
42+
?: "../upload-keystore.jks")
4343
storePassword = keystoreProperties.getProperty("storePassword")
4444
?: System.getenv("STORE_PASSWORD") ?: ""
4545
}

android/gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
33
zipStoreBase=GRADLE_USER_HOME
44
zipStorePath=wrapper/dists
5-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
5+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

android/settings.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ pluginManagement {
1818

1919
plugins {
2020
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
21-
id("com.android.application") version "8.7.0" apply false
22-
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
21+
id("com.android.application") version "8.9.1" apply false
22+
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
2323
}
2424

2525
include(":app")

lib/core/router/app_router.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
33

44
import '../../features/auth/presentation/login_screen.dart';
55
import '../../features/download/presentation/download_screen.dart';
6+
import '../../features/minimal/presentation/minimal_screen.dart';
67
import '../../features/player/presentation/full_player_screen.dart';
78
import '../../features/playlist/presentation/playlist_detail_screen.dart';
89
import '../../features/playlist/presentation/playlist_list_screen.dart';
@@ -20,12 +21,18 @@ abstract class AppRoutes {
2021
static const String settings = '/settings';
2122
static const String login = '/login';
2223
static const String player = '/player';
24+
static const String minimal = '/minimal';
2325
}
2426

27+
/// 启动时读取的极简模式标志,由 main() 通过 ProviderScope override 注入。
28+
final initialMinimalModeProvider = Provider<bool>((ref) => false);
29+
2530
/// Global router provider for the application.
2631
final appRouterProvider = Provider<GoRouter>((ref) {
32+
final isMinimalMode = ref.read(initialMinimalModeProvider);
33+
2734
return GoRouter(
28-
initialLocation: AppRoutes.home,
35+
initialLocation: isMinimalMode ? AppRoutes.minimal : AppRoutes.home,
2936
routes: [
3037
StatefulShellRoute.indexedStack(
3138
builder: (context, state, navigationShell) {
@@ -91,6 +98,12 @@ final appRouterProvider = Provider<GoRouter>((ref) {
9198
name: 'player',
9299
builder: (context, state) => const FullPlayerScreen(),
93100
),
101+
// 极简模式独立路由(不包含底部导航栏)
102+
GoRoute(
103+
path: AppRoutes.minimal,
104+
name: 'minimal',
105+
builder: (context, state) => const MinimalScreen(),
106+
),
94107
],
95108
);
96109
});

lib/features/app_update/application/update_notifier.g.dart

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import 'dart:math';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:go_router/go_router.dart';
6+
7+
import '../../../core/api/bili_dio.dart';
8+
import '../../../core/router/app_router.dart';
9+
import '../../../core/utils/logger.dart';
10+
import '../../auth/application/auth_notifier.dart';
11+
import '../../player/application/player_notifier.dart';
12+
import '../../player/domain/models/audio_track.dart';
13+
import '../../playlist/data/playlist_repository_impl.dart';
14+
import '../../search_and_parse/data/parse_repository.dart';
15+
import '../../search_and_parse/data/parse_repository_impl.dart';
16+
import '../../settings/application/settings_notifier.dart';
17+
18+
/// 极简模式页面(内部代号:给我一首歌)。
19+
///
20+
/// 全黑背景,自动随机抽歌并播放。
21+
/// 双击屏幕退出极简模式,回到原版主页。
22+
/// 退到后台时自动停止播放。
23+
class MinimalScreen extends ConsumerStatefulWidget {
24+
const MinimalScreen({super.key});
25+
26+
@override
27+
ConsumerState<MinimalScreen> createState() => _MinimalScreenState();
28+
}
29+
30+
class _MinimalScreenState extends ConsumerState<MinimalScreen>
31+
with WidgetsBindingObserver {
32+
String _statusText = '🎵 正在为你播放...';
33+
bool _isLoading = true;
34+
35+
@override
36+
void initState() {
37+
super.initState();
38+
// 监听 App 生命周期,用于即完即走
39+
WidgetsBinding.instance.addObserver(this);
40+
// 进入页面后触发异步抽歌
41+
WidgetsBinding.instance.addPostFrameCallback((_) {
42+
_pickAndPlay();
43+
});
44+
}
45+
46+
@override
47+
void dispose() {
48+
WidgetsBinding.instance.removeObserver(this);
49+
super.dispose();
50+
}
51+
52+
/// 监听生命周期:退到后台或划掉应用时停止播放
53+
@override
54+
void didChangeAppLifecycleState(AppLifecycleState state) {
55+
if (state == AppLifecycleState.paused ||
56+
state == AppLifecycleState.detached) {
57+
_stopPlayback();
58+
}
59+
}
60+
61+
/// 停止播放
62+
void _stopPlayback() {
63+
try {
64+
ref.read(playerNotifierProvider.notifier).pause();
65+
} catch (e) {
66+
AppLogger.error('停止播放失败', tag: 'Minimal', error: e);
67+
}
68+
}
69+
70+
/// 核心逻辑:抽歌并播放
71+
Future<void> _pickAndPlay() async {
72+
final parseRepo = ParseRepositoryImpl(biliDio: BiliDio());
73+
74+
try {
75+
final track = await _resolveRandomTrack(parseRepo);
76+
if (!mounted) return;
77+
78+
// 获取音频流 URL
79+
final streamInfo = await parseRepo.getAudioStream(
80+
track.bvid,
81+
track.cid,
82+
quality: _preferredQuality,
83+
);
84+
if (!mounted) return;
85+
86+
final playableTrack = track.copyWith(
87+
streamUrl: streamInfo.url,
88+
quality: streamInfo.quality,
89+
);
90+
91+
// 调用全局播放器播放
92+
await ref.read(playerNotifierProvider.notifier).playTrack(playableTrack);
93+
if (!mounted) return;
94+
95+
setState(() {
96+
_statusText = '🎵 ${playableTrack.title}\n${playableTrack.artist}';
97+
_isLoading = false;
98+
});
99+
} catch (e, st) {
100+
AppLogger.error('极简模式抽歌失败', tag: 'Minimal', error: e, stackTrace: st);
101+
if (!mounted) return;
102+
setState(() {
103+
_statusText = '😢 加载失败,双击退出';
104+
_isLoading = false;
105+
});
106+
ScaffoldMessenger.of(context).showSnackBar(
107+
const SnackBar(content: Text('网络请求失败,请检查网络后重试')),
108+
);
109+
}
110+
}
111+
112+
/// 获取用户偏好音质
113+
int? get _preferredQuality {
114+
final q = ref.read(settingsNotifierProvider).preferredQuality;
115+
return q == 0 ? null : q;
116+
}
117+
118+
/// 根据优先级获取随机曲目的 AudioTrack(不含 streamUrl)
119+
///
120+
/// 优先级:本地歌单 > 搜索音乐热门
121+
Future<AudioTrack> _resolveRandomTrack(ParseRepository parseRepo) async {
122+
final random = Random();
123+
124+
// ── 优先级 1:从设置中指定的本地歌单随机抽歌 ──
125+
final minimalPlaylistId = await ref
126+
.read(settingsNotifierProvider.notifier)
127+
.getMinimalPlaylistId();
128+
if (minimalPlaylistId != null && minimalPlaylistId > 0) {
129+
try {
130+
final db = ref.read(databaseProvider);
131+
final playlistRepo = PlaylistRepositoryImpl(db: db);
132+
final songs = await playlistRepo.getSongsInPlaylist(minimalPlaylistId);
133+
if (songs.isNotEmpty) {
134+
final song = songs[random.nextInt(songs.length)];
135+
return AudioTrack(
136+
songId: song.id,
137+
bvid: song.bvid,
138+
cid: song.cid,
139+
title: song.displayTitle,
140+
artist: song.displayArtist,
141+
coverUrl: song.coverUrl,
142+
localPath: song.localPath,
143+
duration: Duration(seconds: song.duration),
144+
quality: song.audioQuality,
145+
);
146+
}
147+
} catch (e) {
148+
AppLogger.warning('从本地歌单获取歌曲失败,回退到搜索', tag: 'Minimal');
149+
}
150+
}
151+
152+
// ── 优先级 2:搜索 B站 音乐区热门视频 ──
153+
final searchResult = await parseRepo.searchVideos('音乐');
154+
final videos = searchResult.results;
155+
if (videos.isEmpty) {
156+
throw Exception('未找到任何音乐视频');
157+
}
158+
159+
// 随机选一个视频
160+
final video = videos[random.nextInt(videos.length)];
161+
162+
// 需要通过 getVideoInfo 获取 cid(搜索结果不包含 pages/cid)
163+
final videoInfo = await parseRepo.getVideoInfo(video.bvid);
164+
if (videoInfo.pages.isEmpty) {
165+
throw Exception('视频 ${video.bvid} 没有可用分P');
166+
}
167+
final page = videoInfo.pages.first;
168+
169+
return AudioTrack(
170+
songId: 0,
171+
bvid: videoInfo.bvid,
172+
cid: page.cid,
173+
title: videoInfo.title,
174+
artist: videoInfo.owner,
175+
coverUrl: videoInfo.coverUrl,
176+
duration: Duration(seconds: page.duration),
177+
);
178+
}
179+
180+
/// 双击退出极简模式,回到原版主页
181+
void _exitMinimalMode() {
182+
context.go(AppRoutes.home);
183+
}
184+
185+
@override
186+
Widget build(BuildContext context) {
187+
return Scaffold(
188+
backgroundColor: Colors.black,
189+
body: GestureDetector(
190+
onDoubleTap: _exitMinimalMode,
191+
behavior: HitTestBehavior.opaque,
192+
child: SizedBox.expand(
193+
child: Column(
194+
mainAxisAlignment: MainAxisAlignment.center,
195+
children: [
196+
if (_isLoading)
197+
const CircularProgressIndicator(color: Colors.white54),
198+
const SizedBox(height: 24),
199+
Padding(
200+
padding: const EdgeInsets.symmetric(horizontal: 32),
201+
child: Text(
202+
_statusText,
203+
textAlign: TextAlign.center,
204+
style: const TextStyle(
205+
color: Colors.white70,
206+
fontSize: 20,
207+
height: 1.6,
208+
),
209+
),
210+
),
211+
const SizedBox(height: 48),
212+
if (!_isLoading)
213+
const Text(
214+
'双击屏幕退出极简模式',
215+
style: TextStyle(color: Colors.white24, fontSize: 13),
216+
),
217+
],
218+
),
219+
),
220+
),
221+
);
222+
}
223+
}

lib/features/player/application/player_notifier.g.dart

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/features/settings/application/settings_notifier.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class SettingsNotifier extends _$SettingsNotifier {
1717
static const _keyCachePath = 'cache_path';
1818
static const _keyPreferredQuality = 'preferred_quality';
1919
static const _keyThemeSeedColor = 'theme_seed_color';
20+
static const _keyMinimalMode = 'is_minimal_mode';
21+
static const _keyMinimalPlaylistId = 'minimal_playlist_id';
2022

2123
@override
2224
UserPreferences build() {
@@ -96,5 +98,37 @@ class SettingsNotifier extends _$SettingsNotifier {
9698
await prefs.remove(_keyCachePath);
9799
await prefs.remove(_keyPreferredQuality);
98100
await prefs.remove(_keyThemeSeedColor);
101+
await prefs.remove(_keyMinimalMode);
102+
await prefs.remove(_keyMinimalPlaylistId);
103+
}
104+
105+
// ── 极简模式设置(不修改 UserPreferences 模型,直接读写 SharedPreferences) ──
106+
107+
/// 读取极简模式开关状态(默认 false)。
108+
Future<bool> getMinimalMode() async {
109+
final prefs = await SharedPreferences.getInstance();
110+
return prefs.getBool(_keyMinimalMode) ?? false;
111+
}
112+
113+
/// 设置极简模式开关。
114+
Future<void> setMinimalMode(bool enabled) async {
115+
final prefs = await SharedPreferences.getInstance();
116+
await prefs.setBool(_keyMinimalMode, enabled);
117+
}
118+
119+
/// 读取极简模式指定的本地歌单 ID。
120+
Future<int?> getMinimalPlaylistId() async {
121+
final prefs = await SharedPreferences.getInstance();
122+
return prefs.getInt(_keyMinimalPlaylistId);
123+
}
124+
125+
/// 设置极简模式指定的本地歌单 ID。
126+
Future<void> setMinimalPlaylistId(int? id) async {
127+
final prefs = await SharedPreferences.getInstance();
128+
if (id != null) {
129+
await prefs.setInt(_keyMinimalPlaylistId, id);
130+
} else {
131+
await prefs.remove(_keyMinimalPlaylistId);
132+
}
99133
}
100134
}

lib/features/settings/application/settings_notifier.g.dart

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/features/settings/presentation/settings_screen.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'widgets/account_section.dart';
66
import 'widgets/appearance_section.dart';
77
import 'widgets/data_management_section.dart';
88
import 'widgets/language_section.dart';
9+
import 'widgets/minimal_mode_section.dart';
910
import 'widgets/playback_section.dart';
1011
import 'widgets/storage_section.dart';
1112

@@ -27,6 +28,8 @@ class SettingsScreen extends ConsumerWidget {
2728
Divider(),
2829
PlaybackSection(),
2930
Divider(),
31+
MinimalModeSection(),
32+
Divider(),
3033
StorageSection(),
3134
Divider(),
3235
AccountSection(),

0 commit comments

Comments
 (0)