自定义线路与线路优化
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
|
||||
/// 音频预览页面
|
||||
@@ -43,7 +44,11 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
player.open(Media(url), play: true);
|
||||
await CustomLineService.instance.configureMediaPlayerProxy(
|
||||
player,
|
||||
url,
|
||||
);
|
||||
await player.open(Media(url), play: true);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
@@ -104,11 +109,7 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
),
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.white54),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
@@ -140,14 +141,17 @@ class AudioPlayerWidget extends StatefulWidget {
|
||||
final Player player;
|
||||
final String fileName;
|
||||
|
||||
const AudioPlayerWidget({super.key, required this.player, required this.fileName});
|
||||
const AudioPlayerWidget({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
|
||||
}
|
||||
|
||||
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -155,10 +159,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
const Color(0xFF1A1A2E),
|
||||
const Color(0xFF16213E),
|
||||
],
|
||||
colors: [const Color(0xFF1A1A2E), const Color(0xFF16213E)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -246,12 +247,18 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
return SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 4,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 8,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 16,
|
||||
),
|
||||
activeTrackColor: const Color(0xFFE94560),
|
||||
inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
|
||||
thumbColor: const Color(0xFFE94560),
|
||||
overlayColor: const Color(0xFFE94560).withValues(alpha: 0.3),
|
||||
overlayColor: const Color(
|
||||
0xFFE94560,
|
||||
).withValues(alpha: 0.3),
|
||||
),
|
||||
child: Slider(
|
||||
value: position.inMilliseconds.toDouble(),
|
||||
@@ -259,9 +266,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
? duration.inMilliseconds.toDouble()
|
||||
: 1.0,
|
||||
onChanged: (value) {
|
||||
widget.player.seek(
|
||||
Duration(milliseconds: value.toInt()),
|
||||
);
|
||||
widget.player.seek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:highlight/highlight.dart';
|
||||
import 'package:highlight/languages/all.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import '../../../core/utils/file_type_utils.dart';
|
||||
|
||||
@@ -62,6 +63,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
if (urls.isEmpty) throw Exception('获取URL为空');
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
final responseContent = await http.get(Uri.parse(url));
|
||||
if (responseContent.statusCode != 200) {
|
||||
@@ -93,7 +95,8 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
_codeController = CodeController(text: _content, language: _languageMode);
|
||||
}
|
||||
|
||||
int _countLines(String text) => text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
|
||||
int _countLines(String text) =>
|
||||
text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
|
||||
|
||||
Mode _detectLanguageMode(String fileName) {
|
||||
final ext = FileTypeUtils.getExtension(fileName);
|
||||
@@ -109,7 +112,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
|
||||
// 自动初始化布局逻辑
|
||||
if (!_hasInitializedLayout && !_isLoading) {
|
||||
_showLineNumbers = screenWidth >= 600;
|
||||
@@ -122,7 +125,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
// 计算行数的位数,并根据当前字体大小分配宽度
|
||||
int digits = _lineCount.toString().length;
|
||||
// 这里的 0.7 是字体宽高的约数比例,20 是左右边距预留
|
||||
lineNumberWidth = (digits * (_fontSize * 0.7)) + 15;
|
||||
lineNumberWidth = (digits * (_fontSize * 0.7)) + 15;
|
||||
if (lineNumberWidth < 35) lineNumberWidth = 35;
|
||||
}
|
||||
|
||||
@@ -131,16 +134,21 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
appBar: AppBar(
|
||||
backgroundColor: _backgroundColor,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(
|
||||
color: Colors.white,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
title: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.file.name, style: const TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center,),
|
||||
if (!_isLoading)
|
||||
Text('$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
|
||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12), textAlign: TextAlign.center,),
|
||||
Text(
|
||||
widget.file.name,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (!_isLoading)
|
||||
Text(
|
||||
'$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
|
||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -159,60 +167,64 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
|
||||
/// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿
|
||||
Widget _buildCodeEditor(double lineNumberWidth) {
|
||||
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
|
||||
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
|
||||
int digits = _lineCount.toString().length;
|
||||
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
|
||||
double stableWidth = _showLineNumbers
|
||||
? (digits * (_fontSize * 0.75)) + 46
|
||||
: 0;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: _backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
|
||||
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
|
||||
int digits = _lineCount.toString().length;
|
||||
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
|
||||
double stableWidth = _showLineNumbers
|
||||
? (digits * (_fontSize * 0.75)) + 46
|
||||
: 0;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: _backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
||||
),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
||||
),
|
||||
child: CodeTheme(
|
||||
data: CodeThemeData(styles: {...atomOneDarkTheme}),
|
||||
child: Scrollbar(
|
||||
child: CodeTheme(
|
||||
data: CodeThemeData(styles: {...atomOneDarkTheme}),
|
||||
child: Scrollbar(
|
||||
controller: _customCodeScrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _customCodeScrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _customCodeScrollController,
|
||||
child: CodeField(
|
||||
controller: _codeController!,
|
||||
child: CodeField(
|
||||
controller: _codeController!,
|
||||
textStyle: TextStyle(
|
||||
fontFamily: 'SourceCodePro',
|
||||
fontSize: _fontSize,
|
||||
height: 1.5,
|
||||
),
|
||||
enabled: false,
|
||||
readOnly: true,
|
||||
background: Colors.transparent,
|
||||
// 关键点 1:调整行号样式
|
||||
lineNumberStyle: LineNumberStyle(
|
||||
width: stableWidth,
|
||||
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
|
||||
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
|
||||
textStyle: TextStyle(
|
||||
fontFamily: 'SourceCodePro',
|
||||
fontSize: _fontSize,
|
||||
height: 1.5,
|
||||
),
|
||||
enabled: false,
|
||||
readOnly: true,
|
||||
background: Colors.transparent,
|
||||
// 关键点 1:调整行号样式
|
||||
lineNumberStyle: LineNumberStyle(
|
||||
width: stableWidth,
|
||||
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
|
||||
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
|
||||
textStyle: TextStyle(
|
||||
color: _showLineNumbers ? Colors.grey.shade600 : Colors.transparent,
|
||||
fontSize: _fontSize * 0.8,
|
||||
height: 1.5, // 必须和正文高度完全一致
|
||||
// 核心修复:通过强制单词不换行来防止数字断裂
|
||||
fontFeatures: const [FontFeature.tabularFigures()], // 使用等宽数字
|
||||
),
|
||||
color: _showLineNumbers
|
||||
? Colors.grey.shade600
|
||||
: Colors.transparent,
|
||||
fontSize: _fontSize * 0.8,
|
||||
height: 1.5, // 必须和正文高度完全一致
|
||||
// 核心修复:通过强制单词不换行来防止数字断裂
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
], // 使用等宽数字
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建功能组合按钮
|
||||
Widget _buildExpandableFab() {
|
||||
@@ -225,7 +237,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
heroTag: 'font_up',
|
||||
mini: true,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
onPressed: () => setState(() { if(_fontSize < 30) _fontSize++; }),
|
||||
onPressed: () => setState(() {
|
||||
if (_fontSize < 30) _fontSize++;
|
||||
}),
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 20),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -234,7 +248,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
heroTag: 'font_down',
|
||||
mini: true,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
onPressed: () => setState(() { if(_fontSize > 8) _fontSize--; }),
|
||||
onPressed: () => setState(() {
|
||||
if (_fontSize > 8) _fontSize--;
|
||||
}),
|
||||
child: const Icon(Icons.remove, color: Colors.white, size: 20),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -242,7 +258,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
FloatingActionButton(
|
||||
heroTag: 'line_toggle',
|
||||
mini: true,
|
||||
backgroundColor: _showLineNumbers ? Colors.blue : Colors.grey.shade800,
|
||||
backgroundColor: _showLineNumbers
|
||||
? Colors.blue
|
||||
: Colors.grey.shade800,
|
||||
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
|
||||
child: Icon(
|
||||
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
|
||||
@@ -253,4 +271,4 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import '../../../services/cache_manager_service.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
@@ -44,6 +45,7 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
|
||||
if (urls.isNotEmpty) {
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -197,7 +199,7 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
|
||||
// 2. 编写平滑缩放函数
|
||||
void _smoothScale(PhotoViewController photoController, double delta) {
|
||||
// 如果正在动画中,忽略新的滚轮脉冲,防止冲突
|
||||
if (_isAnimating) return;
|
||||
if (_isAnimating) return;
|
||||
_isAnimating = true;
|
||||
|
||||
// 计算目标缩放值
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:markdown_widget/widget/blocks/leaf/link.dart';
|
||||
import 'package:markdown_widget/widget/markdown.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
|
||||
class MarkdownPreviewPage extends StatefulWidget {
|
||||
@@ -30,7 +31,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final _tocController = TocController();
|
||||
bool _isDarkMode = false;
|
||||
|
||||
|
||||
// 控制目录是否显示
|
||||
bool _isTocVisible = false;
|
||||
// 标记是否已经根据屏幕宽度进行了初始化
|
||||
@@ -62,6 +63,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
final responseContent = await http.get(Uri.parse(url));
|
||||
if (responseContent.statusCode != 200) {
|
||||
@@ -104,7 +106,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
backgroundColor: bgColor,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: isDark ? Colors.white : Colors.black87),
|
||||
icon: Icon(
|
||||
Icons.arrow_back,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Column(
|
||||
@@ -120,13 +125,19 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!_isLoading && _error == null)
|
||||
Text('Markdown 预览', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Text(
|
||||
'Markdown 预览',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!_isLoading && _error == null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy, color: isDark ? Colors.white : Colors.black87),
|
||||
icon: Icon(
|
||||
Icons.copy,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
|
||||
),
|
||||
],
|
||||
@@ -134,8 +145,8 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _buildErrorWidget()
|
||||
: _buildResponsiveBody(isWideScreen, screenWidth),
|
||||
? _buildErrorWidget()
|
||||
: _buildResponsiveBody(isWideScreen, screenWidth),
|
||||
floatingActionButton: _buildFAB(isDark),
|
||||
);
|
||||
}
|
||||
@@ -143,7 +154,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
// 构建响应式主体
|
||||
Widget _buildResponsiveBody(bool isWideScreen, double screenWidth) {
|
||||
final double tocWidth = isWideScreen ? 300 : screenWidth * 0.7;
|
||||
|
||||
|
||||
// 预定义暗色/亮色下的文字颜色
|
||||
final Color textColor = _isDarkMode ? Colors.white : Colors.black87;
|
||||
final Color subTextColor = _isDarkMode ? Colors.white70 : Colors.black54;
|
||||
@@ -159,10 +170,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: _isDarkMode ? Colors.white10 : Colors.grey.withValues(alpha: 0.2)
|
||||
color: _isDarkMode
|
||||
? Colors.white10
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
color: _isDarkMode ? const Color(0xFF252525) : Colors.grey.shade50,
|
||||
color: _isDarkMode
|
||||
? const Color(0xFF252525)
|
||||
: Colors.grey.shade50,
|
||||
),
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
@@ -178,7 +193,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: _isDarkMode ? Colors.white10 : Colors.grey.shade300),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: _isDarkMode ? Colors.white10 : Colors.grey.shade300,
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
@@ -198,9 +216,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
bodySmall: TextStyle(color: subTextColor),
|
||||
),
|
||||
),
|
||||
child: TocWidget(
|
||||
controller: _tocController,
|
||||
),
|
||||
child: TocWidget(controller: _tocController),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -228,8 +244,11 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
}
|
||||
|
||||
Widget _buildMarkdownWidget(String content) {
|
||||
final config = _isDarkMode ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig;
|
||||
CodeWrapperWidget codeWrapper(child, text, language) => CodeWrapperWidget(child, text, language);
|
||||
final config = _isDarkMode
|
||||
? MarkdownConfig.darkConfig
|
||||
: MarkdownConfig.defaultConfig;
|
||||
CodeWrapperWidget codeWrapper(child, text, language) =>
|
||||
CodeWrapperWidget(child, text, language);
|
||||
|
||||
return MarkdownWidget(
|
||||
data: content,
|
||||
@@ -237,7 +256,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
config: config.copy(
|
||||
configs: [
|
||||
_isDarkMode
|
||||
? PreConfig.darkConfig.copy(theme: a11yLightTheme, wrapper: codeWrapper)
|
||||
? PreConfig.darkConfig.copy(
|
||||
theme: a11yLightTheme,
|
||||
wrapper: codeWrapper,
|
||||
)
|
||||
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
|
||||
LinkConfig(
|
||||
style: TextStyle(
|
||||
@@ -266,12 +288,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFAB(bool isDark) {
|
||||
Widget _buildFAB(bool isDark) {
|
||||
if (_isLoading || _error != null) return const SizedBox.shrink();
|
||||
|
||||
|
||||
// 定义统一的按钮背景颜色,增加视觉一致性
|
||||
final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue;
|
||||
final Color inactiveColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200;
|
||||
final Color inactiveColor = isDark
|
||||
? Colors.grey.shade800
|
||||
: Colors.grey.shade200;
|
||||
final Color iconColor = isDark ? Colors.white : Colors.black87;
|
||||
|
||||
return Column(
|
||||
@@ -285,7 +309,7 @@ Widget _buildFAB(bool isDark) {
|
||||
elevation: 2, // 稍微降低阴影,看起来更精致
|
||||
onPressed: () => setState(() => _isDarkMode = !_isDarkMode),
|
||||
child: Icon(
|
||||
isDark ? Icons.light_mode : Icons.dark_mode,
|
||||
isDark ? Icons.light_mode : Icons.dark_mode,
|
||||
color: iconColor,
|
||||
size: 20, // 微调图标大小
|
||||
),
|
||||
@@ -300,7 +324,7 @@ Widget _buildFAB(bool isDark) {
|
||||
elevation: 2,
|
||||
onPressed: () => setState(() => _isTocVisible = !_isTocVisible),
|
||||
child: Icon(
|
||||
Icons.format_list_bulleted,
|
||||
Icons.format_list_bulleted,
|
||||
color: _isTocVisible ? Colors.white : iconColor,
|
||||
size: 20,
|
||||
),
|
||||
@@ -308,4 +332,4 @@ Widget _buildFAB(bool isDark) {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
|
||||
/// PDF预览页面
|
||||
@@ -37,6 +38,7 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
|
||||
if (urls.isNotEmpty) {
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import 'widgets/video_controls_overlay.dart';
|
||||
|
||||
@@ -45,7 +46,11 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
player.open(Media(url), play: true);
|
||||
await CustomLineService.instance.configureMediaPlayerProxy(
|
||||
player,
|
||||
url,
|
||||
);
|
||||
await player.open(Media(url), play: true);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
@@ -78,37 +83,42 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.white),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_loadVideoUrl();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: ExcludeSemantics(
|
||||
child: Video(
|
||||
controller: controller,
|
||||
controls: (state) => VideoControlsOverlay(state: state, title: widget.file.name),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_loadVideoUrl();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ExcludeSemantics(
|
||||
child: Video(
|
||||
controller: controller,
|
||||
controls: (state) =>
|
||||
VideoControlsOverlay(state: state, title: widget.file.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../services/api_service.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/storage_service.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/desktop_constrained.dart';
|
||||
@@ -45,6 +46,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
_NetworkDiagnostics? _networkDiagnostics;
|
||||
_ServiceDiagnostics? _serviceDiagnostics;
|
||||
_CustomLineDiagnostics? _customDiagnostics;
|
||||
String? _selectedCustomNodeId;
|
||||
String? _networkError;
|
||||
String? _serviceError;
|
||||
String? _customError;
|
||||
@@ -52,6 +54,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_loadSelectedCustomNode());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
|
||||
}
|
||||
|
||||
@@ -122,7 +125,9 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
loading: _customLoading,
|
||||
error: _customError,
|
||||
diagnostics: _customDiagnostics,
|
||||
selectedNodeId: _selectedCustomNodeId,
|
||||
onRefresh: _runCustomLineTest,
|
||||
onSelectNode: _selectCustomNode,
|
||||
onOpenTelegram: () => _openExternalUrl(_telegramUri),
|
||||
),
|
||||
},
|
||||
@@ -173,6 +178,43 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSelectedCustomNode() async {
|
||||
final selected = await CustomLineService.instance.getSelectedNode();
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedCustomNodeId = selected?.id);
|
||||
}
|
||||
|
||||
Future<void> _selectCustomNode(_CustomNodeProbe? probe) async {
|
||||
if (probe == null) {
|
||||
await CustomLineService.instance.clearSelectedNode();
|
||||
if (mounted) setState(() => _selectedCustomNodeId = null);
|
||||
ToastHelper.success('已关闭自定义线路节点');
|
||||
return;
|
||||
}
|
||||
|
||||
if (probe.successCount <= 0) {
|
||||
ToastHelper.failure('该节点当前不可用,无法选择');
|
||||
return;
|
||||
}
|
||||
if (!probe.node.canRewriteDownloadUrl) {
|
||||
ToastHelper.failure('该节点未配置可用于下载加速的 IP 地址');
|
||||
return;
|
||||
}
|
||||
|
||||
await CustomLineService.instance.setSelectedNode(
|
||||
probe.node.toSelectedNode(),
|
||||
);
|
||||
final endpoint = await CustomLineService.instance
|
||||
.activateSelectedNodeForHost(probe.node.host);
|
||||
if (endpoint == null) {
|
||||
await CustomLineService.instance.clearSelectedNode();
|
||||
ToastHelper.failure('节点启用失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
if (mounted) setState(() => _selectedCustomNodeId = probe.node.id);
|
||||
ToastHelper.success('已选择节点:${probe.node.displayName}');
|
||||
}
|
||||
|
||||
bool _isUserGroup(String groupName) =>
|
||||
groupName.trim().toLowerCase() == 'user';
|
||||
|
||||
@@ -229,9 +271,11 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
final nodes = await _fetchCustomNodes(
|
||||
clientId: clientId,
|
||||
groupName: groupName,
|
||||
userId: auth.user!.id,
|
||||
);
|
||||
final probes = await Future.wait(nodes.map(_testCustomNode));
|
||||
final recommended = _bestCustomNode(probes);
|
||||
final selectedNodeId = await _resolveSelectedCustomNodeId(probes);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -240,6 +284,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
nodes: probes,
|
||||
recommended: recommended,
|
||||
);
|
||||
_selectedCustomNodeId = selectedNodeId;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _customError = e.toString());
|
||||
@@ -248,9 +293,29 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _resolveSelectedCustomNodeId(
|
||||
List<_CustomNodeProbe> probes,
|
||||
) async {
|
||||
final selectable = probes
|
||||
.where(
|
||||
(probe) => probe.successCount > 0 && probe.node.canRewriteDownloadUrl,
|
||||
)
|
||||
.toList();
|
||||
|
||||
final current = await CustomLineService.instance.getSelectedNode();
|
||||
if (current != null &&
|
||||
selectable.any((probe) => probe.node.id == current.id)) {
|
||||
return current.id;
|
||||
}
|
||||
|
||||
await CustomLineService.instance.clearSelectedNode();
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<_CustomNode>> _fetchCustomNodes({
|
||||
required String clientId,
|
||||
required String groupName,
|
||||
required String userId,
|
||||
}) async {
|
||||
final handshakeResponse = await http
|
||||
.post(
|
||||
@@ -277,7 +342,10 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
|
||||
final nodesResponse = await http
|
||||
.get(
|
||||
_ipNodeApiUri('/api/v1/nodes', queryParameters: {'group': groupName}),
|
||||
_ipNodeApiUri(
|
||||
'/api/v1/nodes',
|
||||
queryParameters: {'group': groupName, 'user_id': userId},
|
||||
),
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Client-UUID': clientId,
|
||||
@@ -834,7 +902,9 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
final bool loading;
|
||||
final String? error;
|
||||
final _CustomLineDiagnostics? diagnostics;
|
||||
final String? selectedNodeId;
|
||||
final VoidCallback onRefresh;
|
||||
final ValueChanged<_CustomNodeProbe?> onSelectNode;
|
||||
final VoidCallback onOpenTelegram;
|
||||
|
||||
const _CustomLinePanel({
|
||||
@@ -842,7 +912,9 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
required this.loading,
|
||||
required this.error,
|
||||
required this.diagnostics,
|
||||
required this.selectedNodeId,
|
||||
required this.onRefresh,
|
||||
required this.onSelectNode,
|
||||
required this.onOpenTelegram,
|
||||
});
|
||||
|
||||
@@ -868,11 +940,21 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
value: recommended.node.displayName,
|
||||
valueColor: Colors.green,
|
||||
),
|
||||
_InfoRow(label: '节点地址', value: recommended.node.endpointLabel),
|
||||
_InfoRow(label: '地区', value: recommended.node.locationLabel),
|
||||
_InfoRow(label: '运营商', value: recommended.node.carrierLabel),
|
||||
_InfoRow(label: '平均延迟', value: recommended.latencyLabel),
|
||||
_InfoRow(label: '丢包率', value: recommended.lossLabel),
|
||||
_InfoRow(label: '样本', value: recommended.sampleLabel),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed:
|
||||
recommended.successCount > 0 &&
|
||||
recommended.node.canRewriteDownloadUrl
|
||||
? () => onSelectNode(recommended)
|
||||
: null,
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('使用推荐节点'),
|
||||
),
|
||||
),
|
||||
] else if (!loading && data != null) ...[
|
||||
const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'),
|
||||
] else if (!loading && error == null) ...[
|
||||
@@ -880,6 +962,24 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
if (data != null && selectedNodeId != null)
|
||||
_SectionCard(
|
||||
title: '当前选择',
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: '节点',
|
||||
value: data.selectedNodeName(selectedNodeId!) ?? '已选择节点',
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onSelectNode(null),
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('关闭自定义线路'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_SectionCard(title: '可选节点', children: _availableNodeChildren(data)),
|
||||
_SectionCard(
|
||||
title: '自定义节点',
|
||||
@@ -923,7 +1023,11 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
return [
|
||||
for (var i = 0; i < data.nodes.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 18),
|
||||
_CustomNodeTile(probe: data.nodes[i]),
|
||||
_CustomNodeTile(
|
||||
probe: data.nodes[i],
|
||||
selected: data.nodes[i].node.id == selectedNodeId,
|
||||
onSelect: () => onSelectNode(data.nodes[i]),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -931,48 +1035,106 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
|
||||
class _CustomNodeTile extends StatelessWidget {
|
||||
final _CustomNodeProbe probe;
|
||||
final bool selected;
|
||||
final VoidCallback onSelect;
|
||||
|
||||
const _CustomNodeTile({required this.probe});
|
||||
const _CustomNodeTile({
|
||||
required this.probe,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final available = probe.successCount > 0;
|
||||
final statusColor = _statusColor(context, available);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
probe.node.displayName,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
final selectable = available && probe.node.canRewriteDownloadUrl;
|
||||
return InkWell(
|
||||
onTap: selectable ? onSelect : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
selected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: selected ? Colors.green : statusColor,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
probe.node.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (probe.node.dedicated)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3D6),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFE5B454),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'专线',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF8A5A00),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
available ? '可用' : '不可用',
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}',
|
||||
),
|
||||
if (available && !probe.node.canRewriteDownloadUrl) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'未配置可用于下载加速的 IP',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
available ? '可用' : '不可用',
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(probe.node.endpointLabel),
|
||||
const SizedBox(height: 4),
|
||||
Text('${probe.node.locationLabel} / ${probe.node.carrierLabel}'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1125,6 +1287,13 @@ class _CustomLineDiagnostics {
|
||||
required this.nodes,
|
||||
required this.recommended,
|
||||
});
|
||||
|
||||
String? selectedNodeName(String nodeId) {
|
||||
for (final probe in nodes) {
|
||||
if (probe.node.id == nodeId) return probe.node.displayName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomNode {
|
||||
@@ -1139,6 +1308,7 @@ class _CustomNode {
|
||||
final String province;
|
||||
final String carrier;
|
||||
final List<String> groups;
|
||||
final bool dedicated;
|
||||
final bool enabled;
|
||||
final String probeUrl;
|
||||
|
||||
@@ -1154,6 +1324,7 @@ class _CustomNode {
|
||||
required this.province,
|
||||
required this.carrier,
|
||||
required this.groups,
|
||||
required this.dedicated,
|
||||
required this.enabled,
|
||||
required this.probeUrl,
|
||||
});
|
||||
@@ -1171,6 +1342,7 @@ class _CustomNode {
|
||||
province: json['province']?.toString() ?? '',
|
||||
carrier: json['carrier']?.toString() ?? '',
|
||||
groups: _asStringList(json['groups']),
|
||||
dedicated: json['dedicated'] == true,
|
||||
enabled: json['enabled'] != false,
|
||||
probeUrl: json['probe_url']?.toString() ?? '',
|
||||
);
|
||||
@@ -1189,6 +1361,20 @@ class _CustomNode {
|
||||
return ip.trim();
|
||||
}
|
||||
|
||||
bool get canRewriteDownloadUrl =>
|
||||
ip.trim().isNotEmpty && host.trim().isNotEmpty;
|
||||
|
||||
SelectedCustomLineNode toSelectedNode() {
|
||||
return SelectedCustomLineNode(
|
||||
id: id,
|
||||
name: displayName,
|
||||
ip: ip.trim(),
|
||||
host: host.trim(),
|
||||
protocol: protocol.trim(),
|
||||
port: port,
|
||||
);
|
||||
}
|
||||
|
||||
String get endpointLabel {
|
||||
final endpoint = endpointHost;
|
||||
if (endpoint.isEmpty) return '未配置地址';
|
||||
|
||||
Reference in New Issue
Block a user