权限配置暂时到此,后台播放问题并入生命周期管理同步开发
This commit is contained in:
+142
-37
@@ -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');
|
||||
}
|
||||
|
||||
// ⭐ 创建 Handler(AudioService 稍后初始化)
|
||||
_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
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user