老师端几乎ok

This commit is contained in:
zhutao
2025-11-20 23:03:49 +08:00
parent b7239292d1
commit 9c94ee31fd
7 changed files with 325 additions and 94 deletions

View File

@@ -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,
});
}

View File

@@ -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<Student> _students = [];
///房间的基础信息房间id、房间开始时间
final int roomId;
late final DateTime startTime;
StudentsViewModel({required this.roomId, String? start}) {
startTime = parseTime(start!);
_startRoom();
}
List<Student> get students => _students;
///学生摄像头列表
List<RoomUserDto> _students = [];
///房间的基础信息房间id、房间开始时间
final int roomId;
late final DateTime startTime;
///老师选中的学生id
int activeSId = 0;
List<RoomUserDto> 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<String, int> 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<String, dynamic> 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<RoomUserDto> list) {}
void onStudentChange(List<RoomUserDto> list) {
_students = list.where((t) => t.userType != 2).toList();
// 如果当前没有学生,则选择第一个
if (activeSId == 0 && _students.isNotEmpty) {
activeSId = _students.first.userId;
}
notifyListeners();
}
//TODO 同步学生的最新状态
void onSyncStudentStatus() {}
//销毁
@override

View File

@@ -0,0 +1,11 @@
///老师操作学生的状态、摄像头、扬声器、麦克风
enum StudentAction {
///摄像头
camera,
///麦克风
microphone,
///扬声器
speaker,
}

View File

@@ -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<ContentView> createState() => _ContentViewState();
}
class _ContentViewState extends State<ContentView> {
// bool isLoading = true;
//声网数据
RtcEngine? _engine;
void _initRtc() async {
final vm = context.read<StudentsViewModel>();
_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<StudentsViewModel>();
if (_engine == null && vm.students.isNotEmpty) {
_initRtc();
}
}
@override
Widget build(BuildContext context) {
return Consumer<StudentsViewModel>(
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,
),
),
],
),
);
},
);
}
}

View File

@@ -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<StudentItem> createState() => _StudentItemState();
@@ -12,11 +21,18 @@ class StudentItem extends StatefulWidget {
class _StudentItemState extends State<StudentItem> {
///打开文件列表
void _openFileList(){
showFileDialog(context,isUpload: false);
void _openFileList() {
showFileDialog(context, isUpload: false);
}
@override
Widget build(BuildContext context) {
final vm = context.read<StudentsViewModel>();
//摄像头是否开启
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<StudentItem> {
width: double.infinity,
child: Stack(
children: [
VideoSurface(
),
// VideoSurface(),
Positioned(
bottom: 0,
left: 0,
@@ -44,14 +59,31 @@ class _StudentItemState extends State<StudentItem> {
),
),
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<StudentItem> {
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),
],
),
),

View File

@@ -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<String> 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,

View File

@@ -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<VideoSurface> createState() => _VideoSurfaceState();
}
class _VideoSurfaceState extends State<VideoSurface> {
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();
}
}