设置页此前没有任何入口,且内容全是占位。现在: - 首页右上角菜单按钮 → 设置页 - 缓存:标签条数、封面数量/占用、临时探测文件占用,一键清理(保留歌单与收藏) - 诊断:导出 版本/连接/缓存/当前播放/最近错误 到 txt,经 FileProvider 走系统分享 - 连接:显示 WebDAV 地址与用户,支持测试连接、退出登录 - 关于:版本、构建号、播放内核与 UI 许可说明 - 删除未被引用的 FullPlayerPage(与 player_page 重复) 真机验证:设置页正常打开,诊断导出成功调起系统分享面板。
368 lines
12 KiB
Dart
368 lines
12 KiB
Dart
// lib/pages/settings_page.dart
|
|
import 'dart:io';
|
|
|
|
import 'package:android_intent_plus/android_intent.dart';
|
|
import 'package:android_intent_plus/flag.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../database/song_database.dart';
|
|
import '../services/audio_service.dart';
|
|
import '../services/webdav_service.dart';
|
|
import '../utils/file_provider_utils.dart';
|
|
|
|
/// 与 pubspec.yaml 的 version 保持同步(发版时一起改)
|
|
const String kAppVersion = '1.1.0-dev.1';
|
|
const int kAppBuild = 2;
|
|
|
|
class SettingsPage extends StatefulWidget {
|
|
const SettingsPage({super.key});
|
|
|
|
@override
|
|
State<SettingsPage> createState() => _SettingsPageState();
|
|
}
|
|
|
|
class _SettingsPageState extends State<SettingsPage> {
|
|
bool _loading = true;
|
|
|
|
int _songRows = 0;
|
|
int _metadataRows = 0;
|
|
int _favoriteRows = 0;
|
|
int _artworkCount = 0;
|
|
int _artworkBytes = 0;
|
|
int _tempCount = 0;
|
|
int _tempBytes = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadStats();
|
|
}
|
|
|
|
Future<void> _loadStats() async {
|
|
setState(() => _loading = true);
|
|
try {
|
|
final db = await SongDatabase().database;
|
|
final songRows =
|
|
(await db.rawQuery('SELECT COUNT(*) AS c FROM songs')).first['c'];
|
|
final metaRows = (await db.rawQuery(
|
|
'SELECT COUNT(*) AS c FROM metadata_cache'))
|
|
.first['c'];
|
|
final favRows =
|
|
(await db.rawQuery('SELECT COUNT(*) AS c FROM favorites')).first['c'];
|
|
|
|
var artworkCount = 0, artworkBytes = 0;
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
final artDir = Directory('${docs.path}/artworks');
|
|
if (await artDir.exists()) {
|
|
await for (final entity in artDir.list()) {
|
|
if (entity is File) {
|
|
artworkCount++;
|
|
artworkBytes += await entity.length();
|
|
}
|
|
}
|
|
}
|
|
|
|
var tempCount = 0, tempBytes = 0;
|
|
final tmp = await getTemporaryDirectory();
|
|
if (await tmp.exists()) {
|
|
await for (final entity in tmp.list()) {
|
|
if (entity is File &&
|
|
entity.path.contains('/metadata_') &&
|
|
entity.path.endsWith('.part')) {
|
|
tempCount++;
|
|
tempBytes += await entity.length();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_songRows = songRows as int? ?? 0;
|
|
_metadataRows = metaRows as int? ?? 0;
|
|
_favoriteRows = favRows as int? ?? 0;
|
|
_artworkCount = artworkCount;
|
|
_artworkBytes = artworkBytes;
|
|
_tempCount = tempCount;
|
|
_tempBytes = tempBytes;
|
|
_loading = false;
|
|
});
|
|
} catch (e) {
|
|
debugPrint('⚠️ [Settings] 统计失败: $e');
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _clearMetadataCache() async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: const Color(0xFF1A1F1E),
|
|
title: const Text('清理元数据缓存', style: TextStyle(color: Colors.white)),
|
|
content: const Text(
|
|
'会删除歌曲标签缓存和封面文件,歌单与收藏不受影响。\n下次播放时重新抓取。',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('清理', style: TextStyle(color: Colors.redAccent)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
|
|
try {
|
|
final db = await SongDatabase().database;
|
|
await db.delete('songs');
|
|
await db.delete('metadata_cache');
|
|
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
final artDir = Directory('${docs.path}/artworks');
|
|
if (await artDir.exists()) {
|
|
await artDir.delete(recursive: true);
|
|
}
|
|
|
|
final tmp = await getTemporaryDirectory();
|
|
if (await tmp.exists()) {
|
|
await for (final entity in tmp.list()) {
|
|
if (entity is File &&
|
|
entity.path.contains('/metadata_') &&
|
|
entity.path.endsWith('.part')) {
|
|
await entity.delete();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('元数据缓存已清理')),
|
|
);
|
|
}
|
|
await _loadStats();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('清理失败: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _exportDiagnostics() async {
|
|
try {
|
|
final now = DateTime.now();
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
final file =
|
|
File('${docs.path}/diagnostics-${now.millisecondsSinceEpoch}.txt');
|
|
|
|
final song = AudioService().currentSong;
|
|
final buffer = StringBuffer()
|
|
..writeln('清听 诊断信息')
|
|
..writeln('时间: ${now.toIso8601String()}')
|
|
..writeln('版本: $kAppVersion ($kAppBuild)')
|
|
..writeln('平台: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
|
|
..writeln()
|
|
..writeln('[连接]')
|
|
..writeln('baseUrl: ${WebDAVService.instance.baseUrl ?? '(未配置)'}')
|
|
..writeln('username: ${WebDAVService.instance.username ?? '(未配置)'}')
|
|
..writeln('connected: ${WebDAVService.instance.isConnected}')
|
|
..writeln()
|
|
..writeln('[数据]')
|
|
..writeln('songs: $_songRows')
|
|
..writeln('metadata_cache: $_metadataRows')
|
|
..writeln('favorites: $_favoriteRows')
|
|
..writeln('artwork 文件: $_artworkCount 个 / ${_formatBytes(_artworkBytes)}')
|
|
..writeln('临时探测文件: $_tempCount 个 / ${_formatBytes(_tempBytes)}')
|
|
..writeln()
|
|
..writeln('[当前播放]')
|
|
..writeln('song: ${song?.title ?? '(无)'} - ${song?.artist ?? ''}')
|
|
..writeln('path: ${song?.id ?? ''}')
|
|
..writeln('position: ${AudioService().position}')
|
|
..writeln('duration: ${AudioService().duration}')
|
|
..writeln('lastError: ${AudioService().lastPlaybackError ?? '(无)'}');
|
|
|
|
await file.writeAsString(buffer.toString());
|
|
if (!mounted) return;
|
|
|
|
final uri = await FileProviderUtils.getContentUri(file);
|
|
if (uri != null) {
|
|
try {
|
|
final intent = AndroidIntent(
|
|
action: 'android.intent.action.SEND',
|
|
type: 'text/plain',
|
|
arguments: {
|
|
'android.intent.extra.STREAM': uri.toString(),
|
|
'android.intent.extra.SUBJECT': '清听诊断信息',
|
|
},
|
|
flags: const [Flag.FLAG_GRANT_READ_URI_PERMISSION],
|
|
);
|
|
await intent.launch();
|
|
return;
|
|
} catch (e) {
|
|
debugPrint('⚠️ [Settings] 分享失败: $e');
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: const Color(0xFF1A1F1E),
|
|
title: const Text('诊断信息已导出', style: TextStyle(color: Colors.white)),
|
|
content: SelectableText(
|
|
file.path,
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('好'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('导出失败: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _testConnection() async {
|
|
try {
|
|
await WebDAVService.instance.listDirectory(path: '/');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('连接正常')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('连接失败: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _logout() async {
|
|
await WebDAVService.instance.clearCredentials();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('已退出登录,下次进入会要求重新配置')),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatBytes(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0E1211),
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
title: const Text('设置'),
|
|
),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
children: [
|
|
_sectionTitle('缓存'),
|
|
ListTile(
|
|
leading: const Icon(Icons.cleaning_services_outlined,
|
|
color: Color(0xFF7C9A9E)),
|
|
title: const Text('元数据缓存'),
|
|
subtitle: Text(
|
|
'标签 $_songRows 首 · 封面 $_artworkCount 张 '
|
|
'(${_formatBytes(_artworkBytes)}) · 临时文件 ${_formatBytes(_tempBytes)}',
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
|
|
onTap: _clearMetadataCache,
|
|
),
|
|
_sectionTitle('诊断'),
|
|
ListTile(
|
|
leading: const Icon(Icons.bug_report_outlined,
|
|
color: Color(0xFF7C9A9E)),
|
|
title: const Text('导出诊断信息'),
|
|
subtitle: const Text(
|
|
'版本、连接状态、缓存统计、当前播放与最近错误',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
trailing: const Icon(Icons.ios_share, color: Colors.grey),
|
|
onTap: _exportDiagnostics,
|
|
),
|
|
_sectionTitle('连接'),
|
|
ListTile(
|
|
leading: const Icon(Icons.cloud_outlined,
|
|
color: Color(0xFF7C9A9E)),
|
|
title: Text(WebDAVService.instance.baseUrl ?? '(未配置)'),
|
|
subtitle: Text(
|
|
'用户 ${WebDAVService.instance.username ?? '-'} · '
|
|
'${WebDAVService.instance.isConnected ? '已连接' : '未连接'}',
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.wifi_tethering,
|
|
color: Color(0xFF7C9A9E)),
|
|
title: const Text('测试连接'),
|
|
onTap: _testConnection,
|
|
),
|
|
ListTile(
|
|
leading:
|
|
const Icon(Icons.logout, color: Colors.redAccent),
|
|
title: const Text('退出登录'),
|
|
subtitle: const Text(
|
|
'清除本机保存的 WebDAV 账号',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
onTap: _logout,
|
|
),
|
|
_sectionTitle('关于'),
|
|
ListTile(
|
|
leading:
|
|
const Icon(Icons.info_outline, color: Color(0xFF7C9A9E)),
|
|
title: const Text('清听'),
|
|
subtitle: const Text(
|
|
'版本 $kAppVersion ($kAppBuild)\n'
|
|
'播放内核 media_kit (libmpv, LGPL/GPL) · UI Flutter (BSD)',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sectionTitle(String text) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 20, 4, 8),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFFB8D4D0),
|
|
letterSpacing: 1.2,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|