暂时把bug先处理掉了,准备尝试实现最小播放目标

This commit is contained in:
2026-08-15 23:20:54 +08:00
parent 902788c736
commit 699ab7ce86
11 changed files with 313 additions and 130 deletions
@@ -0,0 +1,3 @@
kotlin version: 2.3.20
error message: Daemon compilation failed
+4 -5
View File
@@ -7,9 +7,7 @@ import 'pages/home_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// 初始化 media_kit
Player.init();
// 初始化播放服务
// media_kit 不需要静态 initPlaybackService 中会初始化 Player
PlaybackService().init();
runApp(
ChangeNotifierProvider(
@@ -32,8 +30,9 @@ class QTPlayerApp extends StatelessWidget {
colorScheme: const ColorScheme.dark(
primary: Color(0xFF7C9A9E),
secondary: Color(0xFFB8D4D0),
surface: Color(0xFF1A1F1E),
background: Color(0xFF0E1211),
surface: Color(0xFF1A1F1E), // 替代 background
// 用 onSurface 控制文字颜色
onSurface: Colors.white,
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1A1F1E),
-16
View File
@@ -1,16 +0,0 @@
class Song {
final String id;
final String title;
final String artist;
final String? url; // 以后放 content:// 或 https://
Song({required this.id, required this.title, required this.artist, this.url});
// 示例占位数据
static List<Song> get placeholderSongs => [
Song(id: '1', title: '晴天', artist: '周杰伦'),
Song(id: '2', title: '起风了', artist: '买辣椒也用券'),
Song(id: '3', title: 'Flower Dance', artist: 'DJ Okawari'),
Song(id: '4', title: '夜曲', artist: '周杰伦'),
];
}
-53
View File
@@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/song_model.dart';
import '../services/audio_service.dart';
import '../widgets/full_player_page.dart';
class PlaylistPage extends StatelessWidget {
const PlaylistPage({super.key});
@override
Widget build(BuildContext context) {
final songs = Song.placeholderSongs; // 后续替换成 WebDAV/SAF 列表
return Scaffold(
appBar: AppBar(
title: const Text('我的清听'),
actions: [
IconButton(
icon: const Icon(Icons.shuffle),
onPressed: () {}, // 以后实现随机
),
],
),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: songs.length,
itemBuilder: (context, index) {
final song = songs[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Color(0xFF2A3332),
child: Icon(Icons.audiotrack, size: 20, color: Colors.white54),
),
title: Text(song.title,
style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(song.artist,
style: TextStyle(color: Colors.grey[400], fontSize: 13)),
trailing: const Icon(Icons.more_vert, color: Colors.grey),
onTap: () {
// 1. 先更新服务,显示迷你条
context.read<AudioService>().playSong(song);
// 2. 再跳转全屏播放器
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const FullPlayerPage()),
);
},
);
},
),
);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
_isConnected = hasCred;
_statusText = hasCred ? '已连接' : '未连接';
if (hasCred) {
_baseUrlController.text = WebDAVService.instance._baseUrl ?? '';
_baseUrlController.text = WebDAVService.instance.baseUrl ?? '';
// 用户名不显示,保持隐私
}
});
+21 -5
View File
@@ -1,7 +1,19 @@
import 'package:flutter/material.dart';
import '../models/song_model.dart';
// 这是一个 ChangeNotifier,以后用来刷新底部的迷你播放条
class Song {
final String id;
final String title;
final String artist;
final String? url;
Song({
required this.id,
required this.title,
required this.artist,
this.url,
});
}
class AudioService extends ChangeNotifier {
Song? _currentSong;
bool _isPlaying = false;
@@ -9,16 +21,20 @@ class AudioService extends ChangeNotifier {
Song? get currentSong => _currentSong;
bool get isPlaying => _isPlaying;
// 占位方法:以后这里会调用 media_kit
void playSong(Song song) {
_currentSong = song;
_isPlaying = true;
notifyListeners(); // 刷新 UI
print('🎵 准备播放: ${song.title} - ${song.artist}');
notifyListeners();
}
void togglePlay() {
_isPlaying = !_isPlaying;
notifyListeners();
}
void stopPlay() {
_currentSong = null;
_isPlaying = false;
notifyListeners();
}
}
+46 -8
View File
@@ -1,5 +1,4 @@
import 'package:media_kit/media_kit.dart';
import 'package:media_kit/media_kit.dart' as media_kit;
class PlaybackService {
static final PlaybackService _instance = PlaybackService._();
@@ -7,22 +6,61 @@ class PlaybackService {
PlaybackService._();
late final Player _player;
bool get isInitialized => _player.state.playing;
bool _initialized = false;
void init() {
if (_initialized) return;
_player = Player();
_initialized = true;
}
Future<void> play(String url) async {
if (!_initialized) init();
await _player.open(Media(url));
await _player.play();
}
void pause() => _player.pause();
void resume() => _player.play();
void stop() => _player.stop();
void dispose() => _player.dispose();
void pause() {
if (_initialized) {
_player.pause();
}
}
// 状态流
Stream<PlayerState> get stateStream => _player.stream;
void resume() {
if (_initialized) {
_player.play();
}
}
void stop() {
if (_initialized) {
_player.stop();
}
}
void dispose() {
if (_initialized) {
_player.dispose();
_initialized = false;
}
}
// ✅ 直接返回 PlayerStream(它就是播放器的状态流)
PlayerStream get stateStream {
if (!_initialized) init();
return _player.stream;
}
// 获取当前播放状态
PlayerState get currentState {
if (!_initialized) {
return PlayerState(
playing: false,
position: Duration.zero,
duration: Duration.zero,
buffering: false,
);
}
return _player.state;
}
}
+116 -29
View File
@@ -1,5 +1,7 @@
import 'package:webdav_client/webdav_client.dart';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xml/xml.dart';
class WebDAVService {
static const String _keyBaseUrl = 'webdav_base_url';
@@ -11,10 +13,13 @@ class WebDAVService {
WebDAVService._();
WebDAVClient? _client;
Dio? _dio;
String? _baseUrl;
String? _username;
bool get isConnected => _client != null;
bool get isConnected => _dio != null;
String? get baseUrl => _baseUrl;
String? get username => _username;
// 保存凭据
Future<void> saveCredentials(
@@ -23,11 +28,16 @@ class WebDAVService {
await prefs.setString(_keyBaseUrl, baseUrl);
await prefs.setString(_keyUsername, username);
await prefs.setString(_keyPassword, password);
_baseUrl = baseUrl;
_client = WebDAVClient(
baseUri: Uri.parse(baseUrl),
credentials: '$username:$password',
);
_username = username;
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
headers: {
'Authorization':
'Basic ${base64Encode(utf8.encode('$username:$password'))}',
},
));
}
// 加载已保存的凭据
@@ -36,12 +46,17 @@ class WebDAVService {
final baseUrl = prefs.getString(_keyBaseUrl);
final username = prefs.getString(_keyUsername);
final password = prefs.getString(_keyPassword);
if (baseUrl != null && username != null && password != null) {
_baseUrl = baseUrl;
_client = WebDAVClient(
baseUri: Uri.parse(baseUrl),
credentials: '$username:$password',
);
_username = username;
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
headers: {
'Authorization':
'Basic ${base64Encode(utf8.encode('$username:$password'))}',
},
));
return true;
}
return false;
@@ -53,36 +68,108 @@ class WebDAVService {
await prefs.remove(_keyBaseUrl);
await prefs.remove(_keyUsername);
await prefs.remove(_keyPassword);
_client = null;
_dio = null;
_baseUrl = null;
_username = null;
}
// 获取音乐文件列表(仅 .mp3 .flac .m4a .ape .wav
// 获取音乐文件列表(通过 PROPFIND
Future<List<WebDAVFileItem>> getMusicFiles({String path = '/'}) async {
if (_client == null) throw Exception('WebDAV 未连接');
if (_dio == null) throw Exception('WebDAV 未连接');
final items = await _client!.listAll(recursive: true);
// PROPFIND 请求体(Depth: 1 表示获取子项)
final body = '''
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<resourcetype/>
<getcontentlength/>
<getlastmodified/>
</prop>
</propfind>
''';
final response = await _dio!.request(
path,
options: Options(
method: 'PROPFIND',
headers: {
'Depth': '1',
'Content-Type': 'application/xml; charset=utf-8',
},
),
data: body,
);
if (response.statusCode != 207) {
throw Exception('WebDAV 响应异常: ${response.statusCode}');
}
final xml = XmlDocument.parse(response.data as String);
final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus'];
return items
.where((item) =>
item.isFile &&
musicExtensions.any((ext) => item.path.toLowerCase().endsWith(ext)))
.map((item) => WebDAVFileItem(
path: item.path,
name: item.path.split('/').last,
size: item.size,
modified: item.modified,
))
.toList();
final items = <WebDAVFileItem>[];
// 查找所有响应项
for (final responseNode in xml.findAllElements('response')) {
final hrefNode = responseNode.findElements('href').firstOrNull;
if (hrefNode == null) continue;
String fullPath = Uri.decodeComponent(hrefNode.text.trim());
// 去掉 baseUrl 前缀,得到相对路径
String relativePath = fullPath;
if (_baseUrl != null) {
final baseUri = Uri.parse(_baseUrl!);
final fullUri = Uri.parse(fullPath);
relativePath = fullUri.path;
if (baseUri.path.isNotEmpty && relativePath.startsWith(baseUri.path)) {
relativePath = relativePath.substring(baseUri.path.length);
}
}
if (relativePath.isEmpty || relativePath == '/') continue;
// 检查是否是文件(非集合)
final resTypeNode = responseNode.findElements('resourcetype').firstOrNull;
final isCollection =
resTypeNode?.findElements('collection').isNotEmpty ?? false;
if (isCollection) continue;
// 检查扩展名
final fileName = relativePath.split('/').last;
if (!musicExtensions.any((ext) => fileName.toLowerCase().endsWith(ext)))
continue;
// 获取文件大小和修改时间
int? size;
DateTime? modified;
final sizeNode =
responseNode.findElements('getcontentlength').firstOrNull;
if (sizeNode != null) {
size = int.tryParse(sizeNode.text);
}
final modifiedNode =
responseNode.findElements('getlastmodified').firstOrNull;
if (modifiedNode != null) {
try {
modified = DateTime.parse(modifiedNode.text);
} catch (_) {}
}
items.add(WebDAVFileItem(
path: relativePath,
name: fileName,
size: size,
modified: modified,
));
}
return items;
}
// 获取文件的完整下载 URL(用于播放)
// 获取文件的完整 URL
String getFileUrl(String path) {
if (_baseUrl == null) throw Exception('WebDAV 未配置');
// 确保 baseUrl 末尾有 '/'
final base = _baseUrl!.endsWith('/') ? _baseUrl! : '$_baseUrl/';
// 去掉路径开头的 '/'
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
return '$base$cleanPath';
}
+3 -5
View File
@@ -8,7 +8,9 @@ class MiniPlayerBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final service = context.watch<AudioService>();
final song = service.currentSong!;
final song = service.currentSong;
if (song == null) return const SizedBox.shrink();
return Container(
height: 64,
@@ -21,7 +23,6 @@ class MiniPlayerBar extends StatelessWidget {
offset: const Offset(0, -4),
),
],
// 顶部微渐变(轻阴影)
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
@@ -35,7 +36,6 @@ class MiniPlayerBar extends StatelessWidget {
child: Row(
children: [
const SizedBox(width: 12),
// 封面占位(56px
Container(
width: 48,
height: 48,
@@ -47,7 +47,6 @@ class MiniPlayerBar extends StatelessWidget {
const Icon(Icons.music_note, color: Colors.white38, size: 24),
),
const SizedBox(width: 12),
// 歌曲信息
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -75,7 +74,6 @@ class MiniPlayerBar extends StatelessWidget {
],
),
),
// 控制按钮
IconButton(
icon: Icon(
service.isPlaying ? Icons.pause : Icons.play_arrow,
+116 -7
View File
@@ -97,6 +97,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
@@ -123,6 +131,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: transitive
description:
@@ -143,10 +156,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.9.1"
version: "4.8.0"
leak_tracker:
dependency: transitive
description:
@@ -283,6 +296,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
@@ -291,6 +328,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
@@ -323,6 +368,62 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.6"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.27"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -440,14 +541,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
xml:
xdg_directories:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
version: "1.1.0"
xml:
dependency: "direct main"
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.6.1"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.44.0"
+3 -1
View File
@@ -32,8 +32,10 @@ dependencies:
sdk: flutter
media_kit: ^1.1.10
media_kit_libs_audio: ^1.0.7
shared_preferences: ^2.2.2
provider: ^6.1.2
dio: ^5.4.0
dio: ^5.4.0 # HTTP 客户端
xml: ^6.5.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.