From e6c60a9d6d3c23b3537d02cabdf1a1fbeef8effb Mon Sep 17 00:00:00 2001 From: gongyun Date: Thu, 4 Jun 2026 17:52:16 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E7=BA=BF=E8=B7=AF?= =?UTF-8?q?=E4=B8=8E=E7=BA=BF=E8=B7=AF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/constants/storage_keys.dart | 1 + lib/main.dart | 2 + .../pages/preview/audio_preview_page.dart | 41 ++- .../pages/preview/document_preview_page.dart | 136 ++++---- .../pages/preview/image_preview_page.dart | 4 +- .../pages/preview/markdown_preview_page.dart | 68 ++-- .../pages/preview/pdf_preview_page.dart | 2 + .../pages/preview/video_preview_page.dart | 70 ++-- .../pages/settings/network_test_page.dart | 266 ++++++++++++--- lib/services/custom_line_service.dart | 223 +++++++++++++ lib/services/download_service.dart | 36 +- lib/services/file_service.dart | 16 + lib/services/host_mapping_proxy_service.dart | 314 ++++++++++++++++++ lib/services/server_service.dart | 47 ++- lib/services/thumbnail_service.dart | 18 +- test/custom_line_service_test.dart | 143 ++++++++ 16 files changed, 1184 insertions(+), 203 deletions(-) create mode 100644 lib/services/custom_line_service.dart create mode 100644 lib/services/host_mapping_proxy_service.dart create mode 100644 test/custom_line_service_test.dart diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index ceb6e45..ad29208 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -14,6 +14,7 @@ class StorageKeys { static const String downloadTasks = 'download_tasks'; static const String downloadWifiOnly = 'download_wifi_only'; static const String downloadRetries = 'download_retries'; + static const String selectedCustomLineNode = 'selected_custom_line_node'; // 任务记录 static const String taskRetentionDays = 'task_retention_days'; diff --git a/lib/main.dart b/lib/main.dart index c016aee..7b3426f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,6 +29,7 @@ import 'services/storage_service.dart'; import 'services/server_service.dart'; import 'services/cache_manager_service.dart'; import 'services/avatar_cache_service.dart'; +import 'services/host_mapping_proxy_service.dart'; import 'core/utils/video_fullscreen.dart'; import 'services/desktop_service.dart'; import 'router/app_router.dart'; @@ -51,6 +52,7 @@ Level _parseLogLevel(String level) { void main() async { WidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = HostMappingHttpOverrides(); await AppLogger.init(); final savedLevel = await StorageService.instance.getString( diff --git a/lib/presentation/pages/preview/audio_preview_page.dart b/lib/presentation/pages/preview/audio_preview_page.dart index 277bc08..b96eb15 100644 --- a/lib/presentation/pages/preview/audio_preview_page.dart +++ b/lib/presentation/pages/preview/audio_preview_page.dart @@ -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 { 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 { 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 createState() => _AudioPlayerWidgetState(); } class _AudioPlayerWidgetState extends State { - @override Widget build(BuildContext context) { return Container( @@ -155,10 +159,7 @@ class _AudioPlayerWidgetState extends State { 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 { 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 { ? duration.inMilliseconds.toDouble() : 1.0, onChanged: (value) { - widget.player.seek( - Duration(milliseconds: value.toInt()), - ); + widget.player.seek(Duration(milliseconds: value.toInt())); }, ), ); diff --git a/lib/presentation/pages/preview/document_preview_page.dart b/lib/presentation/pages/preview/document_preview_page.dart index 73c56c2..e765744 100644 --- a/lib/presentation/pages/preview/document_preview_page.dart +++ b/lib/presentation/pages/preview/document_preview_page.dart @@ -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 { if (urls.isEmpty) throw Exception('获取URL为空'); final urlData = urls[0] as Map; 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 { _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 { @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 { // 计算行数的位数,并根据当前字体大小分配宽度 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 { 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 { /// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿 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 { 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 { 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 { 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 { ], ); } -} \ No newline at end of file +} diff --git a/lib/presentation/pages/preview/image_preview_page.dart b/lib/presentation/pages/preview/image_preview_page.dart index afa67c0..5477a28 100644 --- a/lib/presentation/pages/preview/image_preview_page.dart +++ b/lib/presentation/pages/preview/image_preview_page.dart @@ -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 { if (urls.isNotEmpty) { final urlData = urls[0] as Map; final url = urlData['url'] as String; + await CustomLineService.instance.activateSelectedNodeForUrl(url); if (mounted) { setState(() { @@ -197,7 +199,7 @@ class _ImagePreviewPageState extends State { // 2. 编写平滑缩放函数 void _smoothScale(PhotoViewController photoController, double delta) { // 如果正在动画中,忽略新的滚轮脉冲,防止冲突 - if (_isAnimating) return; + if (_isAnimating) return; _isAnimating = true; // 计算目标缩放值 diff --git a/lib/presentation/pages/preview/markdown_preview_page.dart b/lib/presentation/pages/preview/markdown_preview_page.dart index 7b0c474..927d3ca 100644 --- a/lib/presentation/pages/preview/markdown_preview_page.dart +++ b/lib/presentation/pages/preview/markdown_preview_page.dart @@ -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 { final ScrollController _scrollController = ScrollController(); final _tocController = TocController(); bool _isDarkMode = false; - + // 控制目录是否显示 bool _isTocVisible = false; // 标记是否已经根据屏幕宽度进行了初始化 @@ -62,6 +63,7 @@ class _MarkdownPreviewPageState extends State { final urlData = urls[0] as Map; 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 { 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 { 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 { 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 { // 构建响应式主体 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 { 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 { ), ), ), - 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 { bodySmall: TextStyle(color: subTextColor), ), ), - child: TocWidget( - controller: _tocController, - ), + child: TocWidget(controller: _tocController), ), ), ), @@ -228,8 +244,11 @@ class _MarkdownPreviewPageState extends State { } 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 { 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 { ); } -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) { ], ); } -} \ No newline at end of file +} diff --git a/lib/presentation/pages/preview/pdf_preview_page.dart b/lib/presentation/pages/preview/pdf_preview_page.dart index 5b5fc5e..b94a162 100644 --- a/lib/presentation/pages/preview/pdf_preview_page.dart +++ b/lib/presentation/pages/preview/pdf_preview_page.dart @@ -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 { if (urls.isNotEmpty) { final urlData = urls[0] as Map; final url = urlData['url'] as String; + await CustomLineService.instance.activateSelectedNodeForUrl(url); if (mounted) { setState(() { diff --git a/lib/presentation/pages/preview/video_preview_page.dart b/lib/presentation/pages/preview/video_preview_page.dart index e9c208c..9e510cd 100644 --- a/lib/presentation/pages/preview/video_preview_page.dart +++ b/lib/presentation/pages/preview/video_preview_page.dart @@ -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 { 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 { 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), + ), + ), ); } } diff --git a/lib/presentation/pages/settings/network_test_page.dart b/lib/presentation/pages/settings/network_test_page.dart index ec878b9..184c31f 100644 --- a/lib/presentation/pages/settings/network_test_page.dart +++ b/lib/presentation/pages/settings/network_test_page.dart @@ -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 { _NetworkDiagnostics? _networkDiagnostics; _ServiceDiagnostics? _serviceDiagnostics; _CustomLineDiagnostics? _customDiagnostics; + String? _selectedCustomNodeId; String? _networkError; String? _serviceError; String? _customError; @@ -52,6 +54,7 @@ class _NetworkTestPageState extends State { @override void initState() { super.initState(); + unawaited(_loadSelectedCustomNode()); WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest()); } @@ -122,7 +125,9 @@ class _NetworkTestPageState extends State { loading: _customLoading, error: _customError, diagnostics: _customDiagnostics, + selectedNodeId: _selectedCustomNodeId, onRefresh: _runCustomLineTest, + onSelectNode: _selectCustomNode, onOpenTelegram: () => _openExternalUrl(_telegramUri), ), }, @@ -173,6 +178,43 @@ class _NetworkTestPageState extends State { } } + Future _loadSelectedCustomNode() async { + final selected = await CustomLineService.instance.getSelectedNode(); + if (!mounted) return; + setState(() => _selectedCustomNodeId = selected?.id); + } + + Future _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 { 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 { nodes: probes, recommended: recommended, ); + _selectedCustomNodeId = selectedNodeId; }); } catch (e) { if (mounted) setState(() => _customError = e.toString()); @@ -248,9 +293,29 @@ class _NetworkTestPageState extends State { } } + Future _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> _fetchCustomNodes({ required String clientId, required String groupName, + required String userId, }) async { final handshakeResponse = await http .post( @@ -277,7 +342,10 @@ class _NetworkTestPageState extends State { 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 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 '未配置地址'; diff --git a/lib/services/custom_line_service.dart b/lib/services/custom_line_service.dart new file mode 100644 index 0000000..b220e9e --- /dev/null +++ b/lib/services/custom_line_service.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:media_kit/media_kit.dart'; + +import '../core/constants/storage_keys.dart'; +import '../core/utils/app_logger.dart'; +import 'host_mapping_proxy_service.dart'; +import 'server_service.dart'; +import 'storage_service.dart'; + +class CustomLineService { + CustomLineService._(); + + static final CustomLineService instance = CustomLineService._(); + + HostMappingProxyEndpoint? get currentProxyEndpoint => + HostMappingProxyService.instance.endpoint; + + Future getSelectedNode() async { + final raw = await StorageService.instance.getString( + StorageKeys.selectedCustomLineNode, + ); + if (raw == null || raw.isEmpty) return null; + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + final node = SelectedCustomLineNode.fromJson(decoded); + if (!node.canRewriteDownloadUrl) return null; + return node; + } catch (_) { + return null; + } + } + + Future setSelectedNode(SelectedCustomLineNode node) async { + if (!node.canRewriteDownloadUrl) { + throw ArgumentError('The selected custom line node has no usable IP.'); + } + await StorageService.instance.setString( + StorageKeys.selectedCustomLineNode, + jsonEncode(node.toJson()), + ); + } + + Future clearSelectedNode() async { + await StorageService.instance.remove(StorageKeys.selectedCustomLineNode); + await HostMappingProxyService.instance.stop(); + } + + Future routeDownloadUrl(String url) async { + if (!_canUseCustomLine()) { + await HostMappingProxyService.instance.stop(); + return CustomLineDownloadRoute(url: url); + } + + final node = await getSelectedNode(); + if (node == null) return CustomLineDownloadRoute(url: url); + + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) { + return CustomLineDownloadRoute(url: url); + } + + final endpoint = await activateSelectedNodeForHost(uri.host); + if (endpoint == null) { + return CustomLineDownloadRoute(url: url); + } + + return CustomLineDownloadRoute( + url: url, + nodeName: node.name, + originalHost: uri.host, + proxy: endpoint, + ); + } + + Future activateSelectedNodeForHost(String host) { + return activateSelectedNodeForHosts([host]); + } + + Future activateSelectedNodeForHosts( + Iterable hosts, + ) async { + if (!_canUseCustomLine()) { + await HostMappingProxyService.instance.stop(); + return null; + } + + final node = await getSelectedNode(); + if (node == null) { + await HostMappingProxyService.instance.stop(); + return null; + } + + final hostMap = {}; + void addHost(String value) { + final host = value.trim(); + if (host.isNotEmpty) hostMap[host] = node.ip; + } + + addHost(node.host); + for (final host in hosts) { + addHost(host); + } + + if (hostMap.isEmpty) { + await HostMappingProxyService.instance.stop(); + return null; + } + return HostMappingProxyService.instance.configure(hostMap); + } + + Future activateSelectedNodeForUrl(String url) { + final uri = Uri.tryParse(url); + return activateSelectedNodeForHost(uri?.host ?? ''); + } + + Future activateSelectedNodeForUrls( + Iterable urls, + ) { + final hosts = urls + .map((url) => Uri.tryParse(url)?.host ?? '') + .where((host) => host.trim().isNotEmpty); + return activateSelectedNodeForHosts(hosts); + } + + Future configureMediaPlayerProxy(Player player, String url) async { + final endpoint = await activateSelectedNodeForUrl(url); + final platform = player.platform; + if (platform is! NativePlayer) return; + + try { + await platform.setProperty('http-proxy', endpoint?.proxyUri ?? ''); + AppLogger.d('媒体播放器自定义线路代理已配置: ${endpoint?.proxyUri ?? 'DIRECT'}'); + } catch (e) { + AppLogger.d('媒体播放器自定义线路代理配置失败: $e'); + } + } + + bool _canUseCustomLine() { + final user = ServerService.instance.currentServer?.user; + final groupName = user?.group?.name.trim().toLowerCase(); + return user != null && + groupName != null && + groupName.isNotEmpty && + groupName != 'user'; + } +} + +class SelectedCustomLineNode { + final String id; + final String name; + final String ip; + final String host; + final String protocol; + final int? port; + + const SelectedCustomLineNode({ + required this.id, + required this.name, + required this.ip, + required this.host, + this.protocol = '', + this.port, + }); + + bool get canRewriteDownloadUrl => + ip.trim().isNotEmpty && host.trim().isNotEmpty; + + String routeScheme(String fallback) { + final value = protocol.trim().toLowerCase(); + return value == 'http' || value == 'https' ? value : fallback; + } + + int? get routePort => port != null && port! > 0 ? port : null; + + factory SelectedCustomLineNode.fromJson(Map json) { + return SelectedCustomLineNode( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + ip: json['ip']?.toString() ?? '', + host: json['host']?.toString() ?? '', + protocol: json['protocol']?.toString() ?? '', + port: _asNullableInt(json['port']), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'ip': ip, + 'host': host, + if (protocol.trim().isNotEmpty) 'protocol': protocol, + if (routePort != null) 'port': routePort, + }; + } + + static int? _asNullableInt(Object? value) { + if (value is int) return value; + if (value is num) return value.round(); + return int.tryParse(value?.toString() ?? ''); + } +} + +class CustomLineDownloadRoute { + final String url; + final Map headers; + final String? nodeName; + final String? originalHost; + final HostMappingProxyEndpoint? proxy; + + const CustomLineDownloadRoute({ + required this.url, + this.headers = const {}, + this.nodeName, + this.originalHost, + this.proxy, + }); + + bool get changed => proxy != null || headers.isNotEmpty; +} diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index dda14e4..13e62b4 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../core/constants/storage_keys.dart'; import '../data/models/download_task_model.dart'; +import 'custom_line_service.dart'; import 'file_service.dart'; import 'storage_service.dart'; import '../core/utils/app_logger.dart'; @@ -261,7 +262,14 @@ class DownloadService { await file.delete(); } - return _startBdDownload(task, url, dir); + final route = await CustomLineService.instance.routeDownloadUrl(url); + if (route.changed) { + AppLogger.d( + '自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}', + ); + } + + return _startBdDownload(task, route.url, dir, headers: route.headers); } catch (e) { AppLogger.d('下载失败: $e'); rethrow; @@ -272,12 +280,15 @@ class DownloadService { Future _startBdDownload( DownloadTaskModel task, String url, - Directory dir, - ) async { + Directory dir, { + Map headers = const {}, + }) async { + await _configureCustomLineProxy(); final wifiOnly = await isWifiOnlyEnabled(); final retries = await getRetries(); final bdTask = bd.DownloadTask( url: url, + headers: headers, filename: task.fileName, directory: dir.path, baseDirectory: bd.BaseDirectory.root, @@ -340,10 +351,19 @@ class DownloadService { } // 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask + final route = await CustomLineService.instance.routeDownloadUrl(url); + if (route.changed) { + AppLogger.d( + '自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}', + ); + } + + await _configureCustomLineProxy(); final wifiOnly = await isWifiOnlyEnabled(); final retries = await getRetries(); final bdTask = bd.DownloadTask( - url: url, + url: route.url, + headers: route.headers, filename: task.fileName, directory: dir.path, baseDirectory: bd.BaseDirectory.root, @@ -382,6 +402,14 @@ class DownloadService { } } + Future _configureCustomLineProxy() async { + final endpoint = CustomLineService.instance.currentProxyEndpoint; + final config = endpoint == null ? false : (endpoint.host, endpoint.port); + await bd.FileDownloader().configure( + globalConfig: (bd.Config.proxy, config), + ); + } + /// 暂停下载 Future pauseDownload(String taskId) async { final bdTask = _bdTasks[taskId]; diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index c445979..731190f 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -1,6 +1,7 @@ import 'api_service.dart'; import '../core/utils/app_logger.dart'; import '../core/utils/file_utils.dart'; +import 'custom_line_service.dart'; /// 文件服务 class FileService { @@ -156,10 +157,25 @@ class FileService { data: data, headers: headers, ); + await _activateCustomLineForUrls(response); return response; } + Future _activateCustomLineForUrls(Map response) async { + final urls = response['urls']; + if (urls is! List) return; + + final resolvedUrls = []; + for (final item in urls) { + if (item is! Map) continue; + final url = item['url']?.toString(); + if (url == null || url.isEmpty) continue; + resolvedUrls.add(url); + } + await CustomLineService.instance.activateSelectedNodeForUrls(resolvedUrls); + } + /// 创建直接链接(分享链接) Future>> createDirectLinks({ required List uris, diff --git a/lib/services/host_mapping_proxy_service.dart b/lib/services/host_mapping_proxy_service.dart new file mode 100644 index 0000000..e37cc63 --- /dev/null +++ b/lib/services/host_mapping_proxy_service.dart @@ -0,0 +1,314 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../core/utils/app_logger.dart'; + +class HostMappingProxyService { + HostMappingProxyService._(); + + static final HostMappingProxyService instance = HostMappingProxyService._(); + + ServerSocket? _server; + HostMappingProxyEndpoint? _endpoint; + Map _hostMap = const {}; + + HostMappingProxyEndpoint? get endpoint => _hostMap.isEmpty ? null : _endpoint; + + Future configure( + Map hostMap, + ) async { + final cleaned = {}; + for (final entry in hostMap.entries) { + final host = _normalizeHost(entry.key); + final ip = entry.value.trim(); + if (host.isNotEmpty && ip.isNotEmpty) { + cleaned[host] = ip; + } + } + + if (cleaned.isEmpty) { + await stop(); + return null; + } + + _hostMap = cleaned; + if (_server == null) { + _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + _endpoint = HostMappingProxyEndpoint( + host: InternetAddress.loopbackIPv4.address, + port: _server!.port, + ); + _server!.listen( + _handleClient, + onError: (e) { + AppLogger.d('自定义线路本地代理监听错误: $e'); + }, + ); + AppLogger.d('自定义线路本地代理已启动: ${_endpoint!.proxyUri}'); + } + return _endpoint; + } + + Future stop() async { + _hostMap = const {}; + final server = _server; + _server = null; + _endpoint = null; + if (server != null) { + await server.close(); + AppLogger.d('自定义线路本地代理已关闭'); + } + } + + void _handleClient(Socket client) { + unawaited(_serveClient(client)); + } + + Future _serveClient(Socket client) async { + Socket? remote; + StreamController>? rest; + try { + client.setOption(SocketOption.tcpNoDelay, true); + final initial = await _readInitialRequest(client); + rest = initial.rest; + final lines = latin1.decode(initial.header).split('\r\n'); + if (lines.isEmpty || lines.first.trim().isEmpty) { + throw const FormatException('empty proxy request'); + } + + final first = lines.first.split(' '); + if (first.length < 3) { + throw FormatException('invalid proxy request: ${lines.first}'); + } + + final method = first[0].toUpperCase(); + if (method == 'CONNECT') { + final target = _splitAuthority(first[1], 443); + remote = await _connectMapped(target.host, target.port); + client.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n')); + await client.flush(); + } else { + final target = _httpTarget(first[1], lines); + remote = await _connectMapped(target.host, target.port); + remote.add(_rewriteHttpHeader(initial.header, target.requestTarget)); + } + + remote.setOption(SocketOption.tcpNoDelay, true); + unawaited(rest.stream.pipe(remote).catchError((_) {})); + unawaited(remote.cast>().pipe(client).catchError((_) {})); + } catch (e) { + AppLogger.d('自定义线路本地代理请求失败: $e'); + try { + client.add(utf8.encode('HTTP/1.1 502 Bad Gateway\r\n\r\n')); + await client.flush(); + } catch (_) {} + await rest?.close(); + remote?.destroy(); + client.destroy(); + } + } + + Future<_ProxyInitialRequest> _readInitialRequest(Socket socket) { + final completer = Completer<_ProxyInitialRequest>(); + final builder = BytesBuilder(copy: false); + final rest = StreamController>(sync: true); + late StreamSubscription subscription; + + subscription = socket.listen( + (chunk) { + if (completer.isCompleted) { + rest.add(chunk); + return; + } + + builder.add(chunk); + final bytes = builder.toBytes(); + final end = _headerEnd(bytes); + if (end >= 0) { + final headerEnd = end + 4; + completer.complete( + _ProxyInitialRequest( + header: bytes.sublist(0, headerEnd), + rest: rest, + ), + ); + if (bytes.length > headerEnd) { + rest.add(bytes.sublist(headerEnd)); + } + } else if (bytes.length > 64 * 1024) { + completer.completeError( + const FormatException('proxy request header too large'), + ); + unawaited(subscription.cancel()); + unawaited(rest.close()); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } else { + rest.addError(error, stackTrace); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.completeError(const SocketException('proxy client closed')); + } + unawaited(rest.close()); + }, + cancelOnError: false, + ); + + return completer.future; + } + + int _headerEnd(List bytes) { + for (var i = 0; i <= bytes.length - 4; i++) { + if (bytes[i] == 13 && + bytes[i + 1] == 10 && + bytes[i + 2] == 13 && + bytes[i + 3] == 10) { + return i; + } + } + return -1; + } + + Future _connectMapped(String host, int port) { + final normalized = _normalizeHost(host); + final targetHost = _hostMap[normalized] ?? host; + return Socket.connect( + targetHost, + port, + timeout: const Duration(seconds: 12), + ); + } + + _ProxyTarget _httpTarget(String target, List lines) { + final uri = Uri.tryParse(target); + if (uri != null && uri.hasScheme && uri.host.isNotEmpty) { + return _ProxyTarget( + host: uri.host, + port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? 443 : 80), + requestTarget: _originForm(uri), + ); + } + + final hostHeader = _headerValue(lines, 'Host'); + final authority = _splitAuthority(hostHeader, 80); + return _ProxyTarget( + host: authority.host, + port: authority.port, + requestTarget: target, + ); + } + + List _rewriteHttpHeader(List header, String requestTarget) { + final text = latin1.decode(header); + final lines = text.split('\r\n'); + final first = lines.first.split(' '); + first[1] = requestTarget.isEmpty ? '/' : requestTarget; + lines[0] = first.join(' '); + final filtered = lines + .where((line) => !line.toLowerCase().startsWith('proxy-connection:')) + .join('\r\n'); + return latin1.encode(filtered); + } + + String _originForm(Uri uri) { + final path = uri.path.isEmpty ? '/' : uri.path; + return uri.hasQuery ? '$path?${uri.query}' : path; + } + + String _headerValue(List lines, String name) { + final prefix = '${name.toLowerCase()}:'; + for (final line in lines.skip(1)) { + if (line.toLowerCase().startsWith(prefix)) { + return line.substring(line.indexOf(':') + 1).trim(); + } + } + throw FormatException('missing $name header'); + } + + _ProxyAuthority _splitAuthority(String value, int defaultPort) { + final authority = value.trim(); + if (authority.startsWith('[')) { + final end = authority.indexOf(']'); + if (end > 0) { + final host = authority.substring(1, end); + final port = authority.length > end + 2 + ? int.tryParse(authority.substring(end + 2)) ?? defaultPort + : defaultPort; + return _ProxyAuthority(host, port); + } + } + + final colon = authority.lastIndexOf(':'); + if (colon > 0 && colon < authority.length - 1) { + final port = int.tryParse(authority.substring(colon + 1)); + if (port != null) { + return _ProxyAuthority(authority.substring(0, colon), port); + } + } + return _ProxyAuthority(authority, defaultPort); + } + + String _normalizeHost(String host) { + final value = host.trim(); + final uri = Uri.tryParse(value); + final resolved = uri != null && uri.hasScheme && uri.host.isNotEmpty + ? uri.host + : value; + return resolved.toLowerCase().replaceAll(RegExp(r'\.$'), ''); + } +} + +class HostMappingProxyEndpoint { + final String host; + final int port; + + const HostMappingProxyEndpoint({required this.host, required this.port}); + + String get proxyUri => 'http://$host:$port'; +} + +class HostMappingHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + final client = super.createHttpClient(context); + client.findProxy = (_) { + final endpoint = HostMappingProxyService.instance.endpoint; + if (endpoint == null) return 'DIRECT'; + return 'PROXY ${endpoint.host}:${endpoint.port}; DIRECT'; + }; + return client; + } +} + +class _ProxyInitialRequest { + final Uint8List header; + final StreamController> rest; + + const _ProxyInitialRequest({required this.header, required this.rest}); +} + +class _ProxyAuthority { + final String host; + final int port; + + const _ProxyAuthority(this.host, this.port); +} + +class _ProxyTarget { + final String host; + final int port; + final String requestTarget; + + const _ProxyTarget({ + required this.host, + required this.port, + required this.requestTarget, + }); +} diff --git a/lib/services/server_service.dart b/lib/services/server_service.dart index b8360bd..51766e3 100644 --- a/lib/services/server_service.dart +++ b/lib/services/server_service.dart @@ -45,12 +45,7 @@ class ServerService { } catch (e) { AppLogger.d('加载服务器列表失败: $e'); // 加载失败时使用默认服务器 - _servers = [ - ServerModel( - label: _defaultLabel, - baseUrl: _defaultBaseUrl, - ), - ]; + _servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)]; _currentServer = _servers.first; } } @@ -65,15 +60,10 @@ class ServerService { } } - _currentServer = (savedDefaultServer ?? - ServerModel( - label: _defaultLabel, - baseUrl: _defaultBaseUrl, - )) - .copyWith( - label: _defaultLabel, - baseUrl: _defaultBaseUrl, - ); + _currentServer = + (savedDefaultServer ?? + ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)) + .copyWith(label: _defaultLabel, baseUrl: _defaultBaseUrl); _servers = [_currentServer!]; } @@ -91,7 +81,9 @@ class ServerService { /// 保存上次选中的服务器 Future _saveLastSelected() async { if (_currentServer != null) { - await StorageService.instance.setLastSelectedServerLabel(_currentServer!.label); + await StorageService.instance.setLastSelectedServerLabel( + _currentServer!.label, + ); } } @@ -125,16 +117,21 @@ class ServerService { String? password, UserModel? user, bool? rememberMe, + bool clearEmail = false, + bool clearPassword = false, + bool clearUser = false, }) async { if (_currentServer == null) { throw Exception('没有选中的服务器'); } - _currentServer = _currentServer!.copyWith( - email: email, - password: password, - user: user, + _currentServer = ServerModel( + label: _currentServer!.label, + baseUrl: _currentServer!.baseUrl, rememberMe: rememberMe ?? _currentServer!.rememberMe, + email: clearEmail ? null : (email ?? _currentServer!.email), + password: clearPassword ? null : (password ?? _currentServer!.password), + user: clearUser ? null : (user ?? _currentServer!.user), ); // 更新列表中的引用 @@ -152,17 +149,15 @@ class ServerService { email: null, password: null, user: null, + clearEmail: true, + clearPassword: true, + clearUser: true, ); } /// 重置为默认服务器列表 Future resetToDefault() async { - _servers = [ - ServerModel( - label: _defaultLabel, - baseUrl: _defaultBaseUrl, - ), - ]; + _servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)]; _currentServer = _servers.first; await _saveServers(); await _saveLastSelected(); diff --git a/lib/services/thumbnail_service.dart b/lib/services/thumbnail_service.dart index 7340edb..a052fa7 100644 --- a/lib/services/thumbnail_service.dart +++ b/lib/services/thumbnail_service.dart @@ -2,6 +2,7 @@ import 'api_service.dart'; import '../core/utils/app_logger.dart'; import '../core/utils/file_utils.dart'; import '../core/utils/time_flow_decoder.dart'; +import 'custom_line_service.dart'; /// 缩略图缓存条目 class _ThumbCacheEntry { @@ -41,6 +42,9 @@ class ThumbnailService { // 1. 检查内存缓存 final cached = _urlCache[cacheKey]; if (cached != null && !cached.isExpired) { + await CustomLineService.instance.activateSelectedNodeForUrl( + cached.imageUrl, + ); return cached.imageUrl; } if (cached != null) { @@ -64,7 +68,10 @@ class ThumbnailService { } } - Future _fetchThumbnailUrl(String fileUri, String? contextHint) async { + Future _fetchThumbnailUrl( + String fileUri, + String? contextHint, + ) async { try { final uri = FileUtils.toCloudreveUri(fileUri); final headers = contextHint != null @@ -97,12 +104,15 @@ class ThumbnailService { if (url.isEmpty) return null; AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url'); + await CustomLineService.instance.activateSelectedNodeForUrl(url); // 解析过期时间,缓存提前 30 秒过期 DateTime expiresAt; if (expiresStr != null) { try { - expiresAt = DateTime.parse(expiresStr).subtract(const Duration(seconds: 30)); + expiresAt = DateTime.parse( + expiresStr, + ).subtract(const Duration(seconds: 30)); } catch (_) { expiresAt = DateTime.now().add(const Duration(minutes: 5)); } @@ -118,7 +128,9 @@ class ThumbnailService { return url; } catch (e) { - AppLogger.d('ThumbnailService: failed to get thumbnail URL for $fileUri: $e'); + AppLogger.d( + 'ThumbnailService: failed to get thumbnail URL for $fileUri: $e', + ); return null; } } diff --git a/test/custom_line_service_test.dart b/test/custom_line_service_test.dart new file mode 100644 index 0000000..779e974 --- /dev/null +++ b/test/custom_line_service_test.dart @@ -0,0 +1,143 @@ +import 'package:cloudreve4_flutter/data/models/user_model.dart'; +import 'package:cloudreve4_flutter/services/custom_line_service.dart'; +import 'package:cloudreve4_flutter/services/server_service.dart'; +import 'package:cloudreve4_flutter/services/storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('selected node json round trip preserves rewrite fields', () { + const node = SelectedCustomLineNode( + id: 'node-1', + name: 'HK 01', + ip: '203.0.113.10', + host: 'node.example.com', + protocol: 'https', + port: 443, + ); + + final restored = SelectedCustomLineNode.fromJson(node.toJson()); + + expect(restored.id, 'node-1'); + expect(restored.name, 'HK 01'); + expect(restored.ip, '203.0.113.10'); + expect(restored.host, 'node.example.com'); + expect(restored.protocol, 'https'); + expect(restored.port, 443); + expect(restored.canRewriteDownloadUrl, isTrue); + }); + + test('download route defaults to unchanged', () { + const route = CustomLineDownloadRoute(url: 'https://pan.example.com/file'); + + expect(route.changed, isFalse); + expect(route.headers, isEmpty); + }); + + test( + 'routeDownloadUrl keeps url unchanged and enables host mapping proxy', + () async { + SharedPreferences.setMockInitialValues({}); + await ServerService.instance.init(); + await ServerService.instance.updateCurrentServerLogin( + user: UserModel( + id: 'user-1', + nickname: 'VIP', + createdAt: DateTime(2026, 1), + group: GroupModel(id: 'vip', name: 'VIP'), + ), + ); + await CustomLineService.instance.setSelectedNode( + const SelectedCustomLineNode( + id: 'node-1', + name: 'HK 01', + ip: '203.0.113.10', + host: 'node.example.com', + ), + ); + + final route = await CustomLineService.instance.routeDownloadUrl( + 'https://storage.example.com:8443/download/file.txt?token=abc', + ); + + expect( + route.url, + 'https://storage.example.com:8443/download/file.txt?token=abc', + ); + expect(route.headers, isEmpty); + expect(route.changed, isTrue); + expect(route.originalHost, 'storage.example.com'); + expect(route.proxy, isNotNull); + expect(route.proxy!.host, '127.0.0.1'); + expect(route.proxy!.port, greaterThan(0)); + + await CustomLineService.instance.clearSelectedNode(); + await StorageService.instance.clear(); + }, + ); + + test('routeDownloadUrl stays unchanged for User group', () async { + SharedPreferences.setMockInitialValues({}); + await ServerService.instance.init(); + await ServerService.instance.updateCurrentServerLogin( + user: UserModel( + id: 'user-2', + nickname: 'Normal', + createdAt: DateTime(2026, 1), + group: GroupModel(id: 'user', name: 'User'), + ), + ); + await CustomLineService.instance.setSelectedNode( + const SelectedCustomLineNode( + id: 'node-1', + name: 'HK 01', + ip: '203.0.113.10', + host: 'node.example.com', + ), + ); + + final route = await CustomLineService.instance.routeDownloadUrl( + 'https://storage.example.com/download/file.txt', + ); + + expect(route.url, 'https://storage.example.com/download/file.txt'); + expect(route.headers, isEmpty); + expect(route.changed, isFalse); + + await CustomLineService.instance.clearSelectedNode(); + await StorageService.instance.clear(); + }); + + test('routeDownloadUrl stays unchanged after login is cleared', () async { + SharedPreferences.setMockInitialValues({}); + await ServerService.instance.init(); + await ServerService.instance.updateCurrentServerLogin( + user: UserModel( + id: 'user-3', + nickname: 'VIP', + createdAt: DateTime(2026, 1), + group: GroupModel(id: 'vip', name: 'VIP'), + ), + ); + await CustomLineService.instance.setSelectedNode( + const SelectedCustomLineNode( + id: 'node-1', + name: 'HK 01', + ip: '203.0.113.10', + host: 'node.example.com', + ), + ); + await ServerService.instance.clearCurrentServerLogin(); + + final route = await CustomLineService.instance.routeDownloadUrl( + 'https://storage.example.com/download/file.txt', + ); + + expect(route.url, 'https://storage.example.com/download/file.txt'); + expect(route.headers, isEmpty); + expect(route.changed, isFalse); + + await CustomLineService.instance.clearSelectedNode(); + await StorageService.instance.clear(); + }); +}