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; }