SpringBoot+WebSocket实现直播连麦

首页 编程分享 PHP丨JAVA丨OTHER 正文

风象南 转载 编程分享 2025-06-28 22:05:46

简介 ## 一、引言 随着互联网技术的发展,直播已成为一种主流的内容传播形式。 其中,连麦功能作为直播


一、引言

随着互联网技术的发展,直播已成为一种主流的内容传播形式。

其中,连麦功能作为直播互动的重要手段,能够有效提升用户参与感和观看体验。

本文将介绍如何使用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 核心功能模块

  1. 用户认证模块:处理用户登录和简单认证
  2. 直播间管理模块:创建、查询和管理直播间
  3. WebSocket通信模块:处理实时消息传递
  4. 连麦管理模块:处理连麦请求、状态管理和信令交换
  5. 聊天消息模块:处理直播间内的文字聊天

2.3 数据流程

  1. 用户通过HTTP接口登录获取Token
  2. 主播创建直播间,系统分配直播间ID
  3. 观众通过直播间ID加入直播间
  4. 观众发送连麦请求,主播确认后建立WebRTC连接
  5. 所有用户通过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只能在一个窗口使用,也就是没法在两个窗口同时看到画面内容

转载链接:https://juejin.cn/post/7519752214744498217


Tags:


本篇评论 —— 揽流光,涤眉霜,清露烈酒一口话苍茫。


    声明:参照站内规则,不文明言论将会删除,谢谢合作。


      最新评论




ABOUT ME

Blogger:袅袅牧童 | Arkin

Ido:PHP攻城狮

WeChat:nnmutong

Email:nnmutong@icloud.com

标签云