Compare commits
9 Commits
33509de4e3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dc0c8bd48 | |||
| c143ae4c1c | |||
|
45e487e7a0
|
|||
|
7afa4bf156
|
|||
| 18def0753f | |||
| 526990a01a | |||
|
db10873aaf
|
|||
|
e6c60a9d6d
|
|||
| 3b94f40def |
@@ -12,6 +12,9 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
releases: write
|
releases: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
android:
|
android:
|
||||||
name: Build Android APK
|
name: Build Android APK
|
||||||
@@ -113,7 +116,7 @@ jobs:
|
|||||||
flutter doctor -v
|
flutter doctor -v
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get --enforce-lockfile
|
||||||
|
|
||||||
- name: Restore Android signing files
|
- name: Restore Android signing files
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -145,7 +148,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
rm -rf build dist .dart_tool android/.gradle
|
rm -rf build dist android/.gradle
|
||||||
|
|
||||||
pkg_dir="$PWD/dist/android"
|
pkg_dir="$PWD/dist/android"
|
||||||
if [ -d "$pkg_dir" ]; then
|
if [ -d "$pkg_dir" ]; then
|
||||||
@@ -153,7 +156,7 @@ jobs:
|
|||||||
rm -rf "$pkg_dir"
|
rm -rf "$pkg_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build_args=(--release)
|
build_args=(--release --no-pub)
|
||||||
if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then
|
if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then
|
||||||
build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}")
|
build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}")
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -160,6 +160,14 @@ Linux: Debian 12
|
|||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果当前网络无法解析 `pub.dev`,先设置 Pub/Flutter 镜像:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PUB_HOSTED_URL="https://pub.flutter-io.cn"
|
||||||
|
$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn"
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
### 运行项目
|
### 运行项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -170,11 +178,11 @@ flutter run # pdf 和 音视频会再构建过程中下载github上的依赖,
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Android
|
# Android
|
||||||
flutter build apk --release
|
flutter build apk --release --no-pub
|
||||||
# Linux
|
# Linux
|
||||||
flutter build -d linux --release
|
flutter build -d linux --release --no-pub
|
||||||
# windows
|
# windows
|
||||||
flutter build -d windows --release
|
flutter build -d windows --release --no-pub
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class StorageKeys {
|
|||||||
static const String downloadTasks = 'download_tasks';
|
static const String downloadTasks = 'download_tasks';
|
||||||
static const String downloadWifiOnly = 'download_wifi_only';
|
static const String downloadWifiOnly = 'download_wifi_only';
|
||||||
static const String downloadRetries = 'download_retries';
|
static const String downloadRetries = 'download_retries';
|
||||||
|
static const String selectedCustomLineNode = 'selected_custom_line_node';
|
||||||
|
|
||||||
// 任务记录
|
// 任务记录
|
||||||
static const String taskRetentionDays = 'task_retention_days';
|
static const String taskRetentionDays = 'task_retention_days';
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import 'services/storage_service.dart';
|
|||||||
import 'services/server_service.dart';
|
import 'services/server_service.dart';
|
||||||
import 'services/cache_manager_service.dart';
|
import 'services/cache_manager_service.dart';
|
||||||
import 'services/avatar_cache_service.dart';
|
import 'services/avatar_cache_service.dart';
|
||||||
|
import 'services/host_mapping_proxy_service.dart';
|
||||||
import 'core/utils/video_fullscreen.dart';
|
import 'core/utils/video_fullscreen.dart';
|
||||||
import 'services/desktop_service.dart';
|
import 'services/desktop_service.dart';
|
||||||
import 'router/app_router.dart';
|
import 'router/app_router.dart';
|
||||||
@@ -51,6 +52,7 @@ Level _parseLogLevel(String level) {
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
HttpOverrides.global = HostMappingHttpOverrides();
|
||||||
|
|
||||||
await AppLogger.init();
|
await AppLogger.init();
|
||||||
final savedLevel = await StorageService.instance.getString(
|
final savedLevel = await StorageService.instance.getString(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import '../../../data/models/file_model.dart';
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../services/custom_line_service.dart';
|
||||||
import '../../../services/file_service.dart';
|
import '../../../services/file_service.dart';
|
||||||
|
|
||||||
/// 音频预览页面
|
/// 音频预览页面
|
||||||
@@ -43,7 +44,11 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
player.open(Media(url), play: true);
|
await CustomLineService.instance.configureMediaPlayerProxy(
|
||||||
|
player,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
await player.open(Media(url), play: true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -104,11 +109,7 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(Icons.error_outline, size: 64, color: Colors.white54),
|
||||||
Icons.error_outline,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.white54,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
@@ -140,14 +141,17 @@ class AudioPlayerWidget extends StatefulWidget {
|
|||||||
final Player player;
|
final Player player;
|
||||||
final String fileName;
|
final String fileName;
|
||||||
|
|
||||||
const AudioPlayerWidget({super.key, required this.player, required this.fileName});
|
const AudioPlayerWidget({
|
||||||
|
super.key,
|
||||||
|
required this.player,
|
||||||
|
required this.fileName,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
|
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -155,10 +159,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [const Color(0xFF1A1A2E), const Color(0xFF16213E)],
|
||||||
const Color(0xFF1A1A2E),
|
|
||||||
const Color(0xFF16213E),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -246,12 +247,18 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
|||||||
return SliderTheme(
|
return SliderTheme(
|
||||||
data: SliderThemeData(
|
data: SliderThemeData(
|
||||||
trackHeight: 4,
|
trackHeight: 4,
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
|
thumbShape: const RoundSliderThumbShape(
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
|
enabledThumbRadius: 8,
|
||||||
|
),
|
||||||
|
overlayShape: const RoundSliderOverlayShape(
|
||||||
|
overlayRadius: 16,
|
||||||
|
),
|
||||||
activeTrackColor: const Color(0xFFE94560),
|
activeTrackColor: const Color(0xFFE94560),
|
||||||
inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
|
inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
|
||||||
thumbColor: const Color(0xFFE94560),
|
thumbColor: const Color(0xFFE94560),
|
||||||
overlayColor: const Color(0xFFE94560).withValues(alpha: 0.3),
|
overlayColor: const Color(
|
||||||
|
0xFFE94560,
|
||||||
|
).withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: position.inMilliseconds.toDouble(),
|
value: position.inMilliseconds.toDouble(),
|
||||||
@@ -259,9 +266,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
|||||||
? duration.inMilliseconds.toDouble()
|
? duration.inMilliseconds.toDouble()
|
||||||
: 1.0,
|
: 1.0,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
widget.player.seek(
|
widget.player.seek(Duration(milliseconds: value.toInt()));
|
||||||
Duration(milliseconds: value.toInt()),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:highlight/highlight.dart';
|
|||||||
import 'package:highlight/languages/all.dart';
|
import 'package:highlight/languages/all.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../../../data/models/file_model.dart';
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../services/custom_line_service.dart';
|
||||||
import '../../../services/file_service.dart';
|
import '../../../services/file_service.dart';
|
||||||
import '../../../core/utils/file_type_utils.dart';
|
import '../../../core/utils/file_type_utils.dart';
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
|||||||
if (urls.isEmpty) throw Exception('获取URL为空');
|
if (urls.isEmpty) throw Exception('获取URL为空');
|
||||||
final urlData = urls[0] as Map<String, dynamic>;
|
final urlData = urls[0] as Map<String, dynamic>;
|
||||||
final url = urlData['url'] as String;
|
final url = urlData['url'] as String;
|
||||||
|
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||||
|
|
||||||
final responseContent = await http.get(Uri.parse(url));
|
final responseContent = await http.get(Uri.parse(url));
|
||||||
if (responseContent.statusCode != 200) {
|
if (responseContent.statusCode != 200) {
|
||||||
@@ -93,7 +95,8 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
|||||||
_codeController = CodeController(text: _content, language: _languageMode);
|
_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) {
|
Mode _detectLanguageMode(String fileName) {
|
||||||
final ext = FileTypeUtils.getExtension(fileName);
|
final ext = FileTypeUtils.getExtension(fileName);
|
||||||
@@ -131,16 +134,21 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: _backgroundColor,
|
backgroundColor: _backgroundColor,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
iconTheme: const IconThemeData(
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
title: Column(
|
title: Column(
|
||||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(widget.file.name, style: const TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center,),
|
Text(
|
||||||
|
widget.file.name,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
if (!_isLoading)
|
if (!_isLoading)
|
||||||
Text('$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
|
Text(
|
||||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12), textAlign: TextAlign.center,),
|
'$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -159,60 +167,64 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
|||||||
|
|
||||||
/// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿
|
/// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿
|
||||||
Widget _buildCodeEditor(double lineNumberWidth) {
|
Widget _buildCodeEditor(double lineNumberWidth) {
|
||||||
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
|
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
|
||||||
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
|
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
|
||||||
int digits = _lineCount.toString().length;
|
int digits = _lineCount.toString().length;
|
||||||
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
|
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
|
||||||
double stableWidth = _showLineNumbers
|
double stableWidth = _showLineNumbers
|
||||||
? (digits * (_fontSize * 0.75)) + 46
|
? (digits * (_fontSize * 0.75)) + 46
|
||||||
: 0;
|
: 0;
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _backgroundColor,
|
color: _backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ScrollConfiguration(
|
||||||
|
behavior: ScrollConfiguration.of(context).copyWith(
|
||||||
|
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
||||||
),
|
),
|
||||||
child: ScrollConfiguration(
|
child: CodeTheme(
|
||||||
behavior: ScrollConfiguration.of(context).copyWith(
|
data: CodeThemeData(styles: {...atomOneDarkTheme}),
|
||||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
child: Scrollbar(
|
||||||
),
|
controller: _customCodeScrollController,
|
||||||
child: CodeTheme(
|
child: SingleChildScrollView(
|
||||||
data: CodeThemeData(styles: {...atomOneDarkTheme}),
|
|
||||||
child: Scrollbar(
|
|
||||||
controller: _customCodeScrollController,
|
controller: _customCodeScrollController,
|
||||||
child: SingleChildScrollView(
|
child: CodeField(
|
||||||
controller: _customCodeScrollController,
|
controller: _codeController!,
|
||||||
child: CodeField(
|
textStyle: TextStyle(
|
||||||
controller: _codeController!,
|
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(
|
textStyle: TextStyle(
|
||||||
fontFamily: 'SourceCodePro',
|
color: _showLineNumbers
|
||||||
fontSize: _fontSize,
|
? Colors.grey.shade600
|
||||||
height: 1.5,
|
: Colors.transparent,
|
||||||
),
|
fontSize: _fontSize * 0.8,
|
||||||
enabled: false,
|
height: 1.5, // 必须和正文高度完全一致
|
||||||
readOnly: true,
|
// 核心修复:通过强制单词不换行来防止数字断裂
|
||||||
background: Colors.transparent,
|
fontFeatures: const [
|
||||||
// 关键点 1:调整行号样式
|
FontFeature.tabularFigures(),
|
||||||
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()], // 使用等宽数字
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 构建功能组合按钮
|
// 构建功能组合按钮
|
||||||
Widget _buildExpandableFab() {
|
Widget _buildExpandableFab() {
|
||||||
@@ -225,7 +237,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
|||||||
heroTag: 'font_up',
|
heroTag: 'font_up',
|
||||||
mini: true,
|
mini: true,
|
||||||
backgroundColor: Colors.grey.shade800,
|
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),
|
child: const Icon(Icons.add, color: Colors.white, size: 20),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -234,7 +248,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
|||||||
heroTag: 'font_down',
|
heroTag: 'font_down',
|
||||||
mini: true,
|
mini: true,
|
||||||
backgroundColor: Colors.grey.shade800,
|
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),
|
child: const Icon(Icons.remove, color: Colors.white, size: 20),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -242,7 +258,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
|||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
heroTag: 'line_toggle',
|
heroTag: 'line_toggle',
|
||||||
mini: true,
|
mini: true,
|
||||||
backgroundColor: _showLineNumbers ? Colors.blue : Colors.grey.shade800,
|
backgroundColor: _showLineNumbers
|
||||||
|
? Colors.blue
|
||||||
|
: Colors.grey.shade800,
|
||||||
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
|
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
|
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import '../../../data/models/file_model.dart';
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../services/custom_line_service.dart';
|
||||||
import '../../../services/file_service.dart';
|
import '../../../services/file_service.dart';
|
||||||
import '../../../services/cache_manager_service.dart';
|
import '../../../services/cache_manager_service.dart';
|
||||||
import '../../widgets/toast_helper.dart';
|
import '../../widgets/toast_helper.dart';
|
||||||
@@ -44,6 +45,7 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
|
|||||||
if (urls.isNotEmpty) {
|
if (urls.isNotEmpty) {
|
||||||
final urlData = urls[0] as Map<String, dynamic>;
|
final urlData = urls[0] as Map<String, dynamic>;
|
||||||
final url = urlData['url'] as String;
|
final url = urlData['url'] as String;
|
||||||
|
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:markdown_widget/widget/blocks/leaf/link.dart';
|
|||||||
import 'package:markdown_widget/widget/markdown.dart';
|
import 'package:markdown_widget/widget/markdown.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../../data/models/file_model.dart';
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../services/custom_line_service.dart';
|
||||||
import '../../../services/file_service.dart';
|
import '../../../services/file_service.dart';
|
||||||
|
|
||||||
class MarkdownPreviewPage extends StatefulWidget {
|
class MarkdownPreviewPage extends StatefulWidget {
|
||||||
@@ -62,6 +63,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
|
|
||||||
final urlData = urls[0] as Map<String, dynamic>;
|
final urlData = urls[0] as Map<String, dynamic>;
|
||||||
final url = urlData['url'] as String;
|
final url = urlData['url'] as String;
|
||||||
|
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||||
|
|
||||||
final responseContent = await http.get(Uri.parse(url));
|
final responseContent = await http.get(Uri.parse(url));
|
||||||
if (responseContent.statusCode != 200) {
|
if (responseContent.statusCode != 200) {
|
||||||
@@ -104,7 +106,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
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(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
title: Column(
|
title: Column(
|
||||||
@@ -120,13 +125,19 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (!_isLoading && _error == null)
|
if (!_isLoading && _error == null)
|
||||||
Text('Markdown 预览', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
Text(
|
||||||
|
'Markdown 预览',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isLoading && _error == null)
|
if (!_isLoading && _error == null)
|
||||||
IconButton(
|
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)),
|
onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -134,8 +145,8 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _error != null
|
: _error != null
|
||||||
? _buildErrorWidget()
|
? _buildErrorWidget()
|
||||||
: _buildResponsiveBody(isWideScreen, screenWidth),
|
: _buildResponsiveBody(isWideScreen, screenWidth),
|
||||||
floatingActionButton: _buildFAB(isDark),
|
floatingActionButton: _buildFAB(isDark),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,10 +170,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
right: BorderSide(
|
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: ClipRect(
|
||||||
child: Column(
|
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(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -198,9 +216,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
bodySmall: TextStyle(color: subTextColor),
|
bodySmall: TextStyle(color: subTextColor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: TocWidget(
|
child: TocWidget(controller: _tocController),
|
||||||
controller: _tocController,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -228,8 +244,11 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMarkdownWidget(String content) {
|
Widget _buildMarkdownWidget(String content) {
|
||||||
final config = _isDarkMode ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig;
|
final config = _isDarkMode
|
||||||
CodeWrapperWidget codeWrapper(child, text, language) => CodeWrapperWidget(child, text, language);
|
? MarkdownConfig.darkConfig
|
||||||
|
: MarkdownConfig.defaultConfig;
|
||||||
|
CodeWrapperWidget codeWrapper(child, text, language) =>
|
||||||
|
CodeWrapperWidget(child, text, language);
|
||||||
|
|
||||||
return MarkdownWidget(
|
return MarkdownWidget(
|
||||||
data: content,
|
data: content,
|
||||||
@@ -237,7 +256,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
|||||||
config: config.copy(
|
config: config.copy(
|
||||||
configs: [
|
configs: [
|
||||||
_isDarkMode
|
_isDarkMode
|
||||||
? PreConfig.darkConfig.copy(theme: a11yLightTheme, wrapper: codeWrapper)
|
? PreConfig.darkConfig.copy(
|
||||||
|
theme: a11yLightTheme,
|
||||||
|
wrapper: codeWrapper,
|
||||||
|
)
|
||||||
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
|
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
|
||||||
LinkConfig(
|
LinkConfig(
|
||||||
style: TextStyle(
|
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();
|
if (_isLoading || _error != null) return const SizedBox.shrink();
|
||||||
|
|
||||||
// 定义统一的按钮背景颜色,增加视觉一致性
|
// 定义统一的按钮背景颜色,增加视觉一致性
|
||||||
final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue;
|
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;
|
final Color iconColor = isDark ? Colors.white : Colors.black87;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import '../../../data/models/file_model.dart';
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../services/custom_line_service.dart';
|
||||||
import '../../../services/file_service.dart';
|
import '../../../services/file_service.dart';
|
||||||
|
|
||||||
/// PDF预览页面
|
/// PDF预览页面
|
||||||
@@ -37,6 +38,7 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
|
|||||||
if (urls.isNotEmpty) {
|
if (urls.isNotEmpty) {
|
||||||
final urlData = urls[0] as Map<String, dynamic>;
|
final urlData = urls[0] as Map<String, dynamic>;
|
||||||
final url = urlData['url'] as String;
|
final url = urlData['url'] as String;
|
||||||
|
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import '../../../data/models/file_model.dart';
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../services/custom_line_service.dart';
|
||||||
import '../../../services/file_service.dart';
|
import '../../../services/file_service.dart';
|
||||||
import 'widgets/video_controls_overlay.dart';
|
import 'widgets/video_controls_overlay.dart';
|
||||||
|
|
||||||
@@ -45,7 +46,11 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
|
|||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
player.open(Media(url), play: true);
|
await CustomLineService.instance.configureMediaPlayerProxy(
|
||||||
|
player,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
await player.open(Media(url), play: true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -78,37 +83,42 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
|
|||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator(color: Colors.white))
|
? const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||||
: _errorMessage != null
|
: _errorMessage != null
|
||||||
? Center(
|
? Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, size: 64, color: Colors.white),
|
const Icon(
|
||||||
const SizedBox(height: 16),
|
Icons.error_outline,
|
||||||
Text(
|
size: 64,
|
||||||
_errorMessage!,
|
color: Colors.white,
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
_errorMessage = null;
|
|
||||||
});
|
|
||||||
_loadVideoUrl();
|
|
||||||
},
|
|
||||||
child: const Text('重试'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
const SizedBox(height: 16),
|
||||||
: ExcludeSemantics(
|
Text(
|
||||||
child: Video(
|
_errorMessage!,
|
||||||
controller: controller,
|
textAlign: TextAlign.center,
|
||||||
controls: (state) => VideoControlsOverlay(state: state, title: widget.file.name),
|
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 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../services/api_service.dart';
|
import '../../../services/api_service.dart';
|
||||||
|
import '../../../services/custom_line_service.dart';
|
||||||
import '../../../services/storage_service.dart';
|
import '../../../services/storage_service.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../widgets/desktop_constrained.dart';
|
import '../../widgets/desktop_constrained.dart';
|
||||||
@@ -45,6 +46,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
_NetworkDiagnostics? _networkDiagnostics;
|
_NetworkDiagnostics? _networkDiagnostics;
|
||||||
_ServiceDiagnostics? _serviceDiagnostics;
|
_ServiceDiagnostics? _serviceDiagnostics;
|
||||||
_CustomLineDiagnostics? _customDiagnostics;
|
_CustomLineDiagnostics? _customDiagnostics;
|
||||||
|
String? _selectedCustomNodeId;
|
||||||
String? _networkError;
|
String? _networkError;
|
||||||
String? _serviceError;
|
String? _serviceError;
|
||||||
String? _customError;
|
String? _customError;
|
||||||
@@ -52,6 +54,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
unawaited(_loadSelectedCustomNode());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +125,9 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
loading: _customLoading,
|
loading: _customLoading,
|
||||||
error: _customError,
|
error: _customError,
|
||||||
diagnostics: _customDiagnostics,
|
diagnostics: _customDiagnostics,
|
||||||
|
selectedNodeId: _selectedCustomNodeId,
|
||||||
onRefresh: _runCustomLineTest,
|
onRefresh: _runCustomLineTest,
|
||||||
|
onSelectNode: _selectCustomNode,
|
||||||
onOpenTelegram: () => _openExternalUrl(_telegramUri),
|
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) =>
|
bool _isUserGroup(String groupName) =>
|
||||||
groupName.trim().toLowerCase() == 'user';
|
groupName.trim().toLowerCase() == 'user';
|
||||||
|
|
||||||
@@ -229,9 +271,11 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
final nodes = await _fetchCustomNodes(
|
final nodes = await _fetchCustomNodes(
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
groupName: groupName,
|
groupName: groupName,
|
||||||
|
userId: auth.user!.id,
|
||||||
);
|
);
|
||||||
final probes = await Future.wait(nodes.map(_testCustomNode));
|
final probes = await Future.wait(nodes.map(_testCustomNode));
|
||||||
final recommended = _bestCustomNode(probes);
|
final recommended = _bestCustomNode(probes);
|
||||||
|
final selectedNodeId = await _resolveSelectedCustomNodeId(probes);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -240,6 +284,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
nodes: probes,
|
nodes: probes,
|
||||||
recommended: recommended,
|
recommended: recommended,
|
||||||
);
|
);
|
||||||
|
_selectedCustomNodeId = selectedNodeId;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _customError = e.toString());
|
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({
|
Future<List<_CustomNode>> _fetchCustomNodes({
|
||||||
required String clientId,
|
required String clientId,
|
||||||
required String groupName,
|
required String groupName,
|
||||||
|
required String userId,
|
||||||
}) async {
|
}) async {
|
||||||
final handshakeResponse = await http
|
final handshakeResponse = await http
|
||||||
.post(
|
.post(
|
||||||
@@ -277,7 +342,10 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
|
|
||||||
final nodesResponse = await http
|
final nodesResponse = await http
|
||||||
.get(
|
.get(
|
||||||
_ipNodeApiUri('/api/v1/nodes', queryParameters: {'group': groupName}),
|
_ipNodeApiUri(
|
||||||
|
'/api/v1/nodes',
|
||||||
|
queryParameters: {'group': groupName, 'user_id': userId},
|
||||||
|
),
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'X-Client-UUID': clientId,
|
'X-Client-UUID': clientId,
|
||||||
@@ -834,7 +902,9 @@ class _CustomLinePanel extends StatelessWidget {
|
|||||||
final bool loading;
|
final bool loading;
|
||||||
final String? error;
|
final String? error;
|
||||||
final _CustomLineDiagnostics? diagnostics;
|
final _CustomLineDiagnostics? diagnostics;
|
||||||
|
final String? selectedNodeId;
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
|
final ValueChanged<_CustomNodeProbe?> onSelectNode;
|
||||||
final VoidCallback onOpenTelegram;
|
final VoidCallback onOpenTelegram;
|
||||||
|
|
||||||
const _CustomLinePanel({
|
const _CustomLinePanel({
|
||||||
@@ -842,7 +912,9 @@ class _CustomLinePanel extends StatelessWidget {
|
|||||||
required this.loading,
|
required this.loading,
|
||||||
required this.error,
|
required this.error,
|
||||||
required this.diagnostics,
|
required this.diagnostics,
|
||||||
|
required this.selectedNodeId,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
|
required this.onSelectNode,
|
||||||
required this.onOpenTelegram,
|
required this.onOpenTelegram,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -868,11 +940,21 @@ class _CustomLinePanel extends StatelessWidget {
|
|||||||
value: recommended.node.displayName,
|
value: recommended.node.displayName,
|
||||||
valueColor: Colors.green,
|
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.latencyLabel),
|
||||||
_InfoRow(label: '丢包率', value: recommended.lossLabel),
|
_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) ...[
|
] else if (!loading && data != null) ...[
|
||||||
const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'),
|
const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'),
|
||||||
] else if (!loading && error == null) ...[
|
] 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: '可选节点', children: _availableNodeChildren(data)),
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: '自定义节点',
|
title: '自定义节点',
|
||||||
@@ -923,7 +1023,11 @@ class _CustomLinePanel extends StatelessWidget {
|
|||||||
return [
|
return [
|
||||||
for (var i = 0; i < data.nodes.length; i++) ...[
|
for (var i = 0; i < data.nodes.length; i++) ...[
|
||||||
if (i > 0) const Divider(height: 18),
|
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 {
|
class _CustomNodeTile extends StatelessWidget {
|
||||||
final _CustomNodeProbe probe;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final available = probe.successCount > 0;
|
final available = probe.successCount > 0;
|
||||||
final statusColor = _statusColor(context, available);
|
final statusColor = _statusColor(context, available);
|
||||||
return Padding(
|
final selectable = available && probe.node.canRewriteDownloadUrl;
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
return InkWell(
|
||||||
child: Column(
|
onTap: selectable ? onSelect : null,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
borderRadius: BorderRadius.circular(8),
|
||||||
children: [
|
child: Padding(
|
||||||
Row(
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(
|
children: [
|
||||||
child: Text(
|
Icon(
|
||||||
probe.node.displayName,
|
selected
|
||||||
style: Theme.of(
|
? Icons.radio_button_checked
|
||||||
context,
|
: Icons.radio_button_unchecked,
|
||||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
|
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.nodes,
|
||||||
required this.recommended,
|
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 {
|
class _CustomNode {
|
||||||
@@ -1139,6 +1308,7 @@ class _CustomNode {
|
|||||||
final String province;
|
final String province;
|
||||||
final String carrier;
|
final String carrier;
|
||||||
final List<String> groups;
|
final List<String> groups;
|
||||||
|
final bool dedicated;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final String probeUrl;
|
final String probeUrl;
|
||||||
|
|
||||||
@@ -1154,6 +1324,7 @@ class _CustomNode {
|
|||||||
required this.province,
|
required this.province,
|
||||||
required this.carrier,
|
required this.carrier,
|
||||||
required this.groups,
|
required this.groups,
|
||||||
|
required this.dedicated,
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
required this.probeUrl,
|
required this.probeUrl,
|
||||||
});
|
});
|
||||||
@@ -1171,6 +1342,7 @@ class _CustomNode {
|
|||||||
province: json['province']?.toString() ?? '',
|
province: json['province']?.toString() ?? '',
|
||||||
carrier: json['carrier']?.toString() ?? '',
|
carrier: json['carrier']?.toString() ?? '',
|
||||||
groups: _asStringList(json['groups']),
|
groups: _asStringList(json['groups']),
|
||||||
|
dedicated: json['dedicated'] == true,
|
||||||
enabled: json['enabled'] != false,
|
enabled: json['enabled'] != false,
|
||||||
probeUrl: json['probe_url']?.toString() ?? '',
|
probeUrl: json['probe_url']?.toString() ?? '',
|
||||||
);
|
);
|
||||||
@@ -1189,6 +1361,20 @@ class _CustomNode {
|
|||||||
return ip.trim();
|
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 {
|
String get endpointLabel {
|
||||||
final endpoint = endpointHost;
|
final endpoint = endpointHost;
|
||||||
if (endpoint.isEmpty) return '未配置地址';
|
if (endpoint.isEmpty) return '未配置地址';
|
||||||
|
|||||||
@@ -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<SelectedCustomLineNode?> 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<String, dynamic>) return null;
|
||||||
|
final node = SelectedCustomLineNode.fromJson(decoded);
|
||||||
|
if (!node.canRewriteDownloadUrl) return null;
|
||||||
|
return node;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> clearSelectedNode() async {
|
||||||
|
await StorageService.instance.remove(StorageKeys.selectedCustomLineNode);
|
||||||
|
await HostMappingProxyService.instance.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CustomLineDownloadRoute> 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<HostMappingProxyEndpoint?> activateSelectedNodeForHost(String host) {
|
||||||
|
return activateSelectedNodeForHosts([host]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<HostMappingProxyEndpoint?> activateSelectedNodeForHosts(
|
||||||
|
Iterable<String> 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 = <String, String>{};
|
||||||
|
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<HostMappingProxyEndpoint?> activateSelectedNodeForUrl(String url) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
return activateSelectedNodeForHost(uri?.host ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<HostMappingProxyEndpoint?> activateSelectedNodeForUrls(
|
||||||
|
Iterable<String> urls,
|
||||||
|
) {
|
||||||
|
final hosts = urls
|
||||||
|
.map((url) => Uri.tryParse(url)?.host ?? '')
|
||||||
|
.where((host) => host.trim().isNotEmpty);
|
||||||
|
return activateSelectedNodeForHosts(hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<String, dynamic> 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<String, dynamic> 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<String, String> 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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../core/constants/storage_keys.dart';
|
import '../core/constants/storage_keys.dart';
|
||||||
import '../data/models/download_task_model.dart';
|
import '../data/models/download_task_model.dart';
|
||||||
|
import 'custom_line_service.dart';
|
||||||
import 'file_service.dart';
|
import 'file_service.dart';
|
||||||
import 'storage_service.dart';
|
import 'storage_service.dart';
|
||||||
import '../core/utils/app_logger.dart';
|
import '../core/utils/app_logger.dart';
|
||||||
@@ -261,7 +262,14 @@ class DownloadService {
|
|||||||
await file.delete();
|
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) {
|
} catch (e) {
|
||||||
AppLogger.d('下载失败: $e');
|
AppLogger.d('下载失败: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -272,12 +280,15 @@ class DownloadService {
|
|||||||
Future<String?> _startBdDownload(
|
Future<String?> _startBdDownload(
|
||||||
DownloadTaskModel task,
|
DownloadTaskModel task,
|
||||||
String url,
|
String url,
|
||||||
Directory dir,
|
Directory dir, {
|
||||||
) async {
|
Map<String, String> headers = const {},
|
||||||
|
}) async {
|
||||||
|
await _configureCustomLineProxy();
|
||||||
final wifiOnly = await isWifiOnlyEnabled();
|
final wifiOnly = await isWifiOnlyEnabled();
|
||||||
final retries = await getRetries();
|
final retries = await getRetries();
|
||||||
final bdTask = bd.DownloadTask(
|
final bdTask = bd.DownloadTask(
|
||||||
url: url,
|
url: url,
|
||||||
|
headers: headers,
|
||||||
filename: task.fileName,
|
filename: task.fileName,
|
||||||
directory: dir.path,
|
directory: dir.path,
|
||||||
baseDirectory: bd.BaseDirectory.root,
|
baseDirectory: bd.BaseDirectory.root,
|
||||||
@@ -340,10 +351,19 @@ class DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask
|
// 恢复任务:不删除部分文件,使用 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 wifiOnly = await isWifiOnlyEnabled();
|
||||||
final retries = await getRetries();
|
final retries = await getRetries();
|
||||||
final bdTask = bd.DownloadTask(
|
final bdTask = bd.DownloadTask(
|
||||||
url: url,
|
url: route.url,
|
||||||
|
headers: route.headers,
|
||||||
filename: task.fileName,
|
filename: task.fileName,
|
||||||
directory: dir.path,
|
directory: dir.path,
|
||||||
baseDirectory: bd.BaseDirectory.root,
|
baseDirectory: bd.BaseDirectory.root,
|
||||||
@@ -382,6 +402,14 @@ class DownloadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> pauseDownload(String taskId) async {
|
Future<void> pauseDownload(String taskId) async {
|
||||||
final bdTask = _bdTasks[taskId];
|
final bdTask = _bdTasks[taskId];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
import '../core/utils/app_logger.dart';
|
import '../core/utils/app_logger.dart';
|
||||||
import '../core/utils/file_utils.dart';
|
import '../core/utils/file_utils.dart';
|
||||||
|
import 'custom_line_service.dart';
|
||||||
|
|
||||||
/// 文件服务
|
/// 文件服务
|
||||||
class FileService {
|
class FileService {
|
||||||
@@ -156,10 +157,25 @@ class FileService {
|
|||||||
data: data,
|
data: data,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
);
|
);
|
||||||
|
await _activateCustomLineForUrls(response);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _activateCustomLineForUrls(Map<String, dynamic> response) async {
|
||||||
|
final urls = response['urls'];
|
||||||
|
if (urls is! List) return;
|
||||||
|
|
||||||
|
final resolvedUrls = <String>[];
|
||||||
|
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<List<Map<String, dynamic>>> createDirectLinks({
|
Future<List<Map<String, dynamic>>> createDirectLinks({
|
||||||
required List<String> uris,
|
required List<String> uris,
|
||||||
|
|||||||
@@ -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<String, String> _hostMap = const {};
|
||||||
|
|
||||||
|
HostMappingProxyEndpoint? get endpoint => _hostMap.isEmpty ? null : _endpoint;
|
||||||
|
|
||||||
|
Future<HostMappingProxyEndpoint?> configure(
|
||||||
|
Map<String, String> hostMap,
|
||||||
|
) async {
|
||||||
|
final cleaned = <String, String>{};
|
||||||
|
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<void> 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<void> _serveClient(Socket client) async {
|
||||||
|
Socket? remote;
|
||||||
|
StreamController<List<int>>? 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<List<int>>().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<List<int>>(sync: true);
|
||||||
|
late StreamSubscription<Uint8List> 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<int> 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<Socket> _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<String> 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<int> _rewriteHttpHeader(List<int> 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<String> 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<List<int>> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,12 +45,7 @@ class ServerService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.d('加载服务器列表失败: $e');
|
AppLogger.d('加载服务器列表失败: $e');
|
||||||
// 加载失败时使用默认服务器
|
// 加载失败时使用默认服务器
|
||||||
_servers = [
|
_servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
|
||||||
ServerModel(
|
|
||||||
label: _defaultLabel,
|
|
||||||
baseUrl: _defaultBaseUrl,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
_currentServer = _servers.first;
|
_currentServer = _servers.first;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,15 +60,10 @@ class ServerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentServer = (savedDefaultServer ??
|
_currentServer =
|
||||||
ServerModel(
|
(savedDefaultServer ??
|
||||||
label: _defaultLabel,
|
ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl))
|
||||||
baseUrl: _defaultBaseUrl,
|
.copyWith(label: _defaultLabel, baseUrl: _defaultBaseUrl);
|
||||||
))
|
|
||||||
.copyWith(
|
|
||||||
label: _defaultLabel,
|
|
||||||
baseUrl: _defaultBaseUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
_servers = [_currentServer!];
|
_servers = [_currentServer!];
|
||||||
}
|
}
|
||||||
@@ -91,7 +81,9 @@ class ServerService {
|
|||||||
/// 保存上次选中的服务器
|
/// 保存上次选中的服务器
|
||||||
Future<void> _saveLastSelected() async {
|
Future<void> _saveLastSelected() async {
|
||||||
if (_currentServer != null) {
|
if (_currentServer != null) {
|
||||||
await StorageService.instance.setLastSelectedServerLabel(_currentServer!.label);
|
await StorageService.instance.setLastSelectedServerLabel(
|
||||||
|
_currentServer!.label,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,16 +117,21 @@ class ServerService {
|
|||||||
String? password,
|
String? password,
|
||||||
UserModel? user,
|
UserModel? user,
|
||||||
bool? rememberMe,
|
bool? rememberMe,
|
||||||
|
bool clearEmail = false,
|
||||||
|
bool clearPassword = false,
|
||||||
|
bool clearUser = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (_currentServer == null) {
|
if (_currentServer == null) {
|
||||||
throw Exception('没有选中的服务器');
|
throw Exception('没有选中的服务器');
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentServer = _currentServer!.copyWith(
|
_currentServer = ServerModel(
|
||||||
email: email,
|
label: _currentServer!.label,
|
||||||
password: password,
|
baseUrl: _currentServer!.baseUrl,
|
||||||
user: user,
|
|
||||||
rememberMe: rememberMe ?? _currentServer!.rememberMe,
|
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,
|
email: null,
|
||||||
password: null,
|
password: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
clearEmail: true,
|
||||||
|
clearPassword: true,
|
||||||
|
clearUser: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重置为默认服务器列表
|
/// 重置为默认服务器列表
|
||||||
Future<void> resetToDefault() async {
|
Future<void> resetToDefault() async {
|
||||||
_servers = [
|
_servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
|
||||||
ServerModel(
|
|
||||||
label: _defaultLabel,
|
|
||||||
baseUrl: _defaultBaseUrl,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
_currentServer = _servers.first;
|
_currentServer = _servers.first;
|
||||||
await _saveServers();
|
await _saveServers();
|
||||||
await _saveLastSelected();
|
await _saveLastSelected();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'api_service.dart';
|
|||||||
import '../core/utils/app_logger.dart';
|
import '../core/utils/app_logger.dart';
|
||||||
import '../core/utils/file_utils.dart';
|
import '../core/utils/file_utils.dart';
|
||||||
import '../core/utils/time_flow_decoder.dart';
|
import '../core/utils/time_flow_decoder.dart';
|
||||||
|
import 'custom_line_service.dart';
|
||||||
|
|
||||||
/// 缩略图缓存条目
|
/// 缩略图缓存条目
|
||||||
class _ThumbCacheEntry {
|
class _ThumbCacheEntry {
|
||||||
@@ -41,6 +42,9 @@ class ThumbnailService {
|
|||||||
// 1. 检查内存缓存
|
// 1. 检查内存缓存
|
||||||
final cached = _urlCache[cacheKey];
|
final cached = _urlCache[cacheKey];
|
||||||
if (cached != null && !cached.isExpired) {
|
if (cached != null && !cached.isExpired) {
|
||||||
|
await CustomLineService.instance.activateSelectedNodeForUrl(
|
||||||
|
cached.imageUrl,
|
||||||
|
);
|
||||||
return cached.imageUrl;
|
return cached.imageUrl;
|
||||||
}
|
}
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
@@ -64,7 +68,10 @@ class ThumbnailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _fetchThumbnailUrl(String fileUri, String? contextHint) async {
|
Future<String?> _fetchThumbnailUrl(
|
||||||
|
String fileUri,
|
||||||
|
String? contextHint,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final uri = FileUtils.toCloudreveUri(fileUri);
|
final uri = FileUtils.toCloudreveUri(fileUri);
|
||||||
final headers = contextHint != null
|
final headers = contextHint != null
|
||||||
@@ -97,12 +104,15 @@ class ThumbnailService {
|
|||||||
if (url.isEmpty) return null;
|
if (url.isEmpty) return null;
|
||||||
|
|
||||||
AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url');
|
AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url');
|
||||||
|
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||||
|
|
||||||
// 解析过期时间,缓存提前 30 秒过期
|
// 解析过期时间,缓存提前 30 秒过期
|
||||||
DateTime expiresAt;
|
DateTime expiresAt;
|
||||||
if (expiresStr != null) {
|
if (expiresStr != null) {
|
||||||
try {
|
try {
|
||||||
expiresAt = DateTime.parse(expiresStr).subtract(const Duration(seconds: 30));
|
expiresAt = DateTime.parse(
|
||||||
|
expiresStr,
|
||||||
|
).subtract(const Duration(seconds: 30));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
expiresAt = DateTime.now().add(const Duration(minutes: 5));
|
expiresAt = DateTime.now().add(const Duration(minutes: 5));
|
||||||
}
|
}
|
||||||
@@ -118,7 +128,9 @@ class ThumbnailService {
|
|||||||
|
|
||||||
return url;
|
return url;
|
||||||
} catch (e) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user