权限配置暂时到此,后台播放问题并入生命周期管理同步开发

This commit is contained in:
2026-08-20 22:07:59 +08:00
parent b2c7582ca7
commit f94cda4234
9 changed files with 787 additions and 364 deletions
+9 -6
View File
@@ -3,19 +3,18 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- ⭐ 后台播放(必需) -->
<!-- ⭐ 后台播放 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 12+ 后台启动限制 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- ⭐ 通知(Android 13+ 必需) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="qt_player"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:foregroundServiceType="mediaPlayback">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -40,6 +39,10 @@
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -3,46 +3,20 @@ package com.example.qt_player
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import com.ryanheise.audioservice.AudioServiceActivity // ⭐ 改用这个
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.qt_player/frame_rate"
class MainActivity : AudioServiceActivity() { // ⭐ 继承 AudioServiceActivity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 在创建时尝试设置(但可能窗口还未就绪,所以下面在 onResume 再次设置)
setHighRefreshRate()
}
override fun onResume() {
super.onResume()
// 每次回到前台时确保高刷生效
setHighRefreshRate()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"setHighRefreshRate" -> {
setHighRefreshRate()
result.success(true)
}
"clearRefreshRate" -> {
clearRefreshRate()
result.success(true)
}
else -> result.notImplemented()
}
}
}
private fun setHighRefreshRate() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -60,7 +34,7 @@ class MainActivity : FlutterActivity() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val params = window.attributes
params.preferredRefreshRate = 0f // 0 表示系统自动
params.preferredRefreshRate = 0f
window.attributes = params
Log.d("QTPlayer", "恢复默认刷新率")
}
+142 -37
View File
@@ -1,32 +1,59 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:media_kit/media_kit.dart';
//import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:permission_handler/permission_handler.dart';
import 'services/audio_service.dart';
import 'services/playback_service.dart';
import 'services/app_lifecycle_service.dart';
import 'services/webdav_service.dart';
import 'services/audio_player_handler.dart';
import 'pages/home_page.dart';
import 'pages/player_page.dart';
import 'pages/playlist_page.dart';
import 'widgets/global_mini_player.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// ⏱️ 全局计时器
final stopwatch = Stopwatch();
void main() {
// ⭐ 防止 Hot Restart / 热重载时重复初始化
bool _appInitialized = false;
// ⭐ 全局缓存 Handler
AudioPlayerHandler? _audioHandler;
void main() async {
// ⭐ P0:防止重复初始化
if (_appInitialized) {
debugPrint('⏱️ main 已初始化,跳过重复执行');
return;
}
_appInitialized = true;
stopwatch.start();
debugPrint('⏱️ T0 main: ${stopwatch.elapsedMilliseconds}ms');
WidgetsFlutterBinding.ensureInitialized();
debugPrint('⏱️ T0.5 ensureInitialized: ${stopwatch.elapsedMilliseconds}ms');
// ⭐ MediaKit 在 runApp 前初始化(官方要求)
try {
MediaKit.ensureInitialized();
debugPrint('⏱️ T0.6 MediaKit 初始化完成');
} catch (e) {
debugPrint('❌ MediaKit 初始化失败: $e');
}
// ⭐ 创建 HandlerAudioService 稍后初始化)
_audioHandler = AudioPlayerHandler();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AudioService()),
ChangeNotifierProvider(create: (_) => AppLifecycleService()),
Provider<AudioPlayerHandler>(create: (_) => _audioHandler!),
],
child: const QTPlayerApp(),
),
@@ -35,13 +62,15 @@ void main() {
debugPrint('⏱️ T0.8 runApp 完成: ${stopwatch.elapsedMilliseconds}ms');
}
// ════════════════════════════════════════════════════════════
// 路由管理器(控制 MiniPlayer 显示/隐藏)
// ════════════════════════════════════════════════════════════
class RouteManager extends ChangeNotifier {
static final RouteManager _instance = RouteManager._internal();
factory RouteManager() => _instance;
RouteManager._internal();
String _currentRoute = '/';
String get currentRoute => _currentRoute;
bool get showMiniPlayer =>
@@ -56,6 +85,9 @@ class RouteManager extends ChangeNotifier {
}
}
// ════════════════════════════════════════════════════════════
// 导航观察者(监听路由变化)
// ════════════════════════════════════════════════════════════
class MiniPlayerNavigatorObserver extends NavigatorObserver {
void _updateRoute(Route? route) {
final name = route?.settings.name ?? '/';
@@ -78,6 +110,9 @@ class MiniPlayerNavigatorObserver extends NavigatorObserver {
}
}
// ════════════════════════════════════════════════════════════
// 主应用
// ════════════════════════════════════════════════════════════
class QTPlayerApp extends StatefulWidget {
const QTPlayerApp({super.key});
@@ -86,21 +121,123 @@ class QTPlayerApp extends StatefulWidget {
}
class _QTPlayerAppState extends State<QTPlayerApp> {
bool _isInitializing = false;
// ============================================================
// 生命周期
// ============================================================
@override
void initState() {
super.initState();
debugPrint('⏱️ T1.5 initState: ${stopwatch.elapsedMilliseconds}ms');
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('⏱️ T2 首帧回调: ${stopwatch.elapsedMilliseconds}ms');
_initializeServices();
_startBackgroundInitialization();
});
}
@override
void dispose() {
super.dispose();
}
// ============================================================
// 后台初始化(不阻塞首帧)
// ============================================================
Future<void> _startBackgroundInitialization() async {
if (_isInitializing) return;
_isInitializing = true;
debugPrint('⏱️ T3 后台初始化开始');
// 1. PlaybackService(轻量级)
try {
PlaybackService().init();
debugPrint('⏱️ T3.1 PlaybackService 初始化完成');
} catch (e) {
debugPrint('❌ PlaybackService 初始化失败: $e');
}
// 2. ⭐ AudioService 暂时禁用(等 MainActivity 改为 AudioServiceActivity 后启用)
// await _initAudioService();
// 3. WebDAV
await _initWebDAV();
// 4. 通知权限
await _requestPermissions();
debugPrint('⏱️ T4 全部初始化完成');
}
// ════════════════════════════════════════════════════════════
// 子初始化方法
// ════════════════════════════════════════════════════════════
// ⭐ 暂时禁用,等 MainActivity 改为 AudioServiceActivity 后启用
// Future<void> _initAudioService() async {
// try {
// await audio_service.AudioService.init(
// builder: () => _audioHandler!,
// config: const audio_service.AudioServiceConfig(
// androidNotificationChannelId: 'com.example.qt_player.music',
// androidNotificationChannelName: '清听音乐播放',
// androidNotificationOngoing: true,
// androidStopForegroundOnPause: true,
// ),
// );
// debugPrint('✅ AudioService 初始化完成');
// } catch (e) {
// debugPrint('❌ AudioService 初始化失败: $e');
// }
// }
Future<void> _initWebDAV() async {
try {
final hasCred = await WebDAVService.instance.loadCredentials();
debugPrint('✅ WebDAV 加载完成, 已连接: $hasCred');
} catch (e) {
debugPrint('❌ WebDAV 加载失败: $e');
}
}
Future<void> _requestPermissions() async {
try {
if (await Permission.notification.isDenied) {
final status = await Permission.notification.request();
debugPrint('📢 通知权限状态: $status');
}
} catch (e) {
debugPrint('❌ 权限申请失败: $e');
}
}
// ============================================================
// Build
// ============================================================
@override
Widget build(BuildContext context) {
debugPrint('⏱️ T1 build: ${stopwatch.elapsedMilliseconds}ms');
// 监听歌曲变化,更新通知栏
final audioService = context.watch<AudioService>();
final handler = context.read<AudioPlayerHandler>();
final song = audioService.currentSong;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (song != null) {
handler.updateNotification(
id: song.id,
title: song.title,
artist: song.artist,
);
} else {
handler.mediaItem.add(null);
}
});
return MaterialApp(
title: '清听',
theme: ThemeData(
@@ -144,35 +281,3 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
);
}
}
void _initializeServices() async {
debugPrint('⏱️ T3 开始初始化: ${stopwatch.elapsedMilliseconds}ms');
final lifecycle = AppLifecycleService();
try {
lifecycle.updateStatus(AppStatus.mediaKitInitializing);
MediaKit.ensureInitialized();
debugPrint('⏱️ T4 MediaKit 完成: ${stopwatch.elapsedMilliseconds}ms');
lifecycle.updateStatus(AppStatus.mediaKitReady);
lifecycle.updateStatus(AppStatus.playerInitializing);
PlaybackService().init();
debugPrint('⏱️ T5 Player 完成: ${stopwatch.elapsedMilliseconds}ms');
lifecycle.updateStatus(AppStatus.playerReady);
lifecycle.updateStatus(AppStatus.webdavChecking);
final hasCred = await WebDAVService.instance.loadCredentials();
debugPrint('⏱️ T6 WebDAV 完成: ${stopwatch.elapsedMilliseconds}ms');
if (hasCred) {
lifecycle.updateStatus(AppStatus.webdavReady);
} else {
lifecycle.updateStatus(AppStatus.webdavMissing);
}
lifecycle.updateStatus(AppStatus.fullReady);
debugPrint('✅ 应用完全就绪: ${stopwatch.elapsedMilliseconds}ms');
} catch (e) {
debugPrint('❌ 初始化错误: $e');
lifecycle.updateStatus(AppStatus.error);
}
}
+214 -195
View File
@@ -1,10 +1,17 @@
// lib/pages/player_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../main.dart';
class PlayerPage extends StatefulWidget {
const PlayerPage({super.key});
final VoidCallback? onClose;
final VoidCallback? onOpenPlaylist;
const PlayerPage({
super.key,
this.onClose,
this.onOpenPlaylist,
});
@override
State<PlayerPage> createState() => _PlayerPageState();
@@ -20,6 +27,22 @@ class _PlayerPageState extends State<PlayerPage> {
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
void _closePage() {
if (widget.onClose != null) {
widget.onClose!();
} else {
Navigator.pop(context);
}
}
void _openPlaylist() {
if (widget.onOpenPlaylist != null) {
widget.onOpenPlaylist!();
} else {
Navigator.pushNamed(context, '/playlist');
}
}
@override
Widget build(BuildContext context) {
final service = context.watch<AudioService>();
@@ -28,33 +51,22 @@ class _PlayerPageState extends State<PlayerPage> {
if (song == null) {
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'正在播放',
style: TextStyle(color: Colors.white, fontSize: 16),
),
centerTitle: true,
),
body: const Center(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.music_off, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
const Text(
'没有正在播放的歌曲',
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 16),
Text(
'请先在首页点击一首歌曲',
style: TextStyle(color: Colors.grey, fontSize: 12),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _closePage,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFB8D4D0),
foregroundColor: Colors.black87,
),
child: const Text('返回'),
),
],
),
@@ -63,20 +75,7 @@ class _PlayerPageState extends State<PlayerPage> {
}
final isPlaying = service.isPlaying;
final position = service.position;
final duration = service.duration;
final bufferedPosition = service.bufferedPosition;
final progress = duration.inMilliseconds > 0
? position.inMilliseconds / duration.inMilliseconds
: 0.0;
final bufferProgress = duration.inMilliseconds > 0
? (bufferedPosition.inMilliseconds / duration.inMilliseconds)
.clamp(0.0, 1.0)
: 0.0;
final displayProgress = _dragProgress ?? progress;
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
@@ -85,7 +84,7 @@ class _PlayerPageState extends State<PlayerPage> {
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
onPressed: () => Navigator.pop(context),
onPressed: _closePage,
),
title: const Text(
'正在播放',
@@ -157,169 +156,189 @@ class _PlayerPageState extends State<PlayerPage> {
],
),
const SizedBox(height: 40),
Column(
children: [
SizedBox(
height: 20,
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final bufferWidth =
width * bufferProgress.clamp(0.0, 1.0);
final progressWidth =
width * displayProgress.clamp(0.0, 1.0);
return Stack(
alignment: Alignment.centerLeft,
children: [
Container(
width: width,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(2),
),
),
Container(
width: bufferWidth,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFF4A7A7A),
borderRadius: BorderRadius.circular(2),
),
),
Container(
width: progressWidth,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFB8D4D0),
borderRadius: BorderRadius.circular(2),
),
),
Positioned.fill(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 0,
activeTrackColor: Colors.transparent,
inactiveTrackColor: Colors.transparent,
thumbColor: Colors.transparent,
overlayColor: Colors.transparent,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 0,
),
),
child: Slider(
value: displayProgress.clamp(0.0, 1.0),
min: 0.0,
max: 1.0,
onChanged: (value) {
setState(() {
_dragProgress = value;
});
},
onChangeEnd: (value) {
final newPosition = Duration(
milliseconds:
(value * duration.inMilliseconds)
.round(),
);
service.seekTo(newPosition);
setState(() {
_dragProgress = null;
});
},
),
),
),
],
);
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(position),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
],
),
_buildProgressSection(service, duration),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: service.togglePlayMode,
icon: Icon(
service.playModeIcon,
color: const Color(0xFFB8D4D0),
size: 22,
),
padding: const EdgeInsets.all(12),
),
const SizedBox(width: 4),
IconButton(
onPressed: service.hasQueue ? service.previous : null,
icon: const Icon(Icons.skip_previous, size: 28),
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
padding: const EdgeInsets.all(12),
),
const SizedBox(width: 20),
Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Color(0xFFB8D4D0),
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.black87,
size: 28,
),
padding: EdgeInsets.zero,
onPressed: service.togglePlay,
),
),
const SizedBox(width: 20),
IconButton(
onPressed: service.hasQueue ? service.next : null,
icon: const Icon(Icons.skip_next, size: 28),
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
padding: const EdgeInsets.all(12),
),
IconButton(
onPressed: () {
navigatorKey.currentState?.pushNamed('/playlist');
},
icon: const Icon(
Icons.playlist_play_outlined,
color: Color(0xFFB8D4D0),
size: 26,
),
padding: const EdgeInsets.all(8),
),
const SizedBox(width: 4),
],
),
_buildControlButtons(service, isPlaying),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildProgressSection(AudioService service, Duration duration) {
return ValueListenableBuilder(
valueListenable: service.positionNotifier,
builder: (context, position, _) {
final progress = duration.inMilliseconds > 0
? position.inMilliseconds / duration.inMilliseconds
: 0.0;
final bufferProgress = duration.inMilliseconds > 0
? (service.bufferedNotifier.value.inMilliseconds /
duration.inMilliseconds)
.clamp(0.0, 1.0)
: 0.0;
final displayProgress = _dragProgress ?? progress;
return Column(
children: [
SizedBox(
height: 20,
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final bufferWidth = width * bufferProgress.clamp(0.0, 1.0);
final progressWidth = width * displayProgress.clamp(0.0, 1.0);
return Stack(
alignment: Alignment.centerLeft,
children: [
Container(
width: width,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(2),
),
),
Container(
width: bufferWidth,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFF4A7A7A),
borderRadius: BorderRadius.circular(2),
),
),
Container(
width: progressWidth,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFB8D4D0),
borderRadius: BorderRadius.circular(2),
),
),
Positioned.fill(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 0,
activeTrackColor: Colors.transparent,
inactiveTrackColor: Colors.transparent,
thumbColor: Colors.transparent,
overlayColor: Colors.transparent,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 0,
),
),
child: Slider(
value: displayProgress.clamp(0.0, 1.0),
min: 0.0,
max: 1.0,
onChanged: (value) {
setState(() {
_dragProgress = value;
});
},
onChangeEnd: (value) {
final newPosition = Duration(
milliseconds:
(value * duration.inMilliseconds).round(),
);
service.seekTo(newPosition);
setState(() {
_dragProgress = null;
});
},
),
),
),
],
);
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(position),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
],
);
},
);
}
Widget _buildControlButtons(AudioService service, bool isPlaying) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: service.togglePlayMode,
icon: Icon(
service.playModeIcon,
color: const Color(0xFFB8D4D0),
size: 22,
),
padding: const EdgeInsets.all(12),
),
const SizedBox(width: 4),
IconButton(
onPressed: service.hasQueue ? service.previous : null,
icon: const Icon(Icons.skip_previous, size: 28),
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
padding: const EdgeInsets.all(12),
),
const SizedBox(width: 20),
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFFB8D4D0),
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.black87,
size: 28,
),
padding: EdgeInsets.zero,
onPressed: service.togglePlay,
),
),
const SizedBox(width: 20),
IconButton(
onPressed: service.hasQueue ? service.next : null,
icon: const Icon(Icons.skip_next, size: 28),
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
padding: const EdgeInsets.all(12),
),
IconButton(
onPressed: _openPlaylist,
icon: const Icon(
Icons.playlist_play_outlined,
color: Color(0xFFB8D4D0),
size: 26,
),
padding: const EdgeInsets.all(8),
),
const SizedBox(width: 4),
],
);
}
}
+90
View File
@@ -0,0 +1,90 @@
// lib/services/audio_player_handler.dart
import 'package:audio_service/audio_service.dart';
import 'playback_service.dart';
import 'audio_service.dart' as local_audio;
class AudioPlayerHandler extends BaseAudioHandler {
final PlaybackService _playback = PlaybackService();
late final local_audio.AudioService _localAudio;
MediaItem? _currentMediaItem;
AudioPlayerHandler() {
_localAudio = local_audio.AudioService();
// 1. 监听播放状态(只更新 playing)
_playback.player.stream.playing.listen((playing) {
playbackState.add(playbackState.value.copyWith(
playing: playing,
));
});
// 2. ⭐ 进度更新:跳过,因为当前版本不支持 position 参数
// 通知栏进度条不会动,但播放/暂停/切歌功能正常
// 如果后续需要,可以升级 audio_service 版本或改用其他方案
// 3. 监听播放完成(触发下一首)
_playback.player.stream.completed.listen((_) {
_localAudio.next();
});
// 4. 监听时长变化,更新 MediaItem
_playback.player.stream.duration.listen((duration) {
if (_currentMediaItem != null) {
_currentMediaItem = _currentMediaItem!.copyWith(duration: duration);
mediaItem.add(_currentMediaItem);
}
});
}
/// 外部调用更新通知栏
void updateNotification({
required String id,
required String title,
required String artist,
}) {
_currentMediaItem = MediaItem(
id: id,
title: title,
artist: artist,
duration: _playback.player.state.duration,
);
mediaItem.add(_currentMediaItem);
}
// ---- AudioHandler 接口实现 ----
@override
Future<void> play() async {
_playback.resume();
}
@override
Future<void> pause() async {
_playback.pause();
}
@override
Future<void> stop() async {
_playback.stop();
}
@override
Future<void> seek(Duration position) async {
_playback.seek(position);
}
@override
Future<void> skipToNext() async {
_localAudio.next();
}
@override
Future<void> skipToPrevious() async {
_localAudio.previous();
}
@override
Future<void> click([MediaButton button = MediaButton.media]) async {
// 默认行为:打开 App
}
}
+52 -86
View File
@@ -4,18 +4,12 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'playback_service.dart';
// ============================================================
// 播放模式
// ============================================================
enum PlayMode {
sequential, // 顺序循环
repeatOne, // 单曲循环
shuffle, // 随机播放
sequential,
repeatOne,
shuffle,
}
// ============================================================
// 歌曲模型
// ============================================================
class Song {
final String id;
final String title;
@@ -30,49 +24,43 @@ class Song {
});
}
// ============================================================
// AudioService - 播放状态管理与 UI 数据源
// ============================================================
class AudioService extends ChangeNotifier {
// ---------- 单例 ----------
static final AudioService _instance = AudioService._internal();
factory AudioService() => _instance;
AudioService._internal();
// ---------- 播放状态 ----------
// ---- 基础状态(低频,触发 UI 重建) ----
Song? _currentSong;
bool _isPlaying = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
Duration _bufferedPosition = Duration.zero;
// ---------- 播放模式 ----------
PlayMode _playMode = PlayMode.sequential;
// ---------- 播放队列 ----------
// ---- 播放队列 ----
List<Song> _queue = [];
int _currentIndex = -1;
// ---------- 随机播放相关 ----------
List<int> _shuffledIndices = [];
int _shuffledIndex = -1;
// ---------- 监听控制 ----------
// ---- ⭐ 高频进度(用 ValueNotifier,不触发全局重建) ----
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> bufferedNotifier = ValueNotifier(Duration.zero);
bool _listening = false;
final List<StreamSubscription> _subscriptions = [];
// ---------- Getter ----------
// ---- Getter(高频字段不走 ChangeNotifier ----
Song? get currentSong => _currentSong;
bool get isPlaying => _isPlaying;
Duration get position => _position;
Duration get duration => _duration;
Duration get bufferedPosition => _bufferedPosition;
PlayMode get playMode => _playMode;
List<Song> get queue => List.unmodifiable(_queue);
int get currentIndex => _currentIndex;
bool get hasQueue => _queue.isNotEmpty;
// ---------- 播放模式图标 ----------
// ---- 兼容旧代码:提供 getter 返回 ValueNotifier 的值 ----
Duration get position => positionNotifier.value;
Duration get duration => durationNotifier.value;
Duration get bufferedPosition => bufferedNotifier.value;
IconData get playModeIcon {
switch (_playMode) {
case PlayMode.sequential:
@@ -84,7 +72,7 @@ class AudioService extends ChangeNotifier {
}
}
// ---------- 播放模式切换 ----------
// ---- 切换播放模式 ----
void togglePlayMode() {
switch (_playMode) {
case PlayMode.sequential:
@@ -100,7 +88,7 @@ class AudioService extends ChangeNotifier {
notifyListeners();
}
// ---------- 设置播放队列 ----------
// ---- 设置播放队列 ----
void setQueue(List<Song> queue, {int startIndex = 0}) {
if (queue.isEmpty) {
_clearQueue();
@@ -110,7 +98,6 @@ class AudioService extends ChangeNotifier {
_queue = List.from(queue);
_currentIndex = startIndex.clamp(0, _queue.length - 1);
// 初始化随机播放索引
_shuffledIndices = List.generate(_queue.length, (i) => i);
_shuffledIndices.shuffle();
_shuffledIndex = _shuffledIndices.indexOf(_currentIndex);
@@ -119,11 +106,9 @@ class AudioService extends ChangeNotifier {
_currentIndex = _shuffledIndices[0];
}
// 播放当前歌曲
_playCurrent();
}
// ---------- 清空队列 ----------
void _clearQueue() {
_queue.clear();
_currentIndex = -1;
@@ -132,18 +117,15 @@ class AudioService extends ChangeNotifier {
stopPlay();
}
// ---------- 播放指定歌曲(外部入口) ----------
// ---- 播放指定歌曲 ----
Future<void> playSong(Song song) async {
// 如果当前队列不包含这首歌,替换队列
if (_queue.isEmpty || _queue[_currentIndex].id != song.id) {
setQueue([song], startIndex: 0);
} else {
// 如果已经在队列中,直接播放
_playCurrent();
}
}
// ---------- 播放当前索引歌曲 ----------
void _playCurrent() {
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
stopPlay();
@@ -152,9 +134,11 @@ class AudioService extends ChangeNotifier {
final song = _queue[_currentIndex];
_currentSong = song;
_position = Duration.zero;
_duration = Duration.zero;
_bufferedPosition = Duration.zero;
// ⭐ 重置进度(用 ValueNotifier
positionNotifier.value = Duration.zero;
durationNotifier.value = Duration.zero;
bufferedNotifier.value = Duration.zero;
_startListening();
notifyListeners();
@@ -163,15 +147,13 @@ class AudioService extends ChangeNotifier {
return;
}
// 由 PlaybackService 实际播放,状态由流更新
PlaybackService().play(song.url!);
}
// ---------- 播放下一首 ----------
// ---- 下一首 ----
void next() {
if (_queue.isEmpty) return;
// 随机模式
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
@@ -181,17 +163,15 @@ class AudioService extends ChangeNotifier {
return;
}
// 顺序/单曲模式
final nextIdx = (_currentIndex + 1) % _queue.length;
_currentIndex = nextIdx;
_playCurrent();
}
// ---------- 播放上一首 ----------
// ---- 上一首 ----
void previous() {
if (_queue.isEmpty) return;
// 随机模式
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return;
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
@@ -205,7 +185,6 @@ class AudioService extends ChangeNotifier {
return;
}
// 顺序/单曲模式
final prevIdx = (_currentIndex - 1) % _queue.length;
if (prevIdx < 0) {
_currentIndex = _queue.length - 1;
@@ -215,7 +194,7 @@ class AudioService extends ChangeNotifier {
_playCurrent();
}
// ---------- 播放/暂停切换 ----------
// ---- 播放/暂停 ----
void togglePlay() {
if (_currentSong == null) return;
@@ -224,39 +203,21 @@ class AudioService extends ChangeNotifier {
} else {
PlaybackService().resume();
}
// 状态由 player.stream.playing 更新
}
// ---------- 停止播放 ----------
void stopPlay() {
_currentSong = null;
_isPlaying = false;
_position = Duration.zero;
_duration = Duration.zero;
_bufferedPosition = Duration.zero;
positionNotifier.value = Duration.zero;
durationNotifier.value = Duration.zero;
bufferedNotifier.value = Duration.zero;
_stopListening();
notifyListeners();
}
// ---------- Seek ----------
void seekTo(Duration position) {
PlaybackService().seek(position);
_position = position;
notifyListeners();
}
// ---------- 处理播放结束 ----------
void _onPlaybackCompleted() {
if (_queue.isEmpty) return;
// 单曲循环模式
if (_playMode == PlayMode.repeatOne) {
_playCurrent();
return;
}
// 其他模式:播放下一首
next();
positionNotifier.value = position;
}
void clearQueue() {
@@ -268,7 +229,7 @@ class AudioService extends ChangeNotifier {
notifyListeners();
}
// ---------- 监听 media_kit 状态 ----------
// ---- 监听 media_kit 状态 ----
void _startListening() {
if (_listening) return;
_listening = true;
@@ -279,39 +240,30 @@ class AudioService extends ChangeNotifier {
player.stream.playing.listen((playing) {
if (_isPlaying != playing) {
_isPlaying = playing;
notifyListeners();
notifyListeners(); // 只有播放状态变化才刷新
}
}),
);
// ⭐ 进度更新:只更新 ValueNotifier,不触发全局重建
_subscriptions.add(
player.stream.position.listen((position) {
if (_position != position) {
_position = position;
notifyListeners();
}
positionNotifier.value = position;
}),
);
_subscriptions.add(
player.stream.duration.listen((duration) {
if (_duration != duration) {
_duration = duration;
notifyListeners();
}
durationNotifier.value = duration;
}),
);
_subscriptions.add(
player.stream.buffer.listen((buffer) {
if (_bufferedPosition != buffer) {
_bufferedPosition = buffer;
notifyListeners();
}
bufferedNotifier.value = buffer;
}),
);
// ✅ 播放结束监听
_subscriptions.add(
player.stream.completed.listen((_) {
_onPlaybackCompleted();
@@ -327,9 +279,23 @@ class AudioService extends ChangeNotifier {
_subscriptions.clear();
}
// ---------- 资源释放 ----------
void _onPlaybackCompleted() {
if (_queue.isEmpty) return;
if (_playMode == PlayMode.repeatOne) {
_playCurrent();
return;
}
next();
}
@override
void dispose() {
_stopListening();
positionNotifier.dispose();
durationNotifier.dispose();
bufferedNotifier.dispose();
PlaybackService().dispose();
super.dispose();
}
+7 -10
View File
@@ -1,3 +1,4 @@
// lib/widgets/global_mini_player.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
@@ -8,15 +9,16 @@ class GlobalMiniPlayer extends StatelessWidget {
@override
Widget build(BuildContext context) {
final audioService = context.watch<AudioService>();
final song = audioService.currentSong;
// ⭐ 用 Selector 只监听 currentSong(低频变化)
final song = context.select<AudioService, Song?>((s) => s.currentSong);
// ⭐ 只监听播放状态(低频变化)
final isPlaying = context.select<AudioService, bool>((s) => s.isPlaying);
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Stack(
clipBehavior: Clip.none,
children: [
// 底层遮罩
Positioned(
left: 0,
right: 0,
@@ -26,7 +28,6 @@ class GlobalMiniPlayer extends StatelessWidget {
color: const Color(0xFF1A1F1E),
),
),
// 上层主体
Positioned(
left: 0,
right: 0,
@@ -38,7 +39,6 @@ class GlobalMiniPlayer extends StatelessWidget {
child: Container(
height: 56,
color: const Color(0xFF1A1F1E),
// ⭐ 用 Material 包裹 InkWell,获得更好的点击反馈
child: Material(
color: Colors.transparent,
child: InkWell(
@@ -47,7 +47,6 @@ class GlobalMiniPlayer extends StatelessWidget {
navigatorKey.currentState?.pushNamed('/player');
}
},
// ⭐ 让整个区域都响应点击,包括空白部分
highlightColor: Colors.white.withOpacity(0.05),
splashColor: Colors.white.withOpacity(0.1),
child: Row(
@@ -81,7 +80,6 @@ class GlobalMiniPlayer extends StatelessWidget {
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
// ⭐ 强制去掉下划线
decoration: TextDecoration.none,
),
maxLines: 1,
@@ -94,7 +92,6 @@ class GlobalMiniPlayer extends StatelessWidget {
color: song != null
? Colors.grey[400]
: Colors.grey[600],
// ⭐ 强制去掉下划线
decoration: TextDecoration.none,
),
maxLines: 1,
@@ -105,7 +102,7 @@ class GlobalMiniPlayer extends StatelessWidget {
),
IconButton(
icon: Icon(
song != null && audioService.isPlaying
song != null && isPlaying
? Icons.pause
: Icons.play_arrow,
color: song != null ? Colors.white : Colors.grey[600],
@@ -113,7 +110,7 @@ class GlobalMiniPlayer extends StatelessWidget {
),
onPressed: () {
if (song != null) {
audioService.togglePlay();
context.read<AudioService>().togglePlay();
}
},
),
+264
View File
@@ -9,6 +9,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -17,6 +25,38 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
audio_service:
dependency: "direct main"
description:
name: audio_service
sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.18.19"
audio_service_platform_interface:
dependency: transitive
description:
name: audio_service_platform_interface
sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.3"
audio_service_web:
dependency: transitive
description:
name: audio_service_web
sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.4"
audio_session:
dependency: "direct main"
description:
name: audio_session
sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.25"
boolean_selector:
dependency: transitive
description:
@@ -41,6 +81,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -118,6 +166,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.2"
flutter_lints:
dependency: "direct dev"
description:
@@ -136,6 +192,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
@@ -160,6 +224,38 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.8.0"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -192,6 +288,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -288,6 +392,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.0"
path:
dependency: transitive
description:
@@ -296,6 +416,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@@ -320,6 +464,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.flutter-io.cn"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.6.1"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.4+1"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.4.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.2"
petitparser:
dependency: transitive
description:
@@ -360,6 +552,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.28.0"
safe_local_storage:
dependency: transitive
description:
@@ -437,6 +653,46 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.11"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3+1"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
stack_trace:
dependency: transitive
description:
@@ -557,6 +813,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+5
View File
@@ -36,6 +36,11 @@ dependencies:
provider: ^6.1.2
dio: ^5.4.0 # HTTP 客户端
xml: ^6.5.0
audio_service: ^0.18.13
audio_session: ^0.1.21
permission_handler: ^11.3.1
flutter_cache_manager: ^3.3.1
sqflite: ^2.3.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.