一、引言
随着互联网技术的发展,直播已成为一种主流的内容传播形式。
其中,连麦功能作为直播互动的重要手段,能够有效提升用户参与感和观看体验。
本文将介绍如何使用SpringBoot和WebSocket技术构建一个直播连麦系统,实现主播与观众之间的实时音视频交流和文字聊天功能。
为了方便DEMO的运行,本系统基于纯内存操作实现核心业务逻辑,不依赖外部数据库或者缓存组件。
二、技术设计
2.1 技术栈
- 后端:SpringBoot 3.4.5、WebSocket、STOMP子协议、JWT
- 前端: HTML5、CSS3、JavaScript、WebRTC、SockJS
2.2 整体架构
- 后端:基于SpringBoot实现,负责用户认证、直播间管理、WebSocket消息处理等
- 前端:负责用户界面交互、WebRTC音视频传输、WebSocket连接管理等
- 通信协议:WebSocket + STOMP实现实时消息传递,WebRTC实现P2P音视频传输
2.3 核心功能模块
- 用户认证模块:处理用户登录和简单认证
- 直播间管理模块:创建、查询和管理直播间
- WebSocket通信模块:处理实时消息传递
- 连麦管理模块:处理连麦请求、状态管理和信令交换
- 聊天消息模块:处理直播间内的文字聊天
2.3 数据流程
- 用户通过HTTP接口登录获取Token
- 主播创建直播间,系统分配直播间ID
- 观众通过直播间ID加入直播间
- 观众发送连麦请求,主播确认后建立WebRTC连接
- 所有用户通过WebSocket发送和接收聊天消息
三、后端实现
3.1 项目基础配置
Maven依赖
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
应用配置
server:
port: 8080
spring:
application:
name: livestream-system
jwt:
secret: livestreamSecretKey123456789012345678901234567890
expiration: 86400000
3.2 WebSocket配置
package com.example.livestream.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private WebSocketAuthInterceptor webSocketAuthInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
/*@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 使用/topic和/queue前缀的目的地会路由到消息代理
registry.enableSimpleBroker("/topic", "/queue", "/user");
// 用户目标前缀
registry.setUserDestinationPrefix("/user");
// 应用前缀
registry.setApplicationDestinationPrefixes("/app");
}*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 使用/topic和/queue前缀的目的地会路由到消息代理
registry.enableSimpleBroker("/topic", "/queue");
// 用户目标前缀
registry.setUserDestinationPrefix("/user");
// 应用前缀
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketAuthInterceptor);
}
}
3.3 WebSocket认证拦截器
package com.example.livestream.config;
import com.example.livestream.service.UserService;
import com.example.livestream.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class WebSocketAuthInterceptor implements ChannelInterceptor {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserService userService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
try {
String username = jwtTokenUtil.getUsernameFromToken(token);
if (username != null && jwtTokenUtil.validateToken(token)) {
accessor.setUser(() -> username);
log.info("User authenticated: {}", username);
}
} catch (Exception e) {
log.error("Invalid JWT token: {}", e.getMessage());
}
}
}
return message;
}
}
3.4 JWT工具类
package com.example.livestream.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
private Key getSigningKey() {
byte[] keyBytes = secret.getBytes();
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username);
}
private String createToken(Map<String, Object> claims, String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
public String getUsername(){
// 获取HttpServletRequest对象
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
return (String)request.getAttribute("username");
}
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
private Claims getAllClaimsFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
3.5 模型类设计
用户模型
package com.example.livestream.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private String username;
private String password;
private String nickname;
private String avatar;
private UserRole role; // BROADCASTER or AUDIENCE
public enum UserRole {
BROADCASTER,
AUDIENCE
}
}
直播间模型
package com.example.livestream.model;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
@Data
public class LiveRoom {
private String roomId;
private String title;
private String broadcaster;
private LocalDateTime createdTime;
private boolean active;
// 连麦用户列表
private Set<String> activeMicUsers = new CopyOnWriteArraySet<>();
// 观众列表
private Set<String> audiences = new CopyOnWriteArraySet<>();
// 连麦申请列表
private Map<String, MicRequest> micRequests = new ConcurrentHashMap<>();
@Data
public static class MicRequest {
private String username;
private LocalDateTime requestTime;
private MicRequestStatus status;
public enum MicRequestStatus {
PENDING,
ACCEPTED,
REJECTED
}
}
}
消息模型
package com.example.livestream.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
private String roomId;
private String sender;
private String content;
private LocalDateTime timestamp;
private MessageType type;
public enum MessageType {
CHAT,
JOIN,
LEAVE,
MIC_REQUEST,
MIC_RESPONSE,
SIGNAL
}
}
3.6 服务层实现
用户服务
package com.example.livestream.service;
import com.example.livestream.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@Slf4j
public class UserService {
// 内存中存储用户信息
private final Map<String, User> users = new ConcurrentHashMap<>();
public UserService() {
// 初始化一些测试用户
users.put("zb1", new User("zb1", "zb1", "主播一号",
"avatar1.jpg", User.UserRole.BROADCASTER));
users.put("zb2", new User("zb2", "zb2", "主播二号",
"avatar2.jpg", User.UserRole.BROADCASTER));
users.put("gz1", new User("gz1", "gz1", "观众一号",
"user1.jpg", User.UserRole.AUDIENCE));
users.put("gz2", new User("gz2", "gz2", "观众二号",
"user2.jpg", User.UserRole.AUDIENCE));
}
public User getUserByUsername(String username) {
return users.get(username);
}
public boolean validateUser(String username, String password) {
User user = users.get(username);
return user != null && user.getPassword().equals(password);
}
public User registerUser(User user) {
if (users.containsKey(user.getUsername())) {
return null;
}
users.put(user.getUsername(), user);
return user;
}
}
直播间服务
package com.example.livestream.service;
import com.example.livestream.model.LiveRoom;
import com.example.livestream.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
@Slf4j
public class LiveRoomService {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private UserService userService;
// 内存中存储直播间信息
private final Map<String, LiveRoom> liveRooms = new ConcurrentHashMap<>();
public LiveRoom createLiveRoom(String title, String username) {
User user = userService.getUserByUsername(username);
if (user == null || user.getRole() != User.UserRole.BROADCASTER) {
return null;
}
String roomId = UUID.randomUUID().toString().substring(0, 8);
LiveRoom liveRoom = new LiveRoom();
liveRoom.setRoomId(roomId);
liveRoom.setTitle(title);
liveRoom.setBroadcaster(username);
liveRoom.setCreatedTime(LocalDateTime.now());
liveRoom.setActive(true);
liveRooms.put(roomId, liveRoom);
log.info("Live room created: {} by {}", roomId, username);
return liveRoom;
}
public LiveRoom getLiveRoom(String roomId) {
return liveRooms.get(roomId);
}
public List<LiveRoom> getAllActiveLiveRooms() {
List<LiveRoom> activeRooms = new ArrayList<>();
for (LiveRoom room : liveRooms.values()) {
if (room.isActive()) {
activeRooms.add(room);
}
}
return activeRooms;
}
public boolean joinLiveRoom(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive()) {
return false;
}
room.getAudiences().add(username);
log.info("User {} joined room {}", username, roomId);
return true;
}
public boolean leaveLiveRoom(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null) {
return false;
}
room.getAudiences().remove(username);
room.getActiveMicUsers().remove(username);
room.getMicRequests().remove(username);
log.info("User {} left room {}", username, roomId);
return true;
}
public boolean closeLiveRoom(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.getBroadcaster().equals(username)) {
return false;
}
room.setActive(false);
log.info("Room {} closed by {}", roomId, username);
return true;
}
public boolean requestMic(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive() || !room.getAudiences().contains(username)) {
return false;
}
LiveRoom.MicRequest request = new LiveRoom.MicRequest();
request.setUsername(username);
request.setRequestTime(LocalDateTime.now());
request.setStatus(LiveRoom.MicRequest.MicRequestStatus.PENDING);
room.getMicRequests().put(username, request);
// 通知主播有新的连麦请求
messagingTemplate.convertAndSendToUser(
room.getBroadcaster(),
"/queue/mic-requests",
request);
log.info("Mic request from {} in room {}", username, roomId);
return true;
}
public boolean handleMicRequest(String roomId, String requestUsername,
boolean accept, String broadcasterUsername) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive() ||
!room.getBroadcaster().equals(broadcasterUsername)) {
return false;
}
LiveRoom.MicRequest request = room.getMicRequests().get(requestUsername);
if (request == null) {
return false;
}
if (accept) {
request.setStatus(LiveRoom.MicRequest.MicRequestStatus.ACCEPTED);
room.getActiveMicUsers().add(requestUsername);
} else {
request.setStatus(LiveRoom.MicRequest.MicRequestStatus.REJECTED);
}
// 通知申请者结果
messagingTemplate.convertAndSendToUser(
requestUsername,
"/queue/mic-response",
request);
log.info("Mic request from {} was {} by broadcaster in room {}",
requestUsername, accept ? "accepted" : "rejected", roomId);
return true;
}
public boolean endMic(String roomId, String username, String initiator) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive()) {
return false;
}
// 只有主播或用户自己可以结束连麦
if (!room.getBroadcaster().equals(initiator) && !username.equals(initiator)) {
return false;
}
room.getActiveMicUsers().remove(username);
room.getMicRequests().remove(username);
// 通知相关用户连麦已结束
messagingTemplate.convertAndSendToUser(
username,
"/queue/mic-ended",
roomId);
if (!username.equals(room.getBroadcaster())) {
messagingTemplate.convertAndSendToUser(
room.getBroadcaster(),
"/queue/mic-ended",
username);
}
log.info("Mic ended for {} in room {}, initiated by {}",
username, roomId, initiator);
return true;
}
}
聊天服务
package com.example.livestream.service;
import com.example.livestream.model.ChatMessage;
import com.example.livestream.model.LiveRoom;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@Slf4j
public class ChatService {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private LiveRoomService liveRoomService;
// 内存中存储每个直播间的最近消息
private final Map<String, List<ChatMessage>> roomMessages = new ConcurrentHashMap<>();
private final int MAX_MESSAGES_PER_ROOM = 100;
public void sendMessage(ChatMessage message) {
LiveRoom room = liveRoomService.getLiveRoom(message.getRoomId());
if (room == null || !room.isActive()) {
return;
}
message.setTimestamp(LocalDateTime.now());
// 将消息广播到直播间
messagingTemplate.convertAndSend("/topic/room/" + message.getRoomId(), message);
// 存储消息
roomMessages.computeIfAbsent(message.getRoomId(), k -> new ArrayList<>()).add(message);
// 限制消息数量
List<ChatMessage> messages = roomMessages.get(message.getRoomId());
if (messages.size() > MAX_MESSAGES_PER_ROOM) {
messages.remove(0);
}
log.info("Message sent in room {}: {}", message.getRoomId(), message.getContent());
}
public List<ChatMessage> getRecentMessages(String roomId) {
return roomMessages.getOrDefault(roomId, new ArrayList<>());
}
public void sendJoinMessage(String roomId, String username) {
ChatMessage message = new ChatMessage();
message.setRoomId(roomId);
message.setSender("System");
message.setContent(username + " 加入了直播间");
message.setTimestamp(LocalDateTime.now());
message.setType(ChatMessage.MessageType.JOIN);
messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}
public void sendLeaveMessage(String roomId, String username) {
ChatMessage message = new ChatMessage();
message.setRoomId(roomId);
message.setSender("System");
message.setContent(username + " 离开了直播间");
message.setTimestamp(LocalDateTime.now());
message.setType(ChatMessage.MessageType.LEAVE);
messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}
public void sendSignalingMessage(String roomId, String sender, String receiver, String content) {
ChatMessage message = new ChatMessage();
message.setRoomId(roomId);
message.setSender(sender);
message.setContent(content);
message.setTimestamp(LocalDateTime.now());
message.setType(ChatMessage.MessageType.SIGNAL);
// 发送给特定用户
messagingTemplate.convertAndSendToUser(
receiver,
"/queue/signal",
message);
log.info("Signal sent from {} to {} in room {}", sender, receiver, roomId);
}
}
3.7 控制器实现
认证控制器
package com.example.livestream.controller;
import com.example.livestream.model.User;
import com.example.livestream.service.UserService;
import com.example.livestream.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {
@Autowired
private UserService userService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> loginRequest) {
String username = loginRequest.get("username");
String password = loginRequest.get("password");
if (userService.validateUser(username, password)) {
User user = userService.getUserByUsername(username);
String token = jwtTokenUtil.generateToken(username);
Map<String, Object> response = new HashMap<>();
response.put("token", token);
response.put("username", username);
response.put("nickname", user.getNickname());
response.put("role", user.getRole());
response.put("avatar", user.getAvatar());
log.info("User logged in: {}", username);
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body("Invalid username or password");
}
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody User user) {
User registeredUser = userService.registerUser(user);
if (registeredUser != null) {
log.info("User registered: {}", user.getUsername());
return ResponseEntity.ok("User registered successfully");
} else {
return ResponseEntity.badRequest().body("Username already exists");
}
}
}
直播间控制器
package com.example.livestream.controller;
import com.example.livestream.model.LiveRoom;
import com.example.livestream.service.LiveRoomService;
import com.example.livestream.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/rooms")
@Slf4j
public class LiveRoomController {
@Autowired
private LiveRoomService liveRoomService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@PostMapping
public ResponseEntity<?> createRoom(@RequestBody Map<String, String> roomRequest) {
String title = roomRequest.get("title");
LiveRoom room = liveRoomService.createLiveRoom(title, jwtTokenUtil.getUsername());
if (room != null) {
return ResponseEntity.ok(room);
} else {
return ResponseEntity.badRequest().body("Failed to create room");
}
}
@PostMapping("/{roomId}/mic-response")
public ResponseEntity<?> handleMicResponse(
@PathVariable String roomId,
@RequestParam String username,
@RequestParam boolean accept) {
boolean result = liveRoomService.handleMicRequest(roomId, username, accept, jwtTokenUtil.getUsername());
if (result) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to handle mic request");
}
}
@GetMapping
public ResponseEntity<List<LiveRoom>> getAllRooms() {
return ResponseEntity.ok(liveRoomService.getAllActiveLiveRooms());
}
@GetMapping("/{roomId}")
public ResponseEntity<?> getRoomInfo(@PathVariable String roomId) {
LiveRoom room = liveRoomService.getLiveRoom(roomId);
if (room != null) {
return ResponseEntity.ok(room);
} else {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{roomId}/join")
public ResponseEntity<?> joinRoom(@PathVariable String roomId) {
boolean joined = liveRoomService.joinLiveRoom(roomId, jwtTokenUtil.getUsername());
if (joined) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to join room");
}
}
@PostMapping("/{roomId}/leave")
public ResponseEntity<?> leaveRoom(@PathVariable String roomId) {
boolean left = liveRoomService.leaveLiveRoom(roomId, jwtTokenUtil.getUsername());
if (left) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to leave room");
}
}
@PostMapping("/{roomId}/close")
public ResponseEntity<?> closeRoom(@PathVariable String roomId) {
boolean closed = liveRoomService.closeLiveRoom(roomId, jwtTokenUtil.getUsername());
if (closed) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to close room");
}
}
@GetMapping("/{roomId}/mic-status")
public ResponseEntity<?> getMicStatus(
@PathVariable String roomId,
@RequestParam String username) {
if (!jwtTokenUtil.getUsername().equals(username)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Unauthorized");
}
LiveRoom room = liveRoomService.getLiveRoom(roomId);
if (room == null) {
return ResponseEntity.notFound().build();
}
LiveRoom.MicRequest request = room.getMicRequests().get(username);
Map<String, Object> result = new HashMap<>();
if (request == null) {
// 没有找到请求,可能是已经被处理并移除
if (room.getActiveMicUsers().contains(username)) {
result.put("status", "ACCEPTED");
} else {
result.put("status", "NOT_FOUND");
}
} else {
result.put("status", request.getStatus().toString());
}
return ResponseEntity.ok(result);
}
}
WebSocket消息控制器
package com.example.livestream.controller;
import com.example.livestream.model.ChatMessage;
import com.example.livestream.model.LiveRoom;
import com.example.livestream.service.ChatService;
import com.example.livestream.service.LiveRoomService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;
import java.security.Principal;
import java.util.List;
import java.util.Map;
@Controller
@Slf4j
public class WebSocketController {
@Autowired
private ChatService chatService;
@Autowired
private LiveRoomService liveRoomService;
@Autowired
private SimpMessagingTemplate messagingTemplate;
@MessageMapping("/heartbeat")
public void processHeartbeat(String message, Principal principal) {
if (principal != null) {
log.debug("Received heartbeat from {}: {}", principal.getName(), message);
}
}
@MessageMapping("/chat.send/{roomId}")
public void sendMessage(@DestinationVariable String roomId,
@Payload ChatMessage chatMessage,
Principal principal) {
if (principal == null) return;
chatMessage.setRoomId(roomId);
chatMessage.setSender(principal.getName());
chatMessage.setType(ChatMessage.MessageType.CHAT);
chatService.sendMessage(chatMessage);
}
@SubscribeMapping("/room/{roomId}")
public List<ChatMessage> subscribeRoom(@DestinationVariable String roomId,
Principal principal,
SimpMessageHeaderAccessor headerAccessor) {
if (principal == null) return null;
String username = principal.getName();
liveRoomService.joinLiveRoom(roomId, username);
chatService.sendJoinMessage(roomId, username);
return chatService.getRecentMessages(roomId);
}
@MessageMapping("/mic.request/{roomId}")
public void requestMic(@DestinationVariable String roomId, Principal principal, SimpMessageHeaderAccessor headerAccessor) {
if (principal == null) {
log.error("Cannot request mic: user not authenticated");
return;
}
String username = principal.getName();
log.info("Received mic request from {} for room {}", username, roomId);
// 输出当前会话的所有信息,帮助调试
log.info("Session ID: {}", headerAccessor.getSessionId());
log.info("User: {}", headerAccessor.getUser());
log.info("Headers: {}", headerAccessor.toMap());
boolean success = liveRoomService.requestMic(roomId, username);
log.info("Mic request processing result: {}", success ? "SUCCESS" : "FAILED");
// 如果处理失败,直接通知请求者
if (!success) {
messagingTemplate.convertAndSendToUser(
username,
"/queue/mic-response",
"Mic request failed");
}
}
@MessageMapping("/mic.response/{roomId}")
public void handleMicRequest(@DestinationVariable String roomId,
@Payload Map<String, Object> payload,
Principal principal) {
if (principal == null) return;
String requestUsername = (String) payload.get("username");
boolean accept = (boolean) payload.get("accept");
liveRoomService.handleMicRequest(roomId, requestUsername, accept, principal.getName());
}
@MessageMapping("/mic.end/{roomId}")
public void endMic(@DestinationVariable String roomId,
@Payload String username,
Principal principal) {
if (principal == null) return;
liveRoomService.endMic(roomId, username, principal.getName());
}
@MessageMapping("/signal/{roomId}")
public void handleSignal(@DestinationVariable String roomId,
@Payload Map<String, String> payload,
Principal principal) {
if (principal == null) return;
String sender = principal.getName();
String receiver = payload.get("receiver");
String content = payload.get("content");
chatService.sendSignalingMessage(roomId, sender, receiver, content);
}
}
四、前端实现
4.1 HTML结构
登录页面 (login.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>直播连麦系统 - 登录</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="login-container">
<div class="login-box">
<h1>直播连麦系统</h1>
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" placeholder="请输入用户名">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" placeholder="请输入密码">
</div>
<button id="login-btn">登录</button>
<p class="register-link">没有账号?<a href="register.html">注册</a></p>
</div>
</div>
<script src="js/auth.js"></script>
</body>
</html>
直播大厅 (index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>直播连麦系统 - 大厅</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header>
<div class="logo">直播连麦系统</div>
<div class="user-info">
<span id="user-nickname"></span>
<img id="user-avatar" src="" alt="头像">
<button id="logout-btn">退出</button>
</div>
</header>
<main>
<div class="create-room-box" id="broadcaster-controls">
<h2>创建直播间</h2>
<div class="form-group">
<label for="room-title">直播间标题</label>
<input type="text" id="room-title" placeholder="请输入直播间标题">
</div>
<button id="create-room-btn">创建直播间</button>
</div>
<div class="room-list">
<h2>直播间列表</h2>
<div id="rooms-container"></div>
</div>
</main>
<script src="js/auth.js"></script>
<script src="js/index.js"></script>
</body>
</html>
直播间页面 (room.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>直播间</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/room.css">
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
<body>
<div class="room-container">
<header class="room-header">
<div class="room-info">
<h1 id="room-title">加载中...</h1>
<div class="broadcaster-info">
<span>主播:</span>
<span id="broadcaster-name"></span>
</div>
</div>
<div class="room-controls">
<button id="back-to-lobby">返回大厅</button>
</div>
</header>
<main class="room-main">
<div class="video-section">
<div class="main-video">
<video id="broadcaster-video" autoplay playsinline></video>
<div class="video-label" id="broadcaster-label"></div>
</div>
<div class="audience-videos" id="audience-videos-container"></div>
</div>
<div class="interaction-section">
<div class="chat-box">
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input">
<input type="text" id="chat-input" placeholder="发送消息...">
<button id="send-message-btn">发送</button>
</div>
</div>
<div class="control-panel">
<div id="audience-controls">
<button id="request-mic-btn">申请连麦</button>
<div id="mic-status"></div>
</div>
<div id="broadcaster-controls" style="display: none;">
<div class="mic-requests">
<h3>连麦申请</h3>
<ul id="mic-requests-list"></ul>
</div>
<div class="active-mics">
<h3>当前连麦</h3>
<ul id="active-mics-list"></ul>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="js/auth.js"></script>
<script src="js/webrtc.js"></script>
<script src="js/room.js"></script>
</body>
</html>
4.2 JavaScript实现
认证相关 (auth.js)
const API_URL = 'http://localhost:8080/api';
// 检查是否已登录
function checkAuth() {
const token = localStorage.getItem('token');
if (!token) {
// 未登录,跳转到登录页
if (window.location.pathname !== '/login.html' &&
window.location.pathname !== '/register.html') {
window.location.href = 'login.html';
}
return false;
}
return true;
}
// 获取当前用户信息
function getCurrentUser() {
return {
username: localStorage.getItem('username'),
nickname: localStorage.getItem('nickname'),
role: localStorage.getItem('role'),
avatar: localStorage.getItem('avatar')
};
}
// 登录处理
async function login(username, password) {
try {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
const data = await response.json();
// 存储认证信息
localStorage.setItem('token', data.token);
localStorage.setItem('username', data.username);
localStorage.setItem('nickname', data.nickname);
localStorage.setItem('role', data.role);
localStorage.setItem('avatar', data.avatar);
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
}
// 注册处理
async function register(userData) {
try {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
return true;
} catch (error) {
console.error('Registration failed:', error);
return false;
}
}
// 退出登录
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('nickname');
localStorage.removeItem('role');
localStorage.removeItem('avatar');
window.location.href = 'login.html';
}
// 获取授权头信息
function getAuthHeader() {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`
};
}
// 页面加载时检查认证状态
document.addEventListener('DOMContentLoaded', function() {
// 登录页面处理
if (window.location.pathname.includes('login.html')) {
const loginForm = document.getElementById('login-btn');
if (loginForm) {
loginForm.addEventListener('click', async function() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!username || !password) {
alert('请输入用户名和密码');
return;
}
const success = await login(username, password);
if (success) {
window.location.href = 'index.html';
} else {
alert('登录失败,请检查用户名和密码');
}
});
}
return;
}
// 其他页面需要检查认证
if (!checkAuth()) {
return;
}
// 显示用户信息
const userNickname = document.getElementById('user-nickname');
const userAvatar = document.getElementById('user-avatar');
const logoutBtn = document.getElementById('logout-btn');
if (userNickname) {
userNickname.textContent = getCurrentUser().nickname;
}
if (userAvatar) {
userAvatar.src = getCurrentUser().avatar || 'img/default-avatar.png';
}
if (logoutBtn) {
logoutBtn.addEventListener('click', logout);
}
// 根据用户角色显示/隐藏主播控制面板
const broadcasterControls = document.getElementById('broadcaster-controls');
if (broadcasterControls && getCurrentUser().role !== 'BROADCASTER') {
broadcasterControls.style.display = 'none';
}
});
直播大厅页面脚本 (index.js)
document.addEventListener('DOMContentLoaded', function() {
if (!checkAuth()) return;
loadRooms();
setupCreateRoom();
});
// 加载直播间列表
async function loadRooms() {
try {
const response = await fetch(`${API_URL}/rooms`, {
headers: getAuthHeader()
});
if (!response.ok) {
throw new Error('Failed to load rooms');
}
const rooms = await response.json();
displayRooms(rooms);
} catch (error) {
console.error('Error loading rooms:', error);
alert('加载直播间列表失败');
}
}
// 显示直播间列表
function displayRooms(rooms) {
const container = document.getElementById('rooms-container');
container.innerHTML = '';
if (rooms.length === 0) {
container.innerHTML = '<p class="no-rooms">当前没有直播间,创建一个吧!</p>';
return;
}
rooms.forEach(room => {
const roomElement = document.createElement('div');
roomElement.className = 'room-card';
roomElement.innerHTML = `
<h3>${room.title}</h3>
<div class="room-info">
<p>主播: ${room.broadcaster}</p>
<p>观众: ${room.audiences.length}人</p>
<p>连麦: ${room.activeMicUsers.length}人</p>
</div>
<button class="join-room-btn" data-room-id="${room.roomId}">进入直播间</button>
`;
container.appendChild(roomElement);
});
// 添加进入直播间的点击事件
document.querySelectorAll('.join-room-btn').forEach(button => {
button.addEventListener('click', function() {
const roomId = this.getAttribute('data-room-id');
window.location.href = `room.html?id=${roomId}`;
});
});
}
// 设置创建直播间功能
function setupCreateRoom() {
const createBtn = document.getElementById('create-room-btn');
if (!createBtn) return;
createBtn.addEventListener('click', async function() {
const title = document.getElementById('room-title').value;
if (!title) {
alert('请输入直播间标题');
return;
}
try {
const response = await fetch(`${API_URL}/rooms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeader()
},
body: JSON.stringify({ title })
});
if (!response.ok) {
throw new Error('Failed to create room');
}
const room = await response.json();
window.location.href = `room.html?id=${room.roomId}`;
} catch (error) {
console.error('Error creating room:', error);
alert('创建直播间失败');
}
});
}
WebRTC实现 (webrtc.js)
class WebRTCClient {
constructor(username, roomId, isBroadcaster) {
this.username = username;
this.roomId = roomId;
this.isBroadcaster = isBroadcaster;
this.localStream = null;
this.peerConnections = {};
this.stompClient = null;
this.mediaConstraints = {
audio: true,
video: true
};
// ICE服务器配置
this.iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
}
// 初始化WebSocket连接
async connectWebSocket() {
return new Promise((resolve, reject) => {
const socket = new SockJS('/ws');
// 增加STOMP客户端调试
const client = Stomp.over(socket);
client.debug = function(str) {
console.log('STOMP Debug: ' + str);
};
this.stompClient = client;
const headers = {
'Authorization': `Bearer ${localStorage.getItem('token')}`
};
this.stompClient.connect(headers, frame => {
console.log('Connected to WebSocket:', frame);
// 订阅信令消息
this.stompClient.subscribe(`/user/queue/signal`, message => {
console.log('Received signal message:', message.body);
const signalData = JSON.parse(message.body);
this.handleSignalingData(signalData);
});
// 订阅连麦响应
this.stompClient.subscribe(`/user/queue/mic-response`, message => {
console.log('Received mic response:', message.body);
try {
const response = JSON.parse(message.body);
this.handleMicResponse(response);
} catch (e) {
console.log('Non-JSON mic response:', message.body);
}
});
// 订阅连麦结束通知
this.stompClient.subscribe(`/user/queue/mic-ended`, message => {
console.log('Received mic ended notification:', message.body);
const data = message.body;
this.handleMicEnded(data);
});
// 如果是主播,订阅连麦请求
if (this.isBroadcaster) {
console.log('Broadcaster subscribing to mic-requests');
// 方法1: 标准订阅
this.stompClient.subscribe(`/user/queue/mic-requests`, message => {
console.log('Received mic request (standard):', message.body);
try {
const request = JSON.parse(message.body);
this.handleMicRequest(request);
} catch (e) {
console.error('Error parsing mic request:', e);
}
});
// 方法2: 尝试直接订阅完整路径
const username = localStorage.getItem('username');
this.stompClient.subscribe(`/user/${username}/queue/mic-requests`, message => {
console.log('Received mic request (direct):', message.body);
try {
const request = JSON.parse(message.body);
this.handleMicRequest(request);
} catch (e) {
console.error('Error parsing mic request:', e);
}
});
}
resolve();
}, error => {
console.error('WebSocket connection error:', error);
reject(error);
});
});
}
// 初始化本地媒体流
async initLocalStream() {
try {
this.localStream = await navigator.mediaDevices.getUserMedia(this.mediaConstraints);
return this.localStream;
} catch (error) {
console.error('Error accessing media devices:', error);
throw error;
}
}
// 创建一个新的RTCPeerConnection
createPeerConnection(remoteUsername) {
const pc = new RTCPeerConnection(this.iceServers);
// 添加本地流到连接
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
// 监听ICE候选
pc.onicecandidate = event => {
if (event.candidate) {
this.sendSignalingData(remoteUsername, {
type: 'ice-candidate',
candidate: event.candidate
});
}
};
// 监听远程流
pc.ontrack = event => {
const remoteStream = event.streams[0];
this.onRemoteStreamAdded(remoteUsername, remoteStream);
};
this.peerConnections[remoteUsername] = pc;
return pc;
}
// 发起连接(创建offer)
async createOffer(remoteUsername) {
try {
const pc = this.createPeerConnection(remoteUsername);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.sendSignalingData(remoteUsername, {
type: 'offer',
sdp: pc.localDescription
});
} catch (error) {
console.error('Error creating offer:', error);
}
}
// 响应offer(创建answer)
async handleOffer(offer, remoteUsername) {
try {
const pc = this.createPeerConnection(remoteUsername);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.sendSignalingData(remoteUsername, {
type: 'answer',
sdp: pc.localDescription
});
} catch (error) {
console.error('Error handling offer:', error);
}
}
// 处理answer
async handleAnswer(answer, remoteUsername) {
try {
const pc = this.peerConnections[remoteUsername];
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
} catch (error) {
console.error('Error handling answer:', error);
}
}
// 处理ICE候选
async handleIceCandidate(candidate, remoteUsername) {
try {
const pc = this.peerConnections[remoteUsername];
if (pc) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
}
// 发送信令数据
sendSignalingData(receiver, data) {
if (!this.stompClient) {
console.error('WebSocket not connected');
return;
}
this.stompClient.send(`/app/signal/${this.roomId}`, {}, JSON.stringify({
receiver: receiver,
content: JSON.stringify(data)
}));
}
// 处理收到的信令数据
handleSignalingData(signalData) {
const sender = signalData.sender;
const content = JSON.parse(signalData.content);
switch (content.type) {
case 'offer':
this.handleOffer(content.sdp, sender);
break;
case 'answer':
this.handleAnswer(content.sdp, sender);
break;
case 'ice-candidate':
this.handleIceCandidate(content.candidate, sender);
break;
default:
console.warn('Unknown signal type:', content.type);
}
}
// 申请连麦
requestMic() {
if (!this.stompClient || !this.stompClient.connected) {
console.error('WebSocket not connected');
alert('WebSocket连接未建立,无法申请连麦');
return false;
}
console.log(`Sending mic request for room ${this.roomId} by user ${this.username}`);
try {
// 添加更多错误处理
this.stompClient.send(`/app/mic.request/${this.roomId}`, {}, JSON.stringify({
// 添加更多信息以便调试
timestamp: new Date().getTime(),
username: this.username
}));
console.log('Mic request sent successfully');
return true;
} catch (error) {
console.error('Error sending mic request:', error);
alert('发送连麦请求失败: ' + error.message);
return false;
}
}
// 处理连麦响应
async handleMicResponse(response) {
console.log('处理连麦响应:', response);
// 兼容字符串和对象两种响应格式
let status;
if (typeof response === 'string') {
console.log('收到字符串格式的响应:', response);
// 尝试解析字符串响应
if (response.includes('ACCEPTED')) {
status = 'ACCEPTED';
} else if (response.includes('REJECTED')) {
status = 'REJECTED';
} else {
console.warn('无法识别的响应内容:', response);
return;
}
} else {
// 对象格式响应
status = response.status;
}
console.log('连麦响应状态:', status);
if (status === 'ACCEPTED') {
try {
console.log('连麦请求已接受,初始化媒体流');
// 初始化本地媒体流(如果还没有)
if (!this.localStream) {
await this.initLocalStream();
this.onLocalStreamCreated(this.localStream);
}
// 等待主播发起WebRTC连接
console.log('等待主播发起WebRTC连接');
this.onMicRequestAccepted();
} catch (error) {
console.error('初始化媒体流失败:', error);
alert('连麦初始化失败: ' + error.message);
}
} else if (status === 'REJECTED') {
console.log('连麦请求被拒绝');
this.onMicRequestRejected();
} else {
console.warn('未知的连麦响应状态:', status);
}
}
// 添加新的回调方法
onMicRequestAccepted() {
console.log('连麦请求已接受,等待连接建立');
// 这个方法会由使用者实现
}
// 处理连麦请求(主播)
async handleMicRequest(request) {
// 通知UI显示请求
this.onMicRequestReceived(request);
}
// 响应连麦请求(主播)
async respondToMicRequest(username, accept) {
if (!this.stompClient) {
console.error('WebSocket not connected');
return;
}
this.stompClient.send(`/app/mic.response/${this.roomId}`, {}, JSON.stringify({
username: username,
accept: accept
}));
if (accept) {
// 初始化本地媒体流(如果还没有)
if (!this.localStream) {
await this.initLocalStream();
this.onLocalStreamCreated(this.localStream);
}
// 主播发起WebRTC连接
await this.createOffer(username);
}
}
// 结束连麦
endMic(username) {
if (!this.stompClient) {
console.error('WebSocket not connected');
return;
}
this.stompClient.send(`/app/mic.end/${this.roomId}`, {}, username);
this.closePeerConnection(username);
}
// 处理连麦结束
handleMicEnded(data) {
if (this.isBroadcaster) {
// 主播收到用户结束连麦的通知
this.closePeerConnection(data);
this.onMicEnded(data);
} else {
// 用户收到主播结束连麦的通知
this.closePeerConnection(this.roomId);
this.onMicEnded();
}
}
// 关闭对等连接
closePeerConnection(username) {
const pc = this.peerConnections[username];
if (pc) {
pc.close();
delete this.peerConnections[username];
}
}
// 关闭所有连接
close() {
// 关闭所有对等连接
Object.keys(this.peerConnections).forEach(username => {
this.closePeerConnection(username);
});
// 关闭本地媒体流
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
}
// 断开WebSocket连接
if (this.stompClient && this.stompClient.connected) {
this.stompClient.disconnect();
}
}
// 以下方法需要在使用时实现
onLocalStreamCreated(stream) {
// 显示本地视频流
console.log('Local stream created, implement this method');
}
onRemoteStreamAdded(username, stream) {
// 显示远程视频流
console.log('Remote stream added for', username, 'implement this method');
}
onMicRequestReceived(request) {
// 显示连麦请求
console.log('Mic request received from', request.username, 'implement this method');
}
onMicRequestRejected() {
// 连麦请求被拒绝
console.log('Mic request rejected, implement this method');
}
onMicEnded(username) {
// 连麦结束
console.log('Mic ended for', username, 'implement this method');
}
}
直播间页面脚本 (room.js)
document.addEventListener('DOMContentLoaded', function() {
if (!checkAuth()) return;
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');
if (!roomId) {
alert('直播间ID不能为空');
window.location.href = 'index.html';
return;
}
const currentUser = getCurrentUser();
const isBroadcaster = currentUser.role === 'BROADCASTER';
// 初始化WebRTC客户端
const webrtcClient = new WebRTCClient(currentUser.username, roomId, isBroadcaster);
// 明确设置为全局变量
window.webrtcClient = webrtcClient;
// 设置事件处理函数
setupEventHandlers(webrtcClient);
// 加载直播间信息
loadRoomInfo(roomId);
// 连接WebSocket
initializeWebSocketAndMedia(webrtcClient, isBroadcaster);
});
// 设置事件处理函数
function setupEventHandlers(webrtcClient) {
// 返回大厅按钮
document.getElementById('back-to-lobby').addEventListener('click', function() {
webrtcClient.close();
window.location.href = 'index.html';
});
// 发送消息按钮
document.getElementById('send-message-btn').addEventListener('click', sendChatMessage);
// 聊天输入框回车发送
document.getElementById('chat-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendChatMessage();
}
});
// 申请连麦按钮(观众)
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.addEventListener('click', function() {
console.log('申请连麦按钮被点击');
try {
const success = webrtcClient.requestMic();
if (success) {
this.disabled = true;
this.textContent = '已申请连麦,等待主播接受...';
document.getElementById('mic-status').textContent = '连麦申请中...';
console.log('连麦申请已发送');
} else {
console.error('连麦申请发送失败');
}
} catch (error) {
console.error('申请连麦时发生错误:', error);
alert('申请连麦失败: ' + error.message);
}
});
}
// 重写WebRTC回调函数
webrtcClient.onLocalStreamCreated = function(stream) {
const videoElement = document.getElementById('local-video') ||
document.getElementById('broadcaster-video');
videoElement.srcObject = stream;
};
webrtcClient.onRemoteStreamAdded = function(username, stream) {
let videoElement;
if (webrtcClient.isBroadcaster) {
// 主播看到观众视频
const audienceContainer = document.getElementById('audience-videos-container');
const audienceVideoDiv = document.createElement('div');
audienceVideoDiv.className = 'audience-video-container';
audienceVideoDiv.id = `audience-${username}`;
audienceVideoDiv.innerHTML = `
<video class="audience-video" id="video-${username}" autoplay playsinline></video>
<div class="video-label">${username}</div>
<button class="end-mic-btn" data-username="${username}">结束连麦</button>
`;
audienceContainer.appendChild(audienceVideoDiv);
videoElement = document.getElementById(`video-${username}`);
// 添加结束连麦按钮事件
audienceVideoDiv.querySelector('.end-mic-btn').addEventListener('click', function() {
const username = this.getAttribute('data-username');
webrtcClient.endMic(username);
});
} else {
// 观众看到主播视频
videoElement = document.getElementById('broadcaster-video');
}
if (videoElement) {
videoElement.srcObject = stream;
}
};
webrtcClient.onMicRequestReceived = function(request) {
console.log('处理连麦请求:', request);
const requestsList = document.getElementById('mic-requests-list');
if (!requestsList) {
console.error('找不到连麦请求列表元素');
return;
}
// 检查是否已存在相同的请求
const existingRequest = document.getElementById(`request-${request.username}`);
if (existingRequest) {
console.log('已存在相同用户的连麦请求,不重复添加');
return;
}
const requestItem = document.createElement('li');
requestItem.className = 'mic-request-item';
requestItem.id = `request-${request.username}`;
requestItem.innerHTML = `
<span>${request.username} 申请连麦</span>
<div class="request-actions">
<button class="accept-btn" data-username="${request.username}">接受</button>
<button class="reject-btn" data-username="${request.username}">拒绝</button>
</div>
`;
requestsList.appendChild(requestItem);
console.log('连麦请求UI已添加');
// 添加按钮事件
const acceptBtn = requestItem.querySelector('.accept-btn');
if (acceptBtn) {
acceptBtn.addEventListener('click', function() {
const username = this.getAttribute('data-username');
console.log(`接受用户 ${username} 的连麦请求`);
// 同时使用WebSocket和HTTP API发送响应
webrtcClient.respondToMicRequest(username, true);
// 备用HTTP请求
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');
fetch(`${API_URL}/rooms/${roomId}/mic-response?username=${username}&accept=true`, {
method: 'POST',
headers: getAuthHeader()
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP API连麦响应失败');
}
console.log('通过HTTP API发送连麦接受成功');
})
.catch(error => {
console.error('发送连麦响应失败:', error);
});
requestItem.remove();
// 添加到活跃连麦列表
const activeList = document.getElementById('active-mics-list');
if (activeList) {
const activeItem = document.createElement('li');
activeItem.className = 'active-mic-item';
activeItem.id = `active-${username}`;
activeItem.innerHTML = `
<span>${username}</span>
<button class="end-mic-btn" data-username="${username}">结束连麦</button>
`;
activeList.appendChild(activeItem);
// 添加结束连麦按钮事件
const endMicBtn = activeItem.querySelector('.end-mic-btn');
if (endMicBtn) {
endMicBtn.addEventListener('click', function() {
const username = this.getAttribute('data-username');
console.log(`结束与用户 ${username} 的连麦`);
webrtcClient.endMic(username);
});
}
}
});
}
const rejectBtn = requestItem.querySelector('.reject-btn');
if (rejectBtn) {
rejectBtn.addEventListener('click', function() {
const username = this.getAttribute('data-username');
console.log(`拒绝用户 ${username} 的连麦请求`);
// 同时使用WebSocket和HTTP API发送响应
webrtcClient.respondToMicRequest(username, false);
// 备用HTTP请求
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');
fetch(`${API_URL}/rooms/${roomId}/mic-response?username=${username}&accept=false`, {
method: 'POST',
headers: getAuthHeader()
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP API连麦响应失败');
}
console.log('通过HTTP API发送连麦拒绝成功');
})
.catch(error => {
console.error('发送连麦响应失败:', error);
});
requestItem.remove();
});
}
};
webrtcClient.onMicEnded = function(username) {
if (webrtcClient.isBroadcaster && username) {
// 主播结束观众连麦
const audienceVideo = document.getElementById(`audience-${username}`);
if (audienceVideo) {
audienceVideo.remove();
}
const activeMicItem = document.getElementById(`active-${username}`);
if (activeMicItem) {
activeMicItem.remove();
}
} else {
// 观众的连麦被结束
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.disabled = false;
requestMicBtn.textContent = '申请连麦';
}
document.getElementById('mic-status').textContent = '连麦已结束';
// 停止本地视频
const localVideo = document.getElementById('local-video');
if (localVideo && localVideo.srcObject) {
localVideo.srcObject.getTracks().forEach(track => track.stop());
localVideo.srcObject = null;
}
}
};
webrtcClient.onMicRequestAccepted = function() {
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.disabled = true;
requestMicBtn.textContent = '连麦已接受,正在建立连接...';
}
document.getElementById('mic-status').textContent = '连麦已接受,等待连接建立...';
// 可以添加一个本地视频预览
const videoSection = document.querySelector('.video-section');
if (!document.getElementById('local-video-container')) {
const localVideoContainer = document.createElement('div');
localVideoContainer.className = 'local-video-container';
localVideoContainer.id = 'local-video-container';
localVideoContainer.innerHTML = `
<video id="local-video" autoplay playsinline muted></video>
<div class="video-label">我(观众)</div>
<button id="end-local-mic-btn">结束连麦</button>
`;
videoSection.appendChild(localVideoContainer);
// 设置本地视频流
if (webrtcClient.localStream) {
document.getElementById('local-video').srcObject = webrtcClient.localStream;
}
// 添加结束连麦按钮事件
document.getElementById('end-local-mic-btn').addEventListener('click', function() {
webrtcClient.endMic(getCurrentUser().username);
});
}
};
webrtcClient.onMicRequestRejected = function() {
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.disabled = false;
requestMicBtn.textContent = '申请连麦';
}
document.getElementById('mic-status').textContent = '连麦申请被拒绝';
// 提醒用户
alert('主播拒绝了您的连麦申请');
};
}
// 加载直播间信息
async function loadRoomInfo(roomId) {
try {
const response = await fetch(`${API_URL}/rooms/${roomId}`, {
headers: getAuthHeader()
});
if (!response.ok) {
throw new Error('Failed to load room info');
}
const room = await response.json();
// 更新页面信息
document.getElementById('room-title').textContent = room.title;
document.getElementById('broadcaster-name').textContent = room.broadcaster;
// 显示主播控制面板或观众控制面板
const currentUser = getCurrentUser();
const isBroadcaster = currentUser.username === room.broadcaster;
if (isBroadcaster) {
document.getElementById('broadcaster-controls').style.display = 'block';
document.getElementById('audience-controls').style.display = 'none';
document.getElementById('broadcaster-label').textContent = '我(主播)';
} else {
document.getElementById('broadcaster-controls').style.display = 'none';
document.getElementById('audience-controls').style.display = 'block';
document.getElementById('broadcaster-label').textContent = room.broadcaster;
}
} catch (error) {
console.error('Error loading room info:', error);
alert('加载直播间信息失败');
window.location.href = 'index.html';
}
}
// 初始化WebSocket和媒体流
async function initializeWebSocketAndMedia(webrtcClient, isBroadcaster) {
try {
// 连接WebSocket
await webrtcClient.connectWebSocket();
// 订阅直播间消息
subscribeToRoomMessages(webrtcClient.stompClient, webrtcClient.roomId);
// 如果是主播,初始化本地媒体流
if (isBroadcaster) {
const localStream = await webrtcClient.initLocalStream();
webrtcClient.onLocalStreamCreated(localStream);
}
} catch (error) {
console.error('Error initializing:', error);
alert('初始化连接失败');
}
}
// 订阅直播间消息
function subscribeToRoomMessages(stompClient, roomId) {
stompClient.subscribe(`/topic/room/${roomId}`, function(message) {
const chatMessage = JSON.parse(message.body);
displayChatMessage(chatMessage);
});
}
// 发送聊天消息
function sendChatMessage() {
const inputElement = document.getElementById('chat-input');
const content = inputElement.value.trim();
if (!content) return;
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');
// 添加检查确保webrtcClient存在
if (window.webrtcClient && window.webrtcClient.stompClient && window.webrtcClient.stompClient.connected) {
window.webrtcClient.stompClient.send(`/app/chat.send/${roomId}`, {}, JSON.stringify({
content: content,
type: 'CHAT'
}));
inputElement.value = '';
} else {
console.error('WebSocket连接未建立');
alert('消息发送失败,WebSocket连接未建立');
}
}
// 显示聊天消息
function displayChatMessage(message) {
const messagesContainer = document.getElementById('chat-messages');
const messageElement = document.createElement('div');
messageElement.className = 'chat-message';
switch (message.type) {
case 'CHAT':
messageElement.innerHTML = `
<span class="message-sender">${message.sender}:</span>
<span class="message-content">${message.content}</span>
<span class="message-time">${formatTime(message.timestamp)}</span>
`;
break;
case 'JOIN':
case 'LEAVE':
messageElement.className += ' system-message';
messageElement.innerHTML = `
<span class="message-content">${message.content}</span>
<span class="message-time">${formatTime(message.timestamp)}</span>
`;
break;
}
messagesContainer.appendChild(messageElement);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function setupMicResponsePolling(roomId, username) {
// 只有观众且有正在等待的连麦请求时才需要轮询
const isBroadcaster = getCurrentUser().role === 'BROADCASTER';
if (isBroadcaster) {
return;
}
let isPolling = false;
const requestMicBtn = document.getElementById('request-mic-btn');
// 监听按钮状态变化
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
// 按钮被禁用,表示已发送连麦请求
if (requestMicBtn.disabled && !isPolling) {
startPolling();
} else if (!requestMicBtn.disabled && isPolling) {
stopPolling();
}
}
});
});
observer.observe(requestMicBtn, { attributes: true });
let pollingInterval;
function startPolling() {
console.log('开始轮询连麦响应');
isPolling = true;
pollingInterval = setInterval(async function() {
try {
const response = await fetch(`${API_URL}/rooms/${roomId}/mic-status?username=${username}`, {
headers: getAuthHeader()
});
if (!response.ok) {
throw new Error('获取连麦状态失败');
}
const data = await response.json();
console.log('轮询到的连麦状态:', data);
if (data.status === 'ACCEPTED') {
console.log('连麦请求已被接受');
// 手动触发处理
webrtcClient.handleMicResponse({ status: 'ACCEPTED' });
stopPolling();
} else if (data.status === 'REJECTED') {
console.log('连麦请求已被拒绝');
// 手动触发处理
webrtcClient.handleMicResponse({ status: 'REJECTED' });
stopPolling();
}
// PENDING状态继续轮询
} catch (error) {
console.error('轮询连麦状态失败:', error);
}
}, 2000); // 每2秒轮询一次
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
isPolling = false;
console.log('停止轮询连麦响应');
}
}
// 页面卸载时清除轮询
window.addEventListener('beforeunload', stopPolling);
}
// 格式化时间
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
4.3 CSS样式
基础样式 (style.css)
/* style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
/* 登录和注册页面样式 */
.login-container, .register-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f2f5;
}
.login-box, .register-box {
width: 400px;
padding: 30px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.login-box h1, .register-box h1 {
text-align: center;
margin-bottom: 20px;
color: #1890ff;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
button {
width: 100%;
padding: 12px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #40a9ff;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.register-link, .login-link {
text-align: center;
margin-top: 20px;
}
.register-link a, .login-link a {
color: #1890ff;
text-decoration: none;
}
/* 头部导航栏 */
header {
background-color: #1890ff;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 20px;
font-weight: bold;
}
.user-info {
display: flex;
align-items: center;
}
.user-info span {
margin-right: 10px;
}
.user-info img {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
/* 大厅页面样式 */
main {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.create-room-box {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.create-room-box h2 {
margin-bottom: 15px;
color: #1890ff;
}
.room-list {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.room-list h2 {
margin-bottom: 15px;
color: #1890ff;
}
.room-card {
border: 1px solid #eee;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
transition: all 0.3s;
}
.room-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.room-card h3 {
margin-bottom: 10px;
color: #333;
}
.room-info {
margin-bottom: 15px;
color: #666;
}
.room-card .join-room-btn {
width: auto;
padding: 8px 15px;
}
.no-rooms {
text-align: center;
color: #999;
padding: 20px;
}
直播间页面样式 (room.css)
/* room.css */
.room-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.room-header {
background-color: #1890ff;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.room-info h1 {
margin: 0;
font-size: 24px;
}
.broadcaster-info {
font-size: 14px;
margin-top: 5px;
}
.room-controls button {
width: auto;
padding: 8px 15px;
background-color: white;
color: #1890ff;
}
.room-main {
display: flex;
flex: 1;
overflow: hidden;
}
.video-section {
flex: 2;
display: flex;
flex-direction: column;
background-color: #000;
position: relative;
}
.main-video {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.main-video video {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-label {
position: absolute;
bottom: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.6);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 14px;
}
.audience-videos {
height: 150px;
display: flex;
padding: 10px;
gap: 10px;
background-color: #1a1a1a;
overflow-x: auto;
}
.audience-video-container {
width: 200px;
height: 100%;
position: relative;
background-color: #333;
border-radius: 4px;
overflow: hidden;
}
.audience-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.end-mic-btn {
position: absolute;
top: 5px;
right: 5px;
width: auto;
padding: 3px 8px;
font-size: 12px;
background-color: #ff4d4f;
}
.interaction-section {
flex: 1;
display: flex;
flex-direction: column;
background-color: white;
border-left: 1px solid #eee;
}
.chat-box {
flex: 2;
display: flex;
flex-direction: column;
border-bottom: 1px solid #eee;
}
.chat-messages {
flex: 1;
padding: 15px;
overflow-y: auto;
}
.chat-message {
margin-bottom: 10px;
padding: 8px 10px;
background-color: #f9f9f9;
border-radius: 4px;
word-break: break-word;
}
.message-sender {
font-weight: bold;
color: #1890ff;
margin-right: 5px;
}
.message-time {
font-size: 12px;
color: #999;
margin-left: 5px;
}
.system-message {
background-color: #f0f0f0;
color: #888;
text-align: center;
font-style: italic;
}
.chat-input {
display: flex;
padding: 10px;
border-top: 1px solid #eee;
}
.chat-input input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
margin-right: 10px;
}
.chat-input button {
width: auto;
}
.control-panel {
flex: 1;
padding: 15px;
overflow-y: auto;
}
#audience-controls {
text-align: center;
}
#mic-status {
margin-top: 10px;
font-size: 14px;
color: #666;
}
.mic-requests, .active-mics {
margin-bottom: 20px;
}
.mic-requests h3, .active-mics h3 {
font-size: 16px;
margin-bottom: 10px;
color: #1890ff;
}
.mic-request-item, .active-mic-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
margin-bottom: 10px;
}
.request-actions {
display: flex;
gap: 5px;
}
.accept-btn, .reject-btn {
width: auto;
padding: 5px 10px;
font-size: 12px;
}
.accept-btn {
background-color: #52c41a;
}
.reject-btn {
background-color: #ff4d4f;
}
/* 响应式布局 */
@media (max-width: 768px) {
.room-main {
flex-direction: column;
}
.audience-videos {
height: 100px;
}
.audience-video-container {
width: 120px;
}
.interaction-section {
border-left: none;
border-top: 1px solid #eee;
}
}
五、功能演示流程
5.1 登录主播账号
打开一个浏览器窗口,访问http://localhost:8080/login.html 输入账号 zb1, 密码 zb1登录系统创建直播间
5.1 登录观众账号
新打开另一个浏览器窗口(注意不是多个TAB,是独立的浏览器窗口),输入账号 gz1, 密码 gz1登录系统,进入直播间,申请连麦
5.2 接受或拒绝连麦
主播收到连麦申请,可以选择接受或者拒绝连麦
此处需要注意,同一台电脑的device只能在一个窗口使用,也就是没法在两个窗口同时看到画面内容