948 lines
29 KiB
Dart
948 lines
29 KiB
Dart
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<AgoraPlayerState>? onStateChanged;
|
||
|
||
/// 首帧解码成功回调,参数为远端 uid
|
||
final ValueChanged<int>? onFirstFrame;
|
||
|
||
/// 错误回调
|
||
final ValueChanged<String>? 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<AgoraPlayerState>? 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<String, String> parseParams(String url) {
|
||
final params = <String, String>{};
|
||
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<void> 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<AgoraPlayerState>? onStateChanged,
|
||
}) {
|
||
return showDialog<void>(
|
||
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<void> 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<AgoraPlayerState>? onStateChanged,
|
||
}) {
|
||
return showModalBottomSheet<void>(
|
||
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<void> showManualInput(
|
||
BuildContext context, {
|
||
String title = '实时视频',
|
||
String initialAppId = '',
|
||
String initialChannelId = '',
|
||
String initialToken = '',
|
||
bool barrierDismissible = true,
|
||
}) {
|
||
return showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: barrierDismissible,
|
||
barrierColor: Colors.black87,
|
||
builder: (_) => _AgoraManualParamsDialog(
|
||
title: title,
|
||
initialAppId: initialAppId,
|
||
initialChannelId: initialChannelId,
|
||
initialToken: initialToken,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
State<AgoraVideoPlayer> createState() => _AgoraVideoPlayerState();
|
||
}
|
||
|
||
class _AgoraVideoPlayerState extends State<AgoraVideoPlayer> {
|
||
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<void> _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<void> _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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|