声网SDK

38 阅读5分钟

简介:声网 RTC SDK 是提供音视频实时互动云服务的产品,支持多平台多设备运行,可实现一对一单聊、多人群聊,同时具备纯语音通话、高清视频通话和互动直播功能。

功能: 实现音视频实时互动

代码仓库:gitee.com/agoraio-com…

官网:doc.shengwang.cn/doc/rtc/jav…

相关概念及流程:doc.shengwang.cn/doc/rtc/jav…

前端开发(快速入门):docs.agora.io/en/interact…

前端实现:vue3接入SDK(demo)

import { ref, reactive, onUnmounted } from 'vue';
import AgoraRTC from "agora-rtc-sdk-ng";

/**
 * Agora RTC Composition API
 * 提供音视频通话功能
 */
export function useAgoraRTC() {
    // 响应式状态
    const client = ref(null);
    const localAudioTrack = ref(null);
    const localVideoTrack = ref(null);
    const remoteUsers = ref(new Map());
    const isConnected = ref(false);
    const isPublishing = ref(false);
    const connectionState = ref('disconnected'); // disconnected, connecting, connected, reconnecting
    const error = ref(null);
    
    // 配置参数
    const config = reactive({
        appId: import.meta.env.VITE_AGORA_APP_ID || "<-- Insert app ID -->",
        channel: "default-channel",
        token: import.meta.env.VITE_AGORA_TOKEN || "<-- Insert token -->",
        uid: Math.floor(Math.random() * 100000)
    });

    // 初始化客户端
    const initializeClient = () => {
        if (client.value) return;
        
        try {
            client.value = AgoraRTC.createClient({ 
                mode: "live", 
                codec: "vp8" 
            });
            
            setupEventListeners();
            error.value = null;
        } catch (err) {
            error.value = `初始化客户端失败: ${err.message}`;
            console.error("初始化客户端失败:", err);
        }
    };

    // 设置事件监听器
    const setupEventListeners = () => {
        if (!client.value) return;

        // 用户发布媒体流
        client.value.on("user-published", async (user, mediaType) => {
            try {
                await client.value.subscribe(user, mediaType);
                console.log("订阅用户成功:", user.uid);
                
                if (mediaType === "video") {
                    remoteUsers.value.set(user.uid, user);
                }
                if (mediaType === "audio") {
                    user.audioTrack?.play();
                }
            } catch (err) {
                error.value = `订阅用户失败: ${err.message}`;
                console.error("订阅用户失败:", err);
            }
        });

        // 用户取消发布
        client.value.on("user-unpublished", (user) => {
            remoteUsers.value.delete(user.uid);
        });

        // 用户离开
        client.value.on("user-left", (user) => {
            remoteUsers.value.delete(user.uid);
        });

        // 连接状态变化
        client.value.on("connection-state-change", (state) => {
            connectionState.value = state;
            isConnected.value = state === 'CONNECTED';
            console.log("连接状态变化:", state);
        });

        // 异常处理
        client.value.on("exception", (event) => {
            error.value = `Agora异常: ${event.code} - ${event.msg}`;
            console.error("Agora异常:", event);
        });
    };

    // 创建本地音视频轨道
    const createLocalTracks = async () => {
        try {
            localAudioTrack.value = await AgoraRTC.createMicrophoneAudioTrack({
                encoderConfig: "music_standard",
                AEC: true, // 回声消除
                ANS: true, // 噪声抑制
                AGC: true  // 自动增益控制
            });
            
            localVideoTrack.value = await AgoraRTC.createCameraVideoTrack({
                encoderConfig: "1080p_2",
                optimizationMode: "detail",
                cameraId: await getCameraDeviceId()
            });
            
            error.value = null;
            return true;
        } catch (err) {
            error.value = `创建本地轨道失败: ${err.message}`;
            console.error("创建本地轨道失败:", err);
            return false;
        }
    };

    // 获取摄像头设备ID
    const getCameraDeviceId = async () => {
        try {
            const devices = await AgoraRTC.getCameras();
            return devices.length > 0 ? devices[0].deviceId : undefined;
        } catch {
            return undefined;
        }
    };

    // 作为主持人加入频道
    const joinAsHost = async (customConfig = {}) => {
        try {
            Object.assign(config, customConfig);
            initializeClient();
            
            if (!client.value) {
                throw new Error("客户端未初始化");
            }
            
            connectionState.value = 'connecting';
            await client.value.join(config.appId, config.channel, config.token, config.uid);
            
            await client.value.setClientRole("host");
            
            const tracksCreated = await createLocalTracks();
            if (tracksCreated) {
                await client.value.publish([localAudioTrack.value, localVideoTrack.value]);
                isPublishing.value = true;
            }
            
            connectionState.value = 'connected';
            error.value = null;
            return true;
        } catch (err) {
            error.value = `主持人加入失败: ${err.message}`;
            connectionState.value = 'disconnected';
            console.error("主持人加入失败:", err);
            return false;
        }
    };

    // 作为观众加入频道
    const joinAsAudience = async (customConfig = {}) => {
        try {
            Object.assign(config, customConfig);
            initializeClient();
            
            if (!client.value) {
                throw new Error("客户端未初始化");
            }
            
            connectionState.value = 'connecting';
            await client.value.join(config.appId, config.channel, config.token, config.uid);
            
            await client.value.setClientRole("audience", { level: 2 });
            connectionState.value = 'connected';
            error.value = null;
            return true;
        } catch (err) {
            error.value = `观众加入失败: ${err.message}`;
            connectionState.value = 'disconnected';
            console.error("观众加入失败:", err);
            return false;
        }
    };

    // 离开频道
    const leaveChannel = async () => {
        try {
            // 停止发布
            if (isPublishing.value && client.value) {
                await client.value.unpublish();
                isPublishing.value = false;
            }
            
            // 关闭本地轨道
            if (localAudioTrack.value) {
                localAudioTrack.value.close();
                localAudioTrack.value = null;
            }
            if (localVideoTrack.value) {
                localVideoTrack.value.close();
                localVideoTrack.value = null;
            }
            
            // 离开频道
            if (client.value) {
                await client.value.leave();
                client.value = null;
            }
            
            // 清理状态
            remoteUsers.value.clear();
            isConnected.value = false;
            connectionState.value = 'disconnected';
            error.value = null;
            
            return true;
        } catch (err) {
            error.value = `离开频道失败: ${err.message}`;
            console.error("离开频道失败:", err);
            return false;
        }
    };

    // 切换音频状态
    const toggleAudio = () => {
        if (localAudioTrack.value) {
            const enabled = !localAudioTrack.value.enabled;
            localAudioTrack.value.setEnabled(enabled);
            return enabled;
        }
        return false;
    };

    // 切换视频状态
    const toggleVideo = () => {
        if (localVideoTrack.value) {
            const enabled = !localVideoTrack.value.enabled;
            localVideoTrack.value.setEnabled(enabled);
            return enabled;
        }
        return false;
    };

    // 获取音频状态
    const getAudioStatus = () => {
        return localAudioTrack.value ? localAudioTrack.value.enabled : false;
    };

    // 获取视频状态
    const getVideoStatus = () => {
        return localVideoTrack.value ? localVideoTrack.value.enabled : false;
    };

    // 切换摄像头
    const switchCamera = async (deviceId) => {
        if (!localVideoTrack.value) return false;
        
        try {
            await localVideoTrack.value.setDevice(deviceId);
            return true;
        } catch (err) {
            error.value = `切换摄像头失败: ${err.message}`;
            console.error("切换摄像头失败:", err);
            return false;
        }
    };

    // 获取可用设备列表
    const getDevices = async () => {
        try {
            const [microphones, cameras, speakers] = await Promise.all([
                AgoraRTC.getMicrophones(),
                AgoraRTC.getCameras(),
                AgoraRTC.getPlaybackDevices()
            ]);
            
            return {
                microphones,
                cameras,
                speakers
            };
        } catch (err) {
            error.value = `获取设备列表失败: ${err.message}`;
            console.error("获取设备列表失败:", err);
            return { microphones: [], cameras: [], speakers: [] };
        }
    };

    // 组件卸载时清理资源
    onUnmounted(() => {
        leaveChannel();
    });

    return {
        // 状态
        client,
        localAudioTrack,
        localVideoTrack,
        remoteUsers,
        isConnected,
        isPublishing,
        connectionState,
        config,
        error,
        
        // 方法
        initializeClient,
        joinAsHost,
        joinAsAudience,
        leaveChannel,
        toggleAudio,
        toggleVideo,
        getAudioStatus,
        getVideoStatus,
        switchCamera,
        getDevices,
        createLocalTracks
    };
}
<template>
    <div class="video-container">
        <!-- 头部标题 -->
        <div class="video-header">
            <h1>互动直播 Agora Web SDK</h1>
            <div class="connection-status" :class="connectionState">
                状态: {{ getStatusText(connectionState) }}
            </div>
        </div>

        <!-- 错误提示 -->
        <div v-if="error" class="error-message">
            {{ error }}
        </div>

        <!-- 视频区域 -->
        <div class="video-content">
            <!-- 本地视频 -->
            <div class="video-section">
                <h3>本地视频</h3>
                <div class="video-player">
                    <div 
                        ref="localVideoContainer" 
                        class="video-element"
                        :class="{ 'video-disabled': !getVideoStatus() }"
                    ></div>
                    <div v-if="!isConnected" class="video-placeholder">
                        未连接
                    </div>
                </div>
                <div class="video-controls" v-if="isPublishing">
                    <el-button 
                        :type="getAudioStatus() ? 'primary' : 'danger'" 
                        @click="toggleAudio"
                        :disabled="!isConnected"
                    >
                        {{ getAudioStatus() ? '静音' : '取消静音' }}
                    </el-button>
                    <el-button 
                        :type="getVideoStatus() ? 'primary' : 'danger'" 
                        @click="toggleVideo"
                        :disabled="!isConnected"
                    >
                        {{ getVideoStatus() ? '关闭视频' : '开启视频' }}
                    </el-button>
                </div>
            </div>

            <!-- 远程视频 -->
            <div class="video-section">
                <h3>远程用户 ({{ remoteUsers.size }})</h3>
                <div class="remote-videos">
                    <div 
                        v-for="[uid, user] in remoteUsers" 
                        :key="uid"
                        class="remote-video-item"
                    >
                        <div 
                            :ref="el => setRemoteVideoRef(uid, el)"
                            class="video-element remote"
                        ></div>
                        <div class="user-info">用户 {{ uid }}</div>
                    </div>
                    <div v-if="remoteUsers.size === 0" class="no-remote-users">
                        暂无远程用户
                    </div>
                </div>
            </div>
        </div>

        <!-- 控制按钮 -->
        <div class="control-buttons">
            <el-button 
                type="primary" 
                @click="handleJoinAsHost"
                :disabled="isConnected"
                :loading="connectionState === 'connecting'"
            >
                作为主持人加入
            </el-button>
            <el-button 
                type="success" 
                @click="handleJoinAsAudience"
                :disabled="isConnected"
                :loading="connectionState === 'connecting'"
            >
                作为观众加入
            </el-button>
            <el-button 
                type="danger" 
                @click="handleLeave"
                :disabled="!isConnected"
            >
                离开频道
            </el-button>
            <el-button 
                type="info" 
                @click="handleRefresh"
            >
                刷新页面
            </el-button>
        </div>

        <!-- 配置面板 -->
        <div class="config-panel">
            <el-collapse>
                <el-collapse-item title="频道配置">
                    <div class="config-form">
                        <el-form :model="customConfig" label-width="120px">
                            <el-form-item label="App ID">
                                <el-input v-model="customConfig.appId" placeholder="请输入App ID"></el-input>
                            </el-form-item>
                            <el-form-item label="频道名称">
                                <el-input v-model="customConfig.channel" placeholder="请输入频道名称"></el-input>
                            </el-form-item>
                            <el-form-item label="Token">
                                <el-input v-model="customConfig.token" placeholder="请输入Token"></el-input>
                            </el-form-item>
                            <el-form-item label="用户ID">
                                <el-input v-model="customConfig.uid" type="number" placeholder="请输入用户ID"></el-input>
                            </el-form-item>
                        </el-form>
                    </div>
                </el-collapse-item>
            </el-collapse>
        </div>
    </div>
</template>

<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { useAgoraRTC } from '@/composables/useAgoraRTC';

// 使用Agora RTC
const {
    client,
    localAudioTrack,
    localVideoTrack,
    remoteUsers,
    isConnected,
    isPublishing,
    connectionState,
    config,
    error,
    joinAsHost,
    joinAsAudience,
    leaveChannel,
    toggleAudio,
    toggleVideo,
    getAudioStatus,
    getVideoStatus
} = useAgoraRTC();

// 本地视频容器引用
const localVideoContainer = ref(null);

// 远程视频容器引用映射
const remoteVideoRefs = ref(new Map());

// 自定义配置
const customConfig = reactive({
    appId: '',
    channel: '',
    token: '',
    uid: null
});

// 设置远程视频引用
const setRemoteVideoRef = (uid, el) => {
    if (el) {
        remoteVideoRefs.value.set(uid, el);
        playRemoteVideo(uid);
    }
};

// 播放远程视频
const playRemoteVideo = (uid) => {
    const user = remoteUsers.value.get(uid);
    const container = remoteVideoRefs.value.get(uid);
    
    if (user && user.videoTrack && container) {
        user.videoTrack.play(container);
    }
};

// 播放本地视频
const playLocalVideo = () => {
    if (localVideoTrack.value && localVideoContainer.value) {
        localVideoTrack.value.play(localVideoContainer.value);
    }
};

// 获取状态文本
const getStatusText = (state) => {
    const statusMap = {
        'disconnected': '未连接',
        'connecting': '连接中...',
        'connected': '已连接',
        'reconnecting': '重新连接中...',
        'DISCONNECTED': '断开连接',
        'CONNECTING': '连接中',
        'CONNECTED': '已连接',
        'RECONNECTING': '重新连接中',
        'ABORTED': '连接中止'
    };
    return statusMap[state] || state;
};

// 作为主持人加入
const handleJoinAsHost = async () => {
    const success = await joinAsHost({
        ...customConfig,
        uid: customConfig.uid || config.uid
    });
    
    if (success) {
        ElMessage.success('主持人加入成功');
        await nextTick();
        playLocalVideo();
    } else {
        ElMessage.error('主持人加入失败');
    }
};

// 作为观众加入
const handleJoinAsAudience = async () => {
    const success = await joinAsAudience({
        ...customConfig,
        uid: customConfig.uid || config.uid
    });
    
    if (success) {
        ElMessage.success('观众加入成功');
    } else {
        ElMessage.error('观众加入失败');
    }
};

// 离开频道
const handleLeave = async () => {
    const success = await leaveChannel();
    if (success) {
        ElMessage.success('离开频道成功');
    } else {
        ElMessage.error('离开频道失败');
    }
};

// 刷新页面
const handleRefresh = () => {
    window.location.reload();
};

// 监听远程用户变化,自动播放视频
import { watch } from 'vue';
watch(remoteUsers, (newUsers) => {
    nextTick(() => {
        newUsers.forEach((user, uid) => {
            playRemoteVideo(uid);
        });
    });
}, { deep: true });

// 组件挂载时初始化
onMounted(() => {
    // 设置默认配置
    Object.assign(customConfig, {
        appId: config.appId,
        channel: config.channel,
        token: config.token,
        uid: config.uid
    });
});

// 组件卸载时清理
onUnmounted(() => {
    leaveChannel();
});
</script>

<style scoped>
.video-container {
    padding: 20px;
    max-width: 1200px;
    margin: 0 auto;
}

.video-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
    padding-bottom: 10px;
    border-bottom: 1px solid #e4e7ed;
}

.video-header h1 {
    margin: 0;
    color: #303133;
}

.connection-status {
    padding: 8px 16px;
    border-radius: 4px;
    font-weight: bold;
}

.connection-status.connected,
.connection-status.CONNECTED {
    background-color: #f0f9ff;
    color: #1890ff;
}

.connection-status.connecting,
.connection-status.CONNECTING {
    background-color: #fff7e6;
    color: #fa8c16;
}

.connection-status.disconnected,
.connection-status.DISCONNECTED {
    background-color: #fff2f0;
    color: #ff4d4f;
}

.error-message {
    background-color: #fff2f0;
    color: #ff4d4f;
    padding: 12px;
    border-radius: 4px;
    margin-bottom: 20px;
    border: 1px solid #ffccc7;
}

.video-content {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 20px;
    margin-bottom: 20px;
}

.video-section {
    background: #fafafa;
    border-radius: 8px;
    padding: 16px;
}

.video-section h3 {
    margin: 0 0 16px 0;
    color: #606266;
}

.video-player {
    position: relative;
    background: #000;
    border-radius: 4px;
    overflow: hidden;
    min-height: 300px;
}

.video-element {
    width: 100%;
    height: 300px;
    object-fit: cover;
}

.video-element.video-disabled {
    opacity: 0.3;
}

.video-placeholder {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #fff;
    font-size: 18px;
}

.video-controls {
    margin-top: 12px;
    display: flex;
    gap: 8px;
}

.remote-videos {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 12px;
}

.remote-video-item {
    position: relative;
    background: #000;
    border-radius: 4px;
    overflow: hidden;
    min-height: 150px;
}

.remote-video-item .video-element {
    height: 150px;
}

.user-info {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    background: rgba(0, 0, 0, 0.7);
    color: #fff;
    padding: 4px 8px;
    font-size: 12px;
}

.no-remote-users {
    grid-column: 1 / -1;
    text-align: center;
    padding: 40px;
    color: #909399;
    font-style: italic;
}

.control-buttons {
    display: flex;
    gap: 12px;
    justify-content: center;
    margin-bottom: 20px;
}

.config-panel {
    background: #fafafa;
    border-radius: 8px;
    padding: 16px;
}

.config-form {
    max-width: 500px;
}

/* 响应式设计 */
@media (max-width: 768px) {
    .video-content {
        grid-template-columns: 1fr;
    }
    
    .control-buttons {
        flex-direction: column;
        align-items: center;
    }
    
    .control-buttons .el-button {
        width: 100%;
        max-width: 200px;
    }
}
</style>