diff --git a/lib/data/models/student.dart b/lib/data/models/student.dart deleted file mode 100644 index e659cec..0000000 --- a/lib/data/models/student.dart +++ /dev/null @@ -1,16 +0,0 @@ -///学生视频的模型 -class Student { - final String id; - final String name; - final bool cameraOn; - final bool micOn; - final bool muted; - - Student({ - required this.id, - required this.name, - this.cameraOn = true, - this.micOn = true, - this.muted = true, - }); -} diff --git a/lib/pages/teacher/room/viewmodel/students_view_model.dart b/lib/pages/teacher/room/viewmodel/students_view_model.dart index 2dd3a05..30daec0 100644 --- a/lib/pages/teacher/room/viewmodel/students_view_model.dart +++ b/lib/pages/teacher/room/viewmodel/students_view_model.dart @@ -1,30 +1,33 @@ -import 'package:app/data/models/student.dart'; import 'package:app/request/dto/room/room_user_dto.dart'; +import 'package:app/request/dto/room/rtc_token_dto.dart'; import 'package:app/request/websocket/room_protocol.dart'; import 'package:app/request/websocket/room_websocket.dart'; import 'package:app/utils/time.dart'; import 'package:flutter/cupertino.dart'; +import 'type.dart'; + class StudentsViewModel extends ChangeNotifier { - ///学生摄像头列表 - List _students = []; - - ///房间的基础信息,房间id、房间开始时间 - final int roomId; - late final DateTime startTime; - StudentsViewModel({required this.roomId, String? start}) { startTime = parseTime(start!); _startRoom(); } - List get students => _students; + ///学生摄像头列表 + List _students = []; + + ///房间的基础信息,房间id、房间开始时间 + final int roomId; + late final DateTime startTime; + + ///老师选中的学生id + int activeSId = 0; + + List get students => _students; ///是否能开始自习室 bool get canEnterRoom { final now = DateTime.now(); - - // 如果到了开始时间,则可以进入房间 if (now.isAfter(startTime)) { return true; } @@ -32,11 +35,12 @@ class StudentsViewModel extends ChangeNotifier { } ///websocket管理 - late RoomWebSocket _ws; + final RoomWebSocket _ws = RoomWebSocket(); + + RtcTokenDto? get rtcToken => _ws.rtcToken; ///开始链接房间 void _startRoom() async { - _ws = RoomWebSocket(); //如果socket的token没有,先初始化 if (_ws.wsToken.isEmpty) { await _ws.initToken(roomId); @@ -48,9 +52,21 @@ class StudentsViewModel extends ChangeNotifier { //监听各种ws事件 _ws.stream.listen((msg) { + // 自习室人员变化 if (msg.event == RoomEvent.changeUser) { final list = msg.data['user_list'].map((x) => RoomUserDto.fromJson(x)).toList(); onStudentChange(list); + } else if ([ + RoomEvent.openSpeaker, + RoomEvent.closeSpeaker, + RoomEvent.openMic, + RoomEvent.closeMic, + RoomEvent.openCamera, + RoomEvent.closeCamera, + RoomEvent.handUp, + ].contains(msg.event)) { + onSyncStudentStatus(); + //TODO 直接同步服务器最新的数组覆盖,或者覆盖这一条学生的 } }); notifyListeners(); @@ -66,8 +82,69 @@ class StudentsViewModel extends ChangeNotifier { } } + ///学生选择 + void selectStudent(int id) { + activeSId = id; + notifyListeners(); + } + + ///手动关闭学生扬声器、摄像头、麦克风等操作 + /// - [uId]: 学生id + /// - [action]: 操作类型 + void closeStudentSpeaker({ + required int uId, + required StudentAction action, + }) { + final student = _students.firstWhere((t) => t.userId == uId); + + Map data = { + 'target_user_id': uId, + }; + //如果是控制扬声器 + if (action == StudentAction.speaker) { + student.speekerStatus = student.speekerStatus == 0 ? 1 : 0; + data['speeker'] = student.speekerStatus; + } else if (action == StudentAction.camera) { + //如果是摄像头,只能关 + if (student.cameraStatus == 0) return; + student.cameraStatus = 0; + data['camera'] = 0; + } else if (action == StudentAction.microphone) { + //如果是麦克风,只能关 + if (student.microphoneStatus == 0) return; + student.microphoneStatus = 0; + data['microphone'] = 0; + } + notifyListeners(); + _ws.send(RoomCommand.switchStudentCamera, data); + } + + //清除全部学生举手,或者是指定 + void clearHandUp(int? id) { + Map data = {}; + if (id == null) { + data['target_user_id'] = "all"; + _students.forEach((t) => t.handup = 0); + } else { + data['target_user_id'] = id; + _students.firstWhere((t) => t.userId == id).handup = 0; + } + notifyListeners(); + _ws.send(RoomCommand.clearHandUp, data); + } + ///学生人员变化事件,(如加入、退出、掉线) - void onStudentChange(List list) {} + void onStudentChange(List list) { + _students = list.where((t) => t.userType != 2).toList(); + // 如果当前没有学生,则选择第一个 + if (activeSId == 0 && _students.isNotEmpty) { + activeSId = _students.first.userId; + } + notifyListeners(); + } + + //TODO 同步学生的最新状态 + void onSyncStudentStatus() {} //销毁 @override diff --git a/lib/pages/teacher/room/viewmodel/type.dart b/lib/pages/teacher/room/viewmodel/type.dart new file mode 100644 index 0000000..7e9cccc --- /dev/null +++ b/lib/pages/teacher/room/viewmodel/type.dart @@ -0,0 +1,11 @@ +///老师操作学生的状态、摄像头、扬声器、麦克风 +enum StudentAction { + ///摄像头 + camera, + + ///麦克风 + microphone, + + ///扬声器 + speaker, +} diff --git a/lib/pages/teacher/room/widgets/content_view.dart b/lib/pages/teacher/room/widgets/content_view.dart index aefca41..8d57342 100644 --- a/lib/pages/teacher/room/widgets/content_view.dart +++ b/lib/pages/teacher/room/widgets/content_view.dart @@ -1,35 +1,123 @@ +import 'package:agora_rtc_engine/agora_rtc_engine.dart'; +import 'package:app/config/config.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../viewmodel/students_view_model.dart'; import 'student_item.dart'; -class ContentView extends StatelessWidget { +class ContentView extends StatefulWidget { const ContentView({super.key}); @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(10), - child: Row( - spacing: 15, - children: [ - Expanded( - child: StudentItem(), - ), - SizedBox( - width: 300, - child: ListView.separated( - itemBuilder: (_, index) { - return SizedBox( - height: 250, - child: StudentItem(), - ); - }, - separatorBuilder: (_, __) => SizedBox(height: 15), - itemCount: 7, - ), - ), - ], + State createState() => _ContentViewState(); +} + +class _ContentViewState extends State { + // bool isLoading = true; + + //声网数据 + RtcEngine? _engine; + + void _initRtc() async { + final vm = context.read(); + _engine = createAgoraRtcEngine(); + //初始化 RtcEngine,设置频道场景为 channelProfileLiveBroadcasting(直播场景) + await _engine!.initialize( + RtcEngineContext( + appId: Config.swAppId, + channelProfile: ChannelProfileType.channelProfileLiveBroadcasting, + ), + ); + //添加回调 + _engine!.registerEventHandler( + RtcEngineEventHandler( + // 成功加入频道回调 + onJoinChannelSuccess: (RtcConnection connection, int elapsed) { + setState(() {}); + }, + // 远端用户或主播加入当前频道回调 + onUserJoined: (RtcConnection connection, int remoteUid, int elapsed) {}, + // 远端用户或主播离开当前频道回调 + onUserOffline: (RtcConnection connection, int remoteUid, UserOfflineReasonType reason) {}, + ), + ); + //启动视频模块 + await _engine!.enableVideo(); + //加入频道 + await _engine!.joinChannel( + token: vm.rtcToken!.token, + channelId: vm.rtcToken!.channel, + uid: int.parse(vm.rtcToken!.uid), + options: ChannelMediaOptions( + // 自动订阅所有视频流 + autoSubscribeVideo: true, + // 自动订阅所有音频流 + autoSubscribeAudio: true, + // 发布摄像头采集的视频 + publishCameraTrack: true, + // 发布麦克风采集的音频 + publishMicrophoneTrack: true, + // 设置用户角色为 clientRoleBroadcaster(主播)或 clientRoleAudience(观众) + clientRoleType: ClientRoleType.clientRoleBroadcaster, ), ); } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final vm = context.read(); + if (_engine == null && vm.students.isNotEmpty) { + _initRtc(); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, vm, _) { + if (vm.students.isEmpty) { + return Center( + child: Text('无学生在场,请通知学生入场'), + ); + } + //选中的学生 + final activeStudent = vm.students.firstWhere((t) => t.userId == vm.activeSId); + //其他学生 + final otherStudents = vm.students.where((t) => t.userId != vm.activeSId).toList() + ..sort((a, b) => b.handup.compareTo(a.handup)); + + return Padding( + padding: const EdgeInsets.all(10), + child: Row( + spacing: 15, + children: [ + Expanded( + child: StudentItem( + user: activeStudent, + ), + ), + SizedBox( + width: 300, + child: ListView.separated( + itemBuilder: (_, index) { + var item = otherStudents.elementAt(index); + return SizedBox( + height: 250, + child: StudentItem( + user: item, + ), + ); + }, + separatorBuilder: (_, __) => SizedBox(height: 15), + itemCount: otherStudents.length, + ), + ), + ], + ), + ); + }, + ); + } } diff --git a/lib/pages/teacher/room/widgets/student_item.dart b/lib/pages/teacher/room/widgets/student_item.dart index dea852d..e8bf1b2 100644 --- a/lib/pages/teacher/room/widgets/student_item.dart +++ b/lib/pages/teacher/room/widgets/student_item.dart @@ -1,10 +1,19 @@ +import 'package:app/request/dto/room/room_user_dto.dart'; import 'package:app/widgets/room/file_drawer.dart'; import 'package:app/widgets/room/video_surface.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; +import '../viewmodel/students_view_model.dart'; + class StudentItem extends StatefulWidget { - const StudentItem({super.key}); + final RoomUserDto user; + + const StudentItem({ + super.key, + required this.user, + }); @override State createState() => _StudentItemState(); @@ -12,11 +21,18 @@ class StudentItem extends StatefulWidget { class _StudentItemState extends State { ///打开文件列表 - void _openFileList(){ - showFileDialog(context,isUpload: false); + void _openFileList() { + showFileDialog(context, isUpload: false); } + @override Widget build(BuildContext context) { + final vm = context.read(); + //摄像头是否开启 + bool isCameraOpen = widget.user.cameraStatus == 1; + + ///麦克风是否开启 + bool isMicOpen = widget.user.microphoneStatus == 1; return ClipRRect( borderRadius: BorderRadius.circular(10), child: Container( @@ -28,8 +44,7 @@ class _StudentItemState extends State { width: double.infinity, child: Stack( children: [ - VideoSurface( - ), + // VideoSurface(), Positioned( bottom: 0, left: 0, @@ -44,14 +59,31 @@ class _StudentItemState extends State { ), ), child: Text( - "李明辉", - style: TextStyle( - color: Colors.white, - fontSize: 14, - ), + widget.user.userName, + style: TextStyle(color: Colors.white, fontSize: 14), ), ), ), + if (widget.user.userId != vm.activeSId) + Positioned( + right: 5, + top: 5, + child: InkWell( + onTap: () { + vm.selectStudent(widget.user.userId); + }, + child: Container( + width: 25, + height: 25, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black12, + borderRadius: BorderRadius.circular(5), + ), + child: Icon(RemixIcons.fullscreen_line, color: Colors.white), + ), + ), + ), ], ), ), @@ -60,17 +92,18 @@ class _StudentItemState extends State { color: Color(0xFF232426), child: Row( children: [ - _actionItem(icon: RemixIcons.video_on_fill, isActive: false), _actionItem( - icon: RemixIcons.mic_off_fill, + icon: isCameraOpen ? RemixIcons.video_on_fill : RemixIcons.video_off_fill, + isActive: isCameraOpen, ), _actionItem( - icon: RemixIcons.volume_mute_fill, - ), - _actionItem( - icon: RemixIcons.file_list_3_fill, - onTap: _openFileList + icon: isMicOpen ? RemixIcons.mic_fill : RemixIcons.mic_off_fill, + isActive: isMicOpen, ), + // _actionItem( + // icon: RemixIcons.volume_mute_fill, + // ), + _actionItem(icon: RemixIcons.file_list_3_fill, onTap: _openFileList), ], ), ), diff --git a/lib/request/dto/room/room_user_dto.dart b/lib/request/dto/room/room_user_dto.dart index db221b1..983f1e0 100644 --- a/lib/request/dto/room/room_user_dto.dart +++ b/lib/request/dto/room/room_user_dto.dart @@ -1,19 +1,20 @@ class RoomUserDto { final int userId; final String rtcUid; - final int microphoneStatus; - final int cameraStatus; - final int speekerStatus; + int microphoneStatus; + int cameraStatus; + int speekerStatus; final String wsClientId; final String userName; final String avatar; + /// 1是学生,2是老师 final int userType; final List filesList; final String dataType; - final int handup; - final int online; //0离线,1在线 + int handup; + int online; //0离线,1在线 - const RoomUserDto({ + RoomUserDto({ required this.userId, required this.rtcUid, required this.microphoneStatus, diff --git a/lib/widgets/room/video_surface.dart b/lib/widgets/room/video_surface.dart index 7631da1..d492203 100644 --- a/lib/widgets/room/video_surface.dart +++ b/lib/widgets/room/video_surface.dart @@ -1,4 +1,7 @@ +import 'package:app/config/config.dart'; import 'package:flutter/material.dart'; +import 'package:agora_rtc_engine/agora_rtc_engine.dart'; +import '../../request/dto/room/rtc_token_dto.dart'; /// 视频画面显示状态 enum VideoState { @@ -18,26 +21,60 @@ enum VideoState { error, } -class VideoSurface extends StatelessWidget { - final VideoState state; +// class VideoSurface extends StatelessWidget { +// final VideoState state; +// +// const VideoSurface({super.key, this.state = VideoState.normal}); +// +// @override +// Widget build(BuildContext context) { +// String stateText = switch (state) { +// VideoState.closed => "摄像头已关闭", +// VideoState.offline => "掉线", +// VideoState.loading => "加载中", +// VideoState.error => "错误", +// _ => "未知", +// }; +// //如果不是正常 +// if (state != VideoState.normal) { +// return Align( +// child: Text(stateText, style: TextStyle(color: Colors.white70)), +// ); +// } +// return Container(); +// } +// } - const VideoSurface({super.key, this.state = VideoState.normal}); +class VideoSurface extends StatefulWidget { + final RtcTokenDto rtcToken; + final String remoteUid; + + const VideoSurface({ + super.key, + required this.rtcToken, + required this.remoteUid, + }); + + @override + State createState() => _VideoSurfaceState(); +} + +class _VideoSurfaceState extends State { + RtcEngine? _engine; + + void _init() async { + _engine = createAgoraRtcEngine(); + //初始化 RtcEngine,设置频道场景为 channelProfileLiveBroadcasting(直播场景) + await _engine!.initialize( + RtcEngineContext( + appId: Config.swAppId, + channelProfile: ChannelProfileType.channelProfileLiveBroadcasting, + ), + ); + } @override Widget build(BuildContext context) { - String stateText = switch (state) { - VideoState.closed => "摄像头已关闭", - VideoState.offline => "掉线", - VideoState.loading => "加载中", - VideoState.error => "错误", - _ => "未知", - }; - //如果不是正常 - if (state != VideoState.normal) { - return Align( - child: Text(stateText, style: TextStyle(color: Colors.white70)), - ); - } - return Container(); + return const Placeholder(); } }