import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:network_info_plus/network_info_plus.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'PPT遥控器', theme: ThemeData( primarySwatch: Colors.blue, ), home: const ConnectionPage(), ); } } // 服务器信息类 class ServerInfo { final String ip; final int port; final String name; final String system; ServerInfo({ required this.ip, required this.port, required this.name, required this.system, }); factory ServerInfo.fromJson(Map json) { return ServerInfo( ip: json['ip'], port: json['port'], name: json['name'], system: json['system'], ); } } // 关于页面 class AboutPage extends StatelessWidget { const AboutPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('关于'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), ), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( children: [ // Logo const SizedBox(height: 40), Container( width: 120, height: 120, decoration: BoxDecoration( color: Colors.blue, borderRadius: BorderRadius.circular(20), ), child: const Center( child: Icon( Icons.laptop, size: 60, color: Colors.white, ), ), ), const SizedBox(height: 30), // 软件名称 const Text( 'PPT遥控器', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.black87, ), ), const SizedBox(height: 10), // 当前版本号 const Text( '版本 1.0.0', style: TextStyle( fontSize: 16, color: Colors.grey, ), ), const SizedBox(height: 40), // 官网地址 Container( padding: const EdgeInsets.all(15), decoration: BoxDecoration( border: Border.all(color: Colors.blue.withOpacity(0.3)), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ const Icon(Icons.web, color: Colors.blue), const SizedBox(width: 10), const Expanded( child: Text( 'ppt.xiugan.com', style: TextStyle( color: Colors.blue, decoration: TextDecoration.underline, ), ), ), ], ), ), const SizedBox(height: 20), // 版本更新检测 ElevatedButton( onPressed: () async { const url = 'https://ppt.xiugan.com/download'; if (await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url)); } }, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(25), ), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.update, color: Colors.white), SizedBox(width: 8), Text( '检查版本更新', style: TextStyle(fontSize: 16, color: Colors.white), ), ], ), ), const SizedBox(height: 40), // 版权信息 const Text( '© 2026 PPT遥控器. All rights reserved.', style: TextStyle( fontSize: 12, color: Colors.grey, ), ), ], ), ), ); } } // 全局WebSocket通道 WebSocketChannel? globalChannel; // 二维码扫描页面 class QRScannerPage extends StatefulWidget { final Function(String) onQRScanned; const QRScannerPage({super.key, required this.onQRScanned}); @override State createState() => _QRScannerPageState(); } class _QRScannerPageState extends State { bool _scanned = false; // 防止重复扫描的标记 @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('扫描二维码'), leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), // 点击关闭返回上一页 ), ), body: MobileScanner( onDetect: (capture) { // 当检测到二维码时触发 if (!_scanned) { _scanned = true; final String? code = capture.barcodes.first.rawValue; // 获取扫描内容 if (code != null) { // 先关闭页面,再处理结果 Navigator.pop(context); // 立即关闭扫描页面 // 使用微任务延迟处理,避免阻塞UI Future.microtask(() { widget.onQRScanned(code); // 调用回调函数返回结果 }); } } }, // 可选:添加一个扫描框 Overlay (视觉辅助) overlay: Container( decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 2), borderRadius: BorderRadius.circular(10), ), margin: const EdgeInsets.symmetric(horizontal: 50), ), ), ); } } // 服务器连接页面 class ConnectionPage extends StatefulWidget { const ConnectionPage({super.key}); @override State createState() => _ConnectionPageState(); } class _ConnectionPageState extends State { // 服务器地址输入控制器 final TextEditingController _ipController = TextEditingController(text: '192.168.'); // 端口输入控制器 final TextEditingController _portController = TextEditingController(text: '8765'); // 消息显示 String _message = '请连接到电脑端服务器'; // 连接状态 bool _isConnected = false; // 服务发现相关 List _discoveredServers = []; bool _isSearching = false; List _lanIps = []; int _scannedCount = 0; RawDatagramSocket? _udpSocket; // 获取手机本机局域网IP Future _getLocalIpAddress() async { try { final NetworkInfo networkInfo = NetworkInfo(); String? ip = await networkInfo.getWifiIP(); return ip; } catch (e) { print('获取本机IP失败: $e'); return null; } } // 根据子网掩码计算局域网内所有IP List _calculateLanIps(String localIp) { List ips = []; try { List parts = localIp.split('.'); if (parts.length != 4) return ips; String networkPrefix = '${parts[0]}.${parts[1]}.${parts[2]}.'; for (int i = 1; i <= 254; i++) { if (i != int.parse(parts[3])) { ips.add('$networkPrefix$i'); } } } catch (e) { print('计算局域网IP失败: $e'); } return ips; } // 扫描单个IP Future _scanIp(String ip) async { try { String url = 'ws://$ip:${_portController.text}'; // 使用更短的超时时间提高搜索速度 Completer completer = Completer(); Timer? timeout; WebSocketChannel? channel; timeout = Timer(Duration(milliseconds: 200), () { if (!completer.isCompleted) { channel?.sink.close(); completer.complete(false); } }); try { channel = WebSocketChannel.connect(Uri.parse(url)); // 尝试读取数据 channel.stream.first.then((data) { if (!completer.isCompleted) { timeout?.cancel(); completer.complete(true); } }).catchError((error) { if (!completer.isCompleted) { timeout?.cancel(); completer.complete(false); } }); } catch (e) { if (!completer.isCompleted) { completer.complete(false); } } bool success = await completer.future; if (success && channel != null) { setState(() { ServerInfo server = ServerInfo( ip: ip, port: int.parse(_portController.text), name: 'PPT服务器', system: 'Unknown', ); if (!_discoveredServers.any((s) => s.ip == ip)) { _discoveredServers.add(server); // 自动连接到第一个发现的服务器 if (_discoveredServers.length == 1) { String connectUrl = 'ws://${server.ip}:${server.port}'; _connectToServer(connectUrl); } } }); channel.sink.close(); } } catch (e) { // IP不可用,忽略错误 } finally { setState(() { _scannedCount++; }); } } // 启动UDP广播接收 void _startUdpDiscovery() async { try { // 创建UDP套接字 _udpSocket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 5000); _udpSocket?.broadcastEnabled = true; // 监听UDP数据包 _udpSocket?.listen((event) { if (event == RawSocketEvent.read && _udpSocket != null) { Datagram? datagram = _udpSocket?.receive(); if (datagram != null) { try { // 解析UDP广播数据 String data = String.fromCharCodes(datagram.data); Map serverInfo = jsonDecode(data); if (serverInfo['type'] == 'ppt_remote_server') { String ip = serverInfo['ip']; int port = serverInfo['port']; String name = serverInfo['name']; String system = serverInfo['system']; // 添加到发现的服务器列表 setState(() { ServerInfo server = ServerInfo( ip: ip, port: port, name: name, system: system, ); if (!_discoveredServers.any((s) => s.ip == ip)) { _discoveredServers.add(server); // 自动连接到第一个发现的服务器 if (_discoveredServers.length == 1) { String connectUrl = 'ws://${server.ip}:${server.port}'; _connectToServer(connectUrl); } } }); } } catch (e) { print('解析UDP数据失败: $e'); } } } }); } catch (e) { print('启动UDP广播接收失败: $e'); } } // 停止UDP广播接收 void _stopUdpDiscovery() { _udpSocket?.close(); _udpSocket = null; } // 开始搜索服务器 void _startSearch() async { setState(() { _isSearching = true; _discoveredServers.clear(); _lanIps.clear(); _scannedCount = 0; }); try { // 启动UDP广播接收(快速发现) _startUdpDiscovery(); // 获取本机IP String? localIp = await _getLocalIpAddress(); if (localIp != null) { // 计算局域网内所有IP _lanIps = _calculateLanIps(localIp); if (_lanIps.isEmpty) { setState(() { _isSearching = false; _stopUdpDiscovery(); _message = '无法计算局域网IP'; }); return; } // 使用并行处理提高搜索效率 int totalIps = _lanIps.length; // 创建所有扫描任务 List> scanTasks = []; for (String ip in _lanIps) { scanTasks.add(_scanIp(ip)); } // 实时更新进度 Timer.periodic(Duration(milliseconds: 200), (timer) { if (!_isSearching) { timer.cancel(); return; } setState(() { _message = '正在搜索服务器... ${_scannedCount}/$totalIps'; }); }); // 等待所有任务完成 await Future.wait(scanTasks); // 延长UDP广播接收时间,确保能捕获到广播 await Future.delayed(Duration(seconds: 3)); // 搜索完成 setState(() { _isSearching = false; _stopUdpDiscovery(); if (_discoveredServers.isEmpty) { _message = '未发现局域网内的PPT服务器'; } else { _message = '发现 ${_discoveredServers.length} 个服务器'; } }); } else { setState(() { _isSearching = false; _stopUdpDiscovery(); _message = '无法获取本机IP地址,请检查网络连接'; }); } } catch (e) { print('搜索失败: $e'); setState(() { _isSearching = false; _stopUdpDiscovery(); _message = '搜索失败: ${e.toString()}'; }); } } // 连接服务器 void _connect() { String ip = _ipController.text; String port = _portController.text; String url = 'ws://$ip:$port'; _connectToServer(url); } // 从二维码连接服务器 void _connectFromQR(String qrCode) { try { Uri uri = Uri.parse(qrCode); if (uri.scheme == 'ws') { setState(() { _ipController.text = uri.host; _portController.text = uri.port.toString(); }); _connectToServer(qrCode); } else { setState(() { _message = '二维码内容不是有效的WebSocket地址'; }); } } catch (e) { setState(() { _message = '二维码解析失败: $e'; }); } } // 通用连接服务器方法 void _connectToServer(String url) { try { globalChannel = WebSocketChannel.connect(Uri.parse(url)); setState(() { _isConnected = false; _message = '正在连接到服务器...'; }); globalChannel?.stream.listen((message) { print('服务器消息: $message'); setState(() { _isConnected = true; _message = '连接成功'; }); // 只有连接成功后才跳转到播放页面 Navigator.push( context, MaterialPageRoute(builder: (context) => const PlayPage()), ); }, onError: (error) { setState(() { _isConnected = false; _message = '连接失败: ${error.toString()}'; }); globalChannel = null; }, onDone: () { setState(() { _isConnected = false; _message = '连接已断开'; }); globalChannel = null; }); } catch (e) { setState(() { _isConnected = false; _message = '连接失败: ${e.toString()}'; }); } } // 扫描二维码 void _scanQRCode() { Navigator.push( context, MaterialPageRoute( builder: (context) => QRScannerPage( onQRScanned: _connectFromQR, ), ), ); } // 断开连接 void _disconnect() { if (globalChannel != null) { globalChannel?.sink.close(); globalChannel = null; setState(() { _isConnected = false; _message = '已断开连接'; }); } } // 连接到发现的服务器 void _connectToDiscoveredServer(ServerInfo server) { setState(() { _ipController.text = server.ip; _portController.text = server.port.toString(); }); _connect(); } @override void dispose() { _ipController.dispose(); _portController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('PPT遥控器'), centerTitle: true, ), body: SingleChildScrollView( padding: const EdgeInsets.all(20.0), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 连接设置区域 Card( elevation: 8, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(20.0), child: Column( children: [ const Text( '服务器连接', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.blue, ), ), const SizedBox(height: 16), // IP地址输入 TextField( controller: _ipController, decoration: const InputDecoration( labelText: '服务器地址', border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), keyboardType: TextInputType.text, style: const TextStyle(fontSize: 16), ), const SizedBox(height: 16), // 端口输入 TextField( controller: _portController, decoration: const InputDecoration( labelText: '端口号', border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), keyboardType: TextInputType.number, style: const TextStyle(fontSize: 16), ), const SizedBox(height: 20), // 搜索、扫描和连接按钮 Row( children: [ Expanded( child: ElevatedButton.icon( onPressed: _isSearching ? null : _startSearch, icon: const Icon(Icons.search, size: 18), label: const Text('搜索', style: TextStyle(fontSize: 16)), style: ElevatedButton.styleFrom( backgroundColor: Colors.green, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton.icon( onPressed: _scanQRCode, icon: const Icon(Icons.qr_code_scanner, size: 18), label: const Text('扫描', style: TextStyle(fontSize: 16)), style: ElevatedButton.styleFrom( backgroundColor: Colors.orange, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton( onPressed: _isConnected ? _disconnect : _connect, style: ElevatedButton.styleFrom( backgroundColor: _isConnected ? Colors.red : Colors.blue, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: Text(_isConnected ? '断开' : '连接', style: TextStyle(fontSize: 16)), ), ), ], ), const SizedBox(height: 16), // 搜索状态和发现的服务器列表 if (_isSearching) Column( children: [ const Text( '正在搜索局域网服务器...', style: TextStyle(color: Colors.blue, fontSize: 14), ), Text( '进度: $_scannedCount/${_lanIps.length}', style: const TextStyle(color: Colors.blue, fontSize: 14), ), ], ), if (_discoveredServers.isNotEmpty) Container( margin: const EdgeInsets.only(top: 16), padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all(color: Colors.grey[300]!), borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '发现的服务器:', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey[700], ), ), const SizedBox(height: 8), ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _discoveredServers.length, itemBuilder: (context, index) { final server = _discoveredServers[index]; return Card( elevation: 2, margin: const EdgeInsets.only(bottom: 8), child: ListTile( title: Text(server.name, style: const TextStyle(fontSize: 16)), subtitle: Text('${server.ip}:${server.port}', style: TextStyle(fontSize: 14, color: Colors.grey[600])), onTap: () => _connectToDiscoveredServer(server), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ); }, ), ], ), ), // 连接状态消息 Container( margin: const EdgeInsets.only(top: 16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: _message.contains('失败') ? Colors.red[50] : Colors.green[50], borderRadius: BorderRadius.circular(8), ), child: Text( _message, style: TextStyle( color: _message.contains('失败') ? Colors.red : Colors.green, fontSize: 14, ), textAlign: TextAlign.center, ), ), ], ), ), ), const SizedBox(height: 40), // 底部链接 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () => launchUrl(Uri.parse('https://ppt.xiugan.com')), child: const Text('官网', style: TextStyle(fontSize: 14)), ), const Text(' | ', style: TextStyle(color: Colors.grey)), TextButton( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (context) => const AboutPage()), ); }, child: const Text('关于', style: TextStyle(fontSize: 14)), ), ], ), const SizedBox(height: 40), ], ), ), ), ), ); } } // 播放页面 class PlayPage extends StatelessWidget { const PlayPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('PPT遥控器'), centerTitle: true, leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), ), body: Center( child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 播放按钮 Center( child: SizedBox( width: 240, height: 240, child: ElevatedButton( onPressed: () { globalChannel?.sink.add('start'); Navigator.push( context, MaterialPageRoute(builder: (context) => const ControlPage()), ); }, style: ElevatedButton.styleFrom( shape: const CircleBorder(), backgroundColor: Colors.blue, elevation: 8, ), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.play_arrow, size: 80, color: Colors.white), Text('播放PPT', style: TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.bold)), ], ), ), ), ), const SizedBox(height: 40), // 连接状态 Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), decoration: BoxDecoration( color: Colors.green[50], borderRadius: BorderRadius.circular(8), ), child: const Text( '已经连接上电脑,现在可以控制播放PPT了。', style: TextStyle(color: Colors.green, fontSize: 16), textAlign: TextAlign.center, ), ), const SizedBox(height: 80), // 底部链接 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () => launchUrl(Uri.parse('https://ppt.xiugan.com')), child: const Text('官网', style: TextStyle(fontSize: 14)), ), const Text(' | ', style: TextStyle(color: Colors.grey)), TextButton( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (context) => const AboutPage()), ); }, child: const Text('关于', style: TextStyle(fontSize: 14)), ), ], ), const SizedBox(height: 40), ], ), ), ), ); } } // 控制页面 class ControlPage extends StatefulWidget { const ControlPage({super.key}); @override State createState() => _ControlPageState(); } class _ControlPageState extends State { String _playTime = '00:00:00'; Timer? _timer; int _seconds = 0; @override void initState() { super.initState(); // 开始计时 _timer = Timer.periodic(const Duration(seconds: 1), (timer) { setState(() { _seconds++; int hours = _seconds ~/ 3600; int minutes = (_seconds % 3600) ~/ 60; int secs = _seconds % 60; _playTime = '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}'; }); }); } @override void dispose() { _timer?.cancel(); super.dispose(); } void _sendCommand(String command) { if (globalChannel != null) { globalChannel?.sink.add(command); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('PPT遥控器'), centerTitle: true, leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), ), body: Column( children: [ // 上一页按钮 Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.all(20.0), child: SizedBox( width: 100, height: 100, child: ElevatedButton( onPressed: () => _sendCommand('prev'), style: ElevatedButton.styleFrom( shape: const CircleBorder(), backgroundColor: Colors.grey[700], elevation: 4, ), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.arrow_upward, size: 40, color: Colors.white), Text('上一页', style: TextStyle(fontSize: 14, color: Colors.white, fontWeight: FontWeight.bold)), ], ), ), ), ), ), // 下一页按钮 Expanded( child: Center( child: SizedBox( width: 280, height: 280, child: ElevatedButton( onPressed: () => _sendCommand('next'), style: ElevatedButton.styleFrom( shape: const CircleBorder(), backgroundColor: Colors.blue[800], elevation: 8, ), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.arrow_downward, size: 100, color: Colors.white), Text('下一页', style: TextStyle(fontSize: 28, color: Colors.white, fontWeight: FontWeight.bold)), ], ), ), ), ), ), // 播放时间 Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), decoration: BoxDecoration( color: Colors.grey[50], borderRadius: BorderRadius.circular(8), ), child: Text( '已经播放 $_playTime', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), const SizedBox(height: 40), // 底部链接 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () => launchUrl(Uri.parse('https://ppt.xiugan.com')), child: const Text('官网', style: TextStyle(fontSize: 14)), ), const Text(' | ', style: TextStyle(color: Colors.grey)), TextButton( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (context) => const AboutPage()), ); }, child: const Text('关于', style: TextStyle(fontSize: 14)), ), ], ), const SizedBox(height: 40), ], ), ); } }