From d76bcdcdf298e57ac634092f59ac2ee78ec6d55e Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Thu, 10 Sep 2026 08:55:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A3=B0=E7=BD=91=E9=80=9A=E7=94=A8=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=BC=80=E5=8F=91=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/components/agora_video_player.dart | 947 ++++++++++++++++++ .../widgets/map/geoserver_suzhou_layer.dart | 393 +++++++- 2 files changed, 1338 insertions(+), 2 deletions(-) create mode 100644 lib/components/agora_video_player.dart diff --git a/lib/components/agora_video_player.dart b/lib/components/agora_video_player.dart new file mode 100644 index 00000000..fce18c62 --- /dev/null +++ b/lib/components/agora_video_player.dart @@ -0,0 +1,947 @@ +import 'dart:async'; + +import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora; +import 'package:flutter/material.dart'; + +/// 声网视频播放状态 +enum AgoraPlayerState { + /// 空闲:未连接或已主动断开 + idle, + + /// 连接中:初始化引擎 / 加入频道 / 等待远端首帧 + connecting, + + /// 播放中:已收到远端首帧 + playing, + + /// 失败:参数缺失、加入频道失败、超时或 SDK 报错 + error, +} + +/// 通用声网(Agora RTC)视频展示组件 +/// +/// 只需要 appId / channelId / token 三个值即可拉流播放,组件内部自行完成 +/// 引擎创建、加入频道、远端首帧监听、渲染与资源释放,调用方不用管生命周期。 +/// +/// 两种用法: +/// +/// 1)直接当普通 Widget 嵌进布局: +/// ```dart +/// AgoraVideoPlayer(appId: 'x', channelId: 'y', token: 'z') +/// ``` +/// +/// 2)一行代码弹出(推荐,关闭弹窗即自动释放引擎): +/// ```dart +/// onTap: () => AgoraVideoPlayer.show( +/// context, +/// appId: 'x', +/// channelId: 'y', +/// token: 'z', +/// ), +/// ``` +class AgoraVideoPlayer extends StatefulWidget { + /// 声网 App ID + final String appId; + + /// 频道名(后端接口里的 channel / room_id) + final String channelId; + + /// 频道 Token,未开启鉴权时可传空串 + final String token; + + /// 本端 uid,纯观看传 0 由 SDK 自动分配 + final int uid; + + /// 频道模式,默认直播模式 + final agora.ChannelProfileType channelProfile; + + /// 本端角色,默认观众(只拉流不推流,不占用相机麦克风) + final agora.ClientRoleType clientRole; + + /// 画面填充模式 + final agora.RenderModeType renderMode; + + /// 是否订阅音频,默认只订阅视频 + final bool subscribeAudio; + + /// Android 使用 SurfaceView 渲染(出现黑屏 / 层级遮挡时可切 true) + final bool useAndroidSurfaceView; + + /// iOS、桌面端使用 Flutter Texture 渲染 + final bool useFlutterTexture; + + /// 是否在 initState 自动连接 + final bool autoConnect; + + /// 连接超时:超过该时长仍未收到远端首帧判定为失败 + final Duration connectTimeout; + + /// 状态变化回调 + final ValueChanged? onStateChanged; + + /// 首帧解码成功回调,参数为远端 uid + final ValueChanged? onFirstFrame; + + /// 错误回调 + final ValueChanged? onError; + + /// 视频区背景色 + final Color backgroundColor; + + /// 正在建连时的提示文案 + final String loadingText; + + /// 已进房但未收到画面时的提示文案 + final String waitingText; + + /// 失败时是否显示内置重试按钮 + final bool showRetryButton; + + /// 自定义失败态 UI,返回 null 则使用内置样式 + final Widget Function(BuildContext context, String message)? errorBuilder; + + /// 重连令牌:值变化即强制重新连接,即使 appId / channelId / token 完全相同 + /// (供「重新加载」按钮使用) + final int reloadToken; + + const AgoraVideoPlayer({ + super.key, + required this.appId, + required this.channelId, + required this.token, + this.uid = 0, + this.channelProfile = + agora.ChannelProfileType.channelProfileLiveBroadcasting, + this.clientRole = agora.ClientRoleType.clientRoleAudience, + this.renderMode = agora.RenderModeType.renderModeFit, + this.subscribeAudio = false, + this.useAndroidSurfaceView = false, + this.useFlutterTexture = false, + this.autoConnect = true, + this.connectTimeout = const Duration(seconds: 15), + this.onStateChanged, + this.onFirstFrame, + this.onError, + this.backgroundColor = Colors.black, + this.loadingText = '视频加载中...', + this.waitingText = '等待视频流...', + this.showRetryButton = true, + this.errorBuilder, + this.reloadToken = 0, + }); + + /// 从后端返回的 `app_id=xx&channel=yy&token=zz&user_id=0` 形式参数串构建 + factory AgoraVideoPlayer.fromUrl({ + Key? key, + required String url, + agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit, + Duration connectTimeout = const Duration(seconds: 15), + ValueChanged? onStateChanged, + }) { + final p = parseParams(url); + return AgoraVideoPlayer( + key: key, + appId: p['app_id'] ?? p['appid'] ?? '', + channelId: + p['channel'] ?? p['channel_id'] ?? p['room_id'] ?? p['roomid'] ?? '', + token: p['token'] ?? '', + uid: int.tryParse(p['user_id'] ?? p['uid'] ?? '') ?? 0, + renderMode: renderMode, + connectTimeout: connectTimeout, + onStateChanged: onStateChanged, + ); + } + + /// 解析 `k=v&k=v` 参数串 + static Map parseParams(String url) { + final params = {}; + for (final pair in url.split('&')) { + final kv = pair.split('='); + if (kv.length == 2) { + params[kv[0].trim()] = Uri.decodeComponent(kv[1]); + } + } + return params; + } + + /// 【核心入口】一行代码弹出居中视频弹窗(16:9) + /// + /// 关闭弹窗时组件被移出 widget 树,自动触发 dispose → + /// leaveChannel + release,调用方不需要手动清理任何东西。 + static Future show( + BuildContext context, { + required String appId, + required String channelId, + required String token, + int uid = 0, + String title = '实时视频', + agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit, + bool barrierDismissible = true, + ValueChanged? onStateChanged, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: Colors.black87, + builder: (dialogContext) => Dialog( + backgroundColor: Colors.black, + elevation: 0, + insetPadding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 48, + padding: const EdgeInsets.only(left: 16), + color: const Color(0xFF2A2E34), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon( + Icons.close, + color: Colors.white70, + size: 20, + ), + onPressed: () => Navigator.pop(dialogContext), + ), + ], + ), + ), + AspectRatio( + aspectRatio: 16 / 9, + child: AgoraVideoPlayer( + appId: appId, + channelId: channelId, + token: token, + uid: uid, + renderMode: renderMode, + onStateChanged: onStateChanged, + ), + ), + ], + ), + ), + ), + ); + } + + /// 以底部弹层形式弹出(适合列表页预览,不遮挡整屏) + static Future showBottomSheet( + BuildContext context, { + required String appId, + required String channelId, + required String token, + int uid = 0, + double heightFactor = 0.42, + agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit, + ValueChanged? onStateChanged, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.black, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => SizedBox( + height: MediaQuery.of(sheetContext).size.height * heightFactor, + child: Column( + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + Expanded( + child: AgoraVideoPlayer( + appId: appId, + channelId: channelId, + token: token, + uid: uid, + renderMode: renderMode, + onStateChanged: onStateChanged, + ), + ), + ], + ), + ), + ); + } + + /// 【手动参数入口】弹出带 App ID / Channel / Token 三个输入框的弹窗 + /// + /// 填完点「重新加载」开始拉流;即使参数一模一样也会强制重连。 + /// 适用于接口未就绪、需要手工验证频道参数的场景。 + /// 关闭弹窗同样会自动释放引擎。 + static Future showManualInput( + BuildContext context, { + String title = '实时视频', + String initialAppId = '', + String initialChannelId = '', + String initialToken = '', + bool barrierDismissible = true, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: Colors.black87, + builder: (_) => _AgoraManualParamsDialog( + title: title, + initialAppId: initialAppId, + initialChannelId: initialChannelId, + initialToken: initialToken, + ), + ); + } + + @override + State createState() => _AgoraVideoPlayerState(); +} + +class _AgoraVideoPlayerState extends State { + agora.RtcEngine? _engine; + agora.RtcEngineEventHandler? _handler; + + AgoraPlayerState _state = AgoraPlayerState.idle; + String? _errorMessage; + int? _remoteUid; + bool _isJoined = false; + bool _isDisposed = false; + bool _firstFrameNotified = false; + Timer? _connectTimer; + + /// 连接序号:参数快速变化时用于丢弃过期的异步流程 + int _connectSeq = 0; + + @override + void initState() { + super.initState(); + if (widget.autoConnect) { + _connect(); + } + } + + @override + void didUpdateWidget(covariant AgoraVideoPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + final changed = + oldWidget.appId != widget.appId || + oldWidget.channelId != widget.channelId || + oldWidget.token != widget.token || + oldWidget.uid != widget.uid || + oldWidget.reloadToken != widget.reloadToken; + if (changed) { + _log('参数变化,重新连接: ${widget.channelId}'); + _connect(); + } + } + + @override + void dispose() { + _isDisposed = true; + _cancelConnectTimer(); + _release(); + super.dispose(); + } + + void _log(String msg) => debugPrint('🎥 [Agora] $msg'); + + void _updateState(AgoraPlayerState next, {String? message}) { + if (_state == next && message == _errorMessage) return; + _state = next; + _errorMessage = message; + if (next == AgoraPlayerState.playing) { + _cancelConnectTimer(); + } + if (mounted) { + setState(() {}); + } + widget.onStateChanged?.call(next); + if (next == AgoraPlayerState.error && message != null) { + widget.onError?.call(message); + } + } + + void _startConnectTimer() { + _cancelConnectTimer(); + _connectTimer = Timer(widget.connectTimeout, () { + if (_isDisposed || _state == AgoraPlayerState.playing) return; + _log('⏰ 连接超时(${widget.connectTimeout.inSeconds}s 未收到首帧)'); + _updateState(AgoraPlayerState.error, message: '视频连接超时,请重试'); + }); + } + + void _cancelConnectTimer() { + _connectTimer?.cancel(); + _connectTimer = null; + } + + /// 建立连接:先释放旧引擎,再创建 → 初始化 → 注册回调 → 加入频道 + Future _connect() async { + final seq = ++_connectSeq; + _cancelConnectTimer(); + await _release(); + if (_isDisposed || seq != _connectSeq) return; + + if (widget.appId.isEmpty || widget.channelId.isEmpty) { + _updateState(AgoraPlayerState.error, message: 'appId / channelId 不能为空'); + return; + } + + _remoteUid = null; + _isJoined = false; + _firstFrameNotified = false; + _updateState(AgoraPlayerState.connecting); + _startConnectTimer(); + + try { + final engine = agora.createAgoraRtcEngine(); + _engine = engine; + + await engine.initialize( + agora.RtcEngineContext( + appId: widget.appId, + channelProfile: widget.channelProfile, + ), + ); + if (_isDisposed || seq != _connectSeq) return; + + await engine.enableVideo(); + if (_isDisposed || seq != _connectSeq) return; + + _handler = _buildEventHandler(); + engine.registerEventHandler(_handler!); + + _log('加入频道: ${widget.channelId}'); + await engine.joinChannel( + token: widget.token, + channelId: widget.channelId, + uid: widget.uid, + options: agora.ChannelMediaOptions( + channelProfile: widget.channelProfile, + clientRoleType: widget.clientRole, + autoSubscribeVideo: true, + autoSubscribeAudio: widget.subscribeAudio, + ), + ); + if (_isDisposed || seq != _connectSeq) return; + _log('✅ joinChannel 调用成功,等待远端首帧...'); + } catch (e) { + _log('❌ 初始化失败: $e'); + _updateState(AgoraPlayerState.error, message: '视频连接失败: $e'); + } + } + + agora.RtcEngineEventHandler _buildEventHandler() { + return agora.RtcEngineEventHandler( + onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) { + _log('✅ 加入频道成功: ${connection.channelId}, 耗时 ${elapsed}ms'); + if (!mounted) return; + setState(() => _isJoined = true); + }, + + onUserJoined: (agora.RtcConnection connection, int rUid, int elapsed) { + _log('👤 远端用户加入: uid=$rUid'); + if (!mounted) return; + // VideoViewController.remote 内部断言 uid != 0,uid 为 0 时构建会崩 + if (rUid == 0) { + _log('⚠️ 远端 uid=0,无法用 remote View 渲染,已忽略'); + return; + } + setState(() => _remoteUid ??= rUid); + }, + + onUserOffline: ( + agora.RtcConnection connection, + int rUid, + agora.UserOfflineReasonType reason, + ) { + _log('👋 远端用户离开: uid=$rUid, reason=$reason'); + if (!mounted || _remoteUid != rUid) return; + setState(() => _remoteUid = null); + // 推流端掉线,回到等待态并重新计时 + _updateState(AgoraPlayerState.connecting); + _startConnectTimer(); + }, + + onRemoteVideoStateChanged: ( + agora.RtcConnection connection, + int rUid, + agora.RemoteVideoState state, + agora.RemoteVideoStateReason reason, + int elapsed, + ) { + if (!mounted) return; + _log('📹 远端视频状态: uid=$rUid, state=$state, reason=$reason'); + + if (state == agora.RemoteVideoState.remoteVideoStateDecoding) { + if (rUid != 0 && _remoteUid == null) { + setState(() => _remoteUid = rUid); + } + _updateState(AgoraPlayerState.playing); + if (!_firstFrameNotified) { + _firstFrameNotified = true; + widget.onFirstFrame?.call(rUid); + } + } else if (state == agora.RemoteVideoState.remoteVideoStateFrozen) { + _updateState(AgoraPlayerState.connecting); + } else if (state == agora.RemoteVideoState.remoteVideoStateFailed) { + _updateState(AgoraPlayerState.error, message: '远端视频流异常: $reason'); + } + }, + + onLeaveChannel: (agora.RtcConnection connection, agora.RtcStats stats) { + _log('已离开频道: ${connection.channelId}'); + if (!mounted) return; + setState(() { + _isJoined = false; + _remoteUid = null; + }); + }, + + onError: (agora.ErrorCodeType err, String msg) { + _log('❌ SDK 错误: $err - $msg'); + _updateState(AgoraPlayerState.error, message: '声网错误($err): $msg'); + }, + ); + } + + Future _release() async { + final engine = _engine; + final handler = _handler; + _engine = null; + _handler = null; + if (engine == null) return; + + try { + if (handler != null) { + engine.unregisterEventHandler(handler); + } + await engine.leaveChannel(); + await engine.release(); + _log('🗑️ 引擎已释放'); + } catch (e) { + _log('⚠️ 释放引擎失败: $e'); + } + } + + @override + Widget build(BuildContext context) { + // 关键:远端 uid 未知时绝不能构建 remote View(SDK 内部有 uid != 0 断言) + final canRender = _engine != null && _isJoined && _remoteUid != null; + + return Container( + color: widget.backgroundColor, + child: Stack( + fit: StackFit.expand, + children: [ + if (canRender) _buildVideoView(), + if (_state != AgoraPlayerState.playing) _buildOverlay(canRender), + ], + ), + ); + } + + Widget _buildVideoView() { + return agora.AgoraVideoView( + controller: agora.VideoViewController.remote( + rtcEngine: _engine!, + canvas: agora.VideoCanvas( + uid: _remoteUid!, + renderMode: widget.renderMode, + ), + connection: agora.RtcConnection(channelId: widget.channelId), + useAndroidSurfaceView: widget.useAndroidSurfaceView, + useFlutterTexture: widget.useFlutterTexture, + ), + ); + } + + Widget _buildOverlay(bool canRender) { + if (_state == AgoraPlayerState.error) { + final message = _errorMessage ?? '视频加载失败'; + if (widget.errorBuilder != null) { + return widget.errorBuilder!(context, message); + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.redAccent), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + message, + style: const TextStyle(color: Colors.white70, fontSize: 13), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.showRetryButton) ...[ + const SizedBox(height: 16), + ElevatedButton( + onPressed: _connect, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + ), + child: const Text('重试'), + ), + ], + ], + ), + ); + } + + final text = canRender ? widget.waitingText : widget.loadingText; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ), + const SizedBox(height: 12), + Text(text, style: const TextStyle(color: Colors.white70, fontSize: 13)), + ], + ), + ); + } +} + +/// 手动填写声网参数的弹窗,由 [AgoraVideoPlayer.showManualInput] 弹出 +/// +/// 结构:标题栏 + 三个输入框 + 「重新加载」按钮 + 16:9 视频区。 +/// 点「重新加载」会递增 reloadToken,即使参数未变也会强制断开重连。 +class _AgoraManualParamsDialog extends StatefulWidget { + final String title; + final String initialAppId; + final String initialChannelId; + final String initialToken; + + const _AgoraManualParamsDialog({ + required this.title, + this.initialAppId = '', + this.initialChannelId = '', + this.initialToken = '', + }); + + @override + State<_AgoraManualParamsDialog> createState() => + _AgoraManualParamsDialogState(); +} + +class _AgoraManualParamsDialogState extends State<_AgoraManualParamsDialog> { + static const Color _primary = Color(0xFF165DFF); + static const Color _danger = Color(0xFFF53F3F); + static const Color _success = Color(0xFF00B42A); + + late final TextEditingController _appIdCtrl = TextEditingController( + text: widget.initialAppId, + ); + late final TextEditingController _channelCtrl = TextEditingController( + text: widget.initialChannelId, + ); + late final TextEditingController _tokenCtrl = TextEditingController( + text: widget.initialToken, + ); + + /// 已生效的参数(点「重新加载」才同步,输入过程中不触发重连) + String _appId = ''; + String _channelId = ''; + String _token = ''; + + /// 递增即强制重连 + int _reloadToken = 0; + + /// 是否已发起过加载(未发起时视频区只放占位文案) + bool _started = false; + String? _hint; + AgoraPlayerState _state = AgoraPlayerState.idle; + + @override + void initState() { + super.initState(); + _appId = widget.initialAppId.trim(); + _channelId = widget.initialChannelId.trim(); + _token = widget.initialToken.trim(); + // 调用方已经把参数给齐时直接开播,不用手点 + _started = _appId.isNotEmpty && _channelId.isNotEmpty; + } + + @override + void dispose() { + _appIdCtrl.dispose(); + _channelCtrl.dispose(); + _tokenCtrl.dispose(); + super.dispose(); + } + + void _reload() { + FocusScope.of(context).unfocus(); + final appId = _appIdCtrl.text.trim(); + final channelId = _channelCtrl.text.trim(); + if (appId.isEmpty || channelId.isEmpty) { + setState(() { + _hint = 'App ID 与 Channel 不能为空'; + _started = false; + }); + return; + } + setState(() { + _appId = appId; + _channelId = channelId; + _token = _tokenCtrl.text.trim(); + _reloadToken++; + _started = true; + _hint = null; + }); + debugPrint('🎥 [Agora] 手动重新加载: channel=$_channelId, seq=$_reloadToken'); + } + + String get _stateText { + switch (_state) { + case AgoraPlayerState.idle: + return '未连接'; + case AgoraPlayerState.connecting: + return '连接中…'; + case AgoraPlayerState.playing: + return '播放中'; + case AgoraPlayerState.error: + return '连接失败'; + } + } + + Color get _stateColor { + switch (_state) { + case AgoraPlayerState.idle: + return Colors.white38; + case AgoraPlayerState.connecting: + return Colors.amberAccent; + case AgoraPlayerState.playing: + return _success; + case AgoraPlayerState.error: + return _danger; + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.black, + elevation: 0, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(), + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInput( + controller: _appIdCtrl, + label: 'App ID', + hint: '声网控制台的应用 ID', + ), + _buildInput( + controller: _channelCtrl, + label: 'Channel', + hint: '频道名 / room_id', + ), + _buildInput( + controller: _tokenCtrl, + label: 'Token', + hint: '未开启鉴权可留空', + maxLines: 2, + ), + if (_hint != null) ...[ + const SizedBox(height: 2), + Text( + _hint!, + style: const TextStyle(color: _danger, fontSize: 12), + ), + ], + const SizedBox(height: 10), + ElevatedButton.icon( + onPressed: _reload, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('重新加载'), + style: ElevatedButton.styleFrom( + backgroundColor: _primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 10), + _buildStatusBar(), + ], + ), + ), + AspectRatio(aspectRatio: 16 / 9, child: _buildVideoArea()), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + height: 48, + padding: const EdgeInsets.only(left: 16), + color: const Color(0xFF2A2E34), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white70, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + + Widget _buildStatusBar() { + return Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: _stateColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + '状态:$_stateText', + style: TextStyle(color: _stateColor, fontSize: 12), + ), + const SizedBox(width: 12), + Flexible( + child: Text( + _started ? 'channel: $_channelId' : '未发起连接', + style: const TextStyle(color: Colors.white24, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + Widget _buildVideoArea() { + if (!_started) { + return Container( + color: Colors.black, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: const Text( + '填写 App ID / Channel 后点「重新加载」', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white38, fontSize: 13), + ), + ); + } + return AgoraVideoPlayer( + appId: _appId, + channelId: _channelId, + token: _token, + reloadToken: _reloadToken, + onStateChanged: (s) { + if (!mounted) return; + setState(() => _state = s); + }, + ); + } + + Widget _buildInput({ + required TextEditingController controller, + required String label, + String? hint, + int maxLines = 1, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: controller, + maxLines: maxLines, + style: const TextStyle(color: Colors.white, fontSize: 13), + cursorColor: _primary, + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: Colors.white54, fontSize: 13), + hintText: hint, + hintStyle: const TextStyle(color: Colors.white24, fontSize: 12), + isDense: true, + filled: true, + fillColor: const Color(0xFF1A1D21), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.white12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.white12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: _primary), + ), + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart b/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart index 461169cf..3df2770a 100644 --- a/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart +++ b/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart @@ -1,12 +1,27 @@ +import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:xml/xml.dart'; /// GeoServer 基地址(公司端) const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver'; +/// GeoServer 工作区名(后端 siteMapName 形如 workSpace:suzhou) +const String kGeoWorkspace = 'workSpace'; + +/// WMS 瓦片网络超时。大图层(如 neimeng/zhongke)一屏十几片、GeoServer 实时渲染排队, +/// 单片实际等待可能远超 30s;浏览器预览肯等所以最终能出图,App 若超时太短会集体放弃、 +/// 返回透明图导致“一直出不来”。故放宽到 90s,并配合下方并发闸门分批取图。 +const Duration kWmsTileTimeout = Duration(seconds: 90); + /// 各场站高清图层的真实范围(取自 GeoServer GetCapabilities,原生 EPSG:4326) /// key = 后端下发的 siteMapName(形如 workSpace:suzhou) final Map kStationLayerBounds = { @@ -43,7 +58,8 @@ final Map kStationLayerBounds = { /// 取某场站图层的范围中心点(用作进场默认定位);未知/为空返回 null LatLng? stationLayerCenter(String? siteMapName) { if (siteMapName == null) return null; - final b = kStationLayerBounds[siteMapName]; + // 优先用动态拉取的范围(StationLayerRepo),其内部已含硬编码兜底 + final b = StationLayerRepo.instance.bounds[siteMapName]; if (b == null) return null; return LatLng((b.south + b.north) / 2, (b.west + b.east) / 2); } @@ -123,7 +139,7 @@ class GeoWmsProvider extends TileProvider { 'STYLES': '', }, ); - return NetworkImage(url.toString()); + return CachedWmsImage(url.toString()); } } @@ -204,3 +220,376 @@ final List kAllStationTileLayers = kStationLayerBounds.entries ), ) .toList(); + +/// 带磁盘缓存 + 超时的 WMS 瓦片图: +/// 命中本地缓存直接解码(秒开);未命中则联网拉取(带超时),成功后写入缓存; +/// 失败/超时返回 1×1 透明图,避免异常刷屏。解决 WMS 实时渲染“每次都慢”的问题。 +class CachedWmsImage extends ImageProvider { + CachedWmsImage(this.url) : _gen = _generation; + + final String url; + + /// 构造时捕获的缓存代次,纳入 key 相等性与磁盘文件名。 + /// 手动刷新时 _generation++,旧瓦片(内存 key + 磁盘文件)立即全部失效, + /// 只影响 WMS 瓦片、不动 ESRI 底图缓存。 + final int _gen; + static int _generation = 0; + + static Future? _cacheDirFuture; + + static Future _cacheDir() { + return _cacheDirFuture ??= () async { + try { + final base = await getTemporaryDirectory(); + final dir = Directory('${base.path}${Platform.pathSeparator}wms_tiles'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } catch (e) { + // 初始化失败不要把“坏 Future”永久缓存下来(否则后续所有瓦片都卡在这一步集体失败、 + // 表现为图层全不出来),置空以便下次调用重试 + _cacheDirFuture = null; + rethrow; + } + }(); + } + + /// URL + 代次 → 稳定短文件名(FNV-1a 32 位 + 长度,降低碰撞;代次前缀用于失效旧缓存) + static String _fileName(String url, int gen) { + var h = 0x811c9dc5; + for (final c in url.codeUnits) { + h ^= c; + h = (h * 0x01000193) & 0xFFFFFFFF; + } + return 'g${gen}_${h.toRadixString(16).padLeft(8, '0')}_${url.length}.png'; + } + + /// 清空全部 WMS 瓦片缓存(磁盘文件 + 递增代次使内存 key 失效)。 + /// 后端更新了同名图层的影像内容后,调用它强制丢弃旧缓存、重新联网拉取最新图。 + static Future clearTileCache() async { + _generation++; + try { + final dir = await _cacheDir(); + if (await dir.exists()) { + await for (final e in dir.list()) { + try { + await e.delete(); + } catch (_) {} + } + } + debugPrint('🧹 [WMS] 已清空瓦片磁盘缓存,代次 → $_generation'); + } catch (e) { + debugPrint('⚠️ [WMS] 清缓存失败: $e'); + } + } + + // ===== WMS 并发闸门 ===== + // 大图层(如 neimeng/zhongke)一屏可达十几片瓦片,若同时发起会把 GeoServer 压到排队渲染, + // 每片等待时间暴涨、集体超时 → 全部返回透明图,表现为“一直出不来”。限制并发分批取, + // 单片能更快拿到渲染资源、在超时内完成(浏览器预览能出图,正是因为它肯等且不这么挤)。 + static const int _maxConcurrentWms = 5; + static int _activeWms = 0; + static final List> _wmsWaiters = []; + + static Future _acquireWmsSlot() async { + if (_activeWms < _maxConcurrentWms) { + _activeWms++; + return; + } + final c = Completer(); + _wmsWaiters.add(c); + await c.future; // 被唤醒即表示 slot 已由 _releaseWmsSlot 直接转交,_activeWms 不变 + } + + static void _releaseWmsSlot() { + if (_wmsWaiters.isNotEmpty) { + _wmsWaiters.removeAt(0).complete(); // 有等待者:slot 直接转交队首,_activeWms 保持 + } else { + _activeWms--; + } + } + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + CachedWmsImage key, + ImageDecoderCallback decode, + ) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(decode), + scale: 1.0, + debugLabel: 'CachedWmsImage', + ); + } + + Future _loadAsync(ImageDecoderCallback decode) async { + final sw = Stopwatch()..start(); + try { + final dir = await _cacheDir(); + final file = + File('${dir.path}${Platform.pathSeparator}${_fileName(url, _gen)}'); + + // 1) 命中磁盘缓存 → 秒开 + if (await file.exists()) { + final cached = await file.readAsBytes(); + if (cached.isNotEmpty) { + return decode(await ui.ImmutableBuffer.fromUint8List(cached)); + } + } + + // 2) 未命中 → 联网拉取。先过并发闸门,避免大图层一次性压垮 GeoServer 导致集体超时 + debugPrint('🌐 [WMS] MISS 联网取瓦片: ${_shortLayerTag(url)}'); + await _acquireWmsSlot(); + try { + final resp = await http.get(Uri.parse(url)).timeout(kWmsTileTimeout); + if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { + try { + await file.writeAsBytes(resp.bodyBytes, flush: false); + } catch (_) {} + debugPrint( + '✅ [WMS] 已缓存(${sw.elapsedMilliseconds}ms, ${resp.bodyBytes.length}B): ${_shortLayerTag(url)}', + ); + return decode(await ui.ImmutableBuffer.fromUint8List(resp.bodyBytes)); + } + debugPrint( + '⚠️ [WMS] 响应异常 status=${resp.statusCode}(${sw.elapsedMilliseconds}ms): ${_shortLayerTag(url)}', + ); + } finally { + _releaseWmsSlot(); + } + } catch (e) { + // 超时 / 网络 / 解码异常 → 透明兜底 + debugPrint('❌ [WMS] 瓦片失败(${sw.elapsedMilliseconds}ms): $e'); + } + return decode( + await ui.ImmutableBuffer.fromUint8List(TileProvider.transparentImage), + ); + } + + /// 从 URL 提取 LAYERS 参数做简短日志标签(避免整条 URL 刷屏) + static String _shortLayerTag(String url) { + final m = RegExp(r'LAYERS=([^&]+)').firstMatch(url); + return m?.group(1) ?? 'unknown'; + } + + @override + bool operator ==(Object other) => + other is CachedWmsImage && other.url == url && other._gen == _gen; + + @override + int get hashCode => Object.hash(url, _gen); +} + +/// 场站图层范围仓库: +/// - 优先从 GeoServer WMS GetCapabilities 动态拉取各图层真实范围 +/// (后台增/改/删图层,App 不发版即可跟上) +/// - 拉取或解析失败时,回退到内置 kStationLayerBounds(离线/异常仍可用) +class StationLayerRepo { + StationLayerRepo._(); + + static final StationLayerRepo instance = StationLayerRepo._(); + + Map _bounds = + Map.of(kStationLayerBounds); + bool _dynamicLoaded = false; + Future? _inflight; + final List _listeners = []; + + /// 当前生效的范围表(动态成功后为最新;否则为内置兜底) + Map get bounds => _bounds; + + /// 范围表是否来自动态拉取 + bool get isDynamic => _dynamicLoaded; + + void addListener(void Function() cb) { + if (!_listeners.contains(cb)) _listeners.add(cb); + } + + void removeListener(void Function() cb) => _listeners.remove(cb); + + void _notify() { + for (final cb in List.of(_listeners)) { + cb(); + } + } + + /// 拉取 GetCapabilities 刷新范围表;并发调用自动合并;失败保持兜底不变 + Future refresh() { + return _inflight ??= _doRefresh().whenComplete(() => _inflight = null); + } + + Future _doRefresh() async { + final sw = Stopwatch()..start(); + try { + // 用工作区专用端点:只返回 workSpace 下图层,XML 小、快、稳(全局端点图层多易超时) + final url = Uri.parse('$kGeoserverBase/$kGeoWorkspace/wms').replace( + queryParameters: { + 'SERVICE': 'WMS', + 'VERSION': '1.1.1', + 'REQUEST': 'GetCapabilities', + }, + ); + debugPrint('🌐 [StationLayerRepo] 拉取 GetCapabilities...'); + final resp = await http.get(url).timeout(const Duration(seconds: 20)); + if (resp.statusCode != 200) { + debugPrint( + '⚠️ [StationLayerRepo] status=${resp.statusCode},保持兜底', + ); + return; + } + final parsed = parseStationLayerBounds(resp.body); + debugPrint( + '📦 [StationLayerRepo] 解析到 ${parsed.length} 个图层(${sw.elapsedMilliseconds}ms): ${parsed.keys.join(', ')}', + ); + if (parsed.isEmpty) { + debugPrint('⚠️ [StationLayerRepo] 解析为空,保持内置兜底'); + return; + } + // 数量防护:动态解析出的图层数若明显少于内置兜底(如网络截断只解析到个别图层), + // 直接整体替换会让其余图层凭空消失。改用“兜底打底 + 动态覆盖同名”的合并策略, + // 既跟上后端最新范围,又保证已有图层不会因一次异常响应而集体消失。 + if (parsed.length < kStationLayerBounds.length) { + debugPrint( + '⚠️ [StationLayerRepo] 动态解析图层数(${parsed.length}) < 兜底(${kStationLayerBounds.length}),采用合并策略防止图层消失', + ); + _bounds = {...kStationLayerBounds, ...parsed}; + } else { + _bounds = parsed; + debugPrint('✅ [StationLayerRepo] 已切换为动态范围'); + } + _dynamicLoaded = true; + _notify(); + } catch (e) { + // 保持兜底,不打断地图 + debugPrint( + '❌ [StationLayerRepo] GetCapabilities 失败(${sw.elapsedMilliseconds}ms),保持兜底: $e', + ); + } + } + + /// 基于当前范围表构建全部场站叠加图层(滑到哪个场站区域就叠加哪个) + List buildLayers({double minZoom = 16, double maxZoom = 21}) { + return _bounds.entries.map((entry) { + // 自愈兜底:万一范围表里仍残留非法 bounds(历史污染等),回退到内置兜底的同名范围, + // 避免生成“框不住任何瓦片”的坏图层导致该场站整体不显示 + final b = _isValidLayerBounds(entry.value) + ? entry.value + : (kStationLayerBounds[entry.key] ?? entry.value); + return TileLayer( + tileProvider: GeoWmsProvider( + layerName: entry.key, + bounds: b, + maxLevel: maxZoom.toInt(), + ), + userAgentPackageName: 'com.example.app', + minZoom: minZoom, + maxZoom: maxZoom, + maxNativeZoom: maxZoom.toInt(), + tileBounds: b, + ); + }).toList(); + } +} + +/// 校验图层范围是否为合法且有意义的经纬度矩形: +/// - 纬度 ∈ [-90,90]、经度 ∈ [-180,180] +/// - 南北/东西跨度 > 0 且 ≤ 5°(场站地块都是几百米级小范围,跨度过大必是投影错误/脏数据) +/// 非法范围一旦混入范围表,会让 getImage 的相交判断恒为 false → 瓦片全透明 → 图层“整体消失”, +/// 这正是“放大也不出来、只能退出重登(页面重建重置图层列表)才恢复”的根因。 +bool _isValidLayerBounds(LatLngBounds b) { + final s = b.south; + final n = b.north; + final w = b.west; + final e = b.east; + if (s < -90 || s > 90 || n < -90 || n > 90) return false; + if (w < -180 || w > 180 || e < -180 || e > 180) return false; + final dLat = n - s; + final dLng = e - w; + if (dLat <= 0 || dLng <= 0) return false; + if (dLat > 5 || dLng > 5) return false; + return true; +} + +/// 解析 WMS GetCapabilities,提取 workSpace 下每个图层的 name → EPSG:4326 范围。 +/// 同时兼容 1.1.1(LatLongBoundingBox)与 1.3.0(EX_GeographicBoundingBox)。 +Map parseStationLayerBounds(String xmlStr) { + final result = {}; + try { + final doc = XmlDocument.parse(xmlStr); + for (final layer in doc.findAllElements('Layer')) { + final nameEls = layer.findElements('Name'); + if (nameEls.isEmpty) continue; + final rawName = nameEls.first.innerText.trim(); + // 兼容两种命名:全局端点返回 workSpace:xxx;工作区端点可能只返回裸名 xxx + final String name; + if (rawName.startsWith('$kGeoWorkspace:')) { + name = rawName; + } else if (!rawName.contains(':')) { + name = '$kGeoWorkspace:$rawName'; + } else { + continue; // 其它工作区图层,跳过 + } + + LatLngBounds? b; + + // WMS 1.1.1 + final bbEls = layer.findElements('LatLongBoundingBox'); + if (bbEls.isNotEmpty) { + final bb = bbEls.first; + final minx = double.tryParse(bb.getAttribute('minx') ?? ''); + final miny = double.tryParse(bb.getAttribute('miny') ?? ''); + final maxx = double.tryParse(bb.getAttribute('maxx') ?? ''); + final maxy = double.tryParse(bb.getAttribute('maxy') ?? ''); + if (minx != null && + miny != null && + maxx != null && + maxy != null && + maxx > minx && + maxy > miny) { + b = LatLngBounds(LatLng(miny, minx), LatLng(maxy, maxx)); + } + } + + // WMS 1.3.0 兜底 + if (b == null) { + final exEls = layer.findElements('EX_GeographicBoundingBox'); + if (exEls.isNotEmpty) { + final ex = exEls.first; + double? read(String tag) { + final els = ex.findElements(tag); + return els.isEmpty + ? null + : double.tryParse(els.first.innerText.trim()); + } + + final w = read('westBoundLongitude'); + final e = read('eastBoundLongitude'); + final n = read('northBoundLatitude'); + final s = read('southBoundLatitude'); + if (w != null && e != null && n != null && s != null && e > w && n > s) { + b = LatLngBounds(LatLng(s, w), LatLng(n, e)); + } + } + } + + if (b != null) { + if (_isValidLayerBounds(b)) { + result[name] = b; + } else { + // 范围非法(经纬度越界 / 跨度过大或为 0)→ 丢弃该图层, + // 避免污染范围表导致瓦片框不住、图层整体消失 + debugPrint( + '⚠️ [StationLayerRepo] 图层 $name 范围非法已丢弃: S=${b.south} N=${b.north} W=${b.west} E=${b.east}', + ); + } + } + } + } catch (_) { + // 解析失败返回空表,调用方保持兜底 + } + return result; +}