关于浏览器中的屏幕录制案例及源码
在浏览器中实现屏幕录制通常使用 MediaStream API 和 MediaRecorder API。下面是一个完整的示例,展示如何在Web页面中录制屏幕,并提供下载功能。
功能
- 选择屏幕或窗口进行录制
- 开始/暂停/停止录制
- 录制完成后可下载视频
核心技术
-
navigator.mediaDevices.getDisplayMedia():获取屏幕媒体流
-
MediaRecorder:录制流并保存为视频文件
界面截图:
案例源码:
<template>
<div class="rdp-recorder" :class="{ 'minimal-ui': minimalUI }">
<!-- 悬浮的最小化控制面板 - 适合无感录制 -->
<div v-if="minimalUI" class="floating-controls">
<div class="recording-status">
<span class="record-indicator" :class="{ paused: isPaused }"></span>
<span class="timer">{{ formatTime(recordingTime) }}</span>
</div>
<div class="button-group">
<button
v-if="isPaused"
class="icon-btn resume-btn"
title="继续录制"
@click="resumeRecording"
>
▶
</button>
<button
v-else-if="isRecording"
class="icon-btn pause-btn"
title="暂停录制"
@click="pauseRecording"
>
⏸
</button>
<button
v-if="isRecording"
class="icon-btn stop-btn"
title="停止录制"
@click="stopRecording"
>
⏹
</button>
</div>
<button class="icon-btn expand-btn" title="展开面板" @click="minimalUI = false">
↗
</button>
</div>
<!-- 完整控制面板 -->
<div v-else class="full-controls">
<div class="panel-header">
<h3>RDP会话录制</h3>
<button class="icon-btn minimize-btn" title="最小化" @click="minimalUI = true">
↙
</button>
</div>
<div class="controls">
<button
v-if="!isRecording"
:disabled="!hasSupport"
class="btn start-btn"
@click="startRDPRecording"
>
开始录制RDP会话
</button>
<template v-else>
<button
v-if="!isPaused"
class="btn pause-btn"
@click="pauseRecording"
>
暂停
</button>
<button
v-else
class="btn resume-btn"
@click="resumeRecording"
>
继续
</button>
<button
class="btn stop-btn"
@click="stopRecording"
>
停止录制
</button>
</template>
</div>
<div v-if="isRecording" class="recording-info">
<div class="recording-status">
<span class="record-indicator" :class="{ paused: isPaused }"></span>
<span>{{ isPaused ? '已暂停' : '正在录制RDP会话...' }}</span>
</div>
<div class="timer">{{ formatTime(recordingTime) }}</div>
</div>
<div v-if="recordingURL" class="preview">
<h3>录制预览</h3>
<video :src="recordingURL" controls width="100%"></video>
<button class="btn download-btn" @click="downloadRecording('rdp-recording.webm')">
下载录制文件
</button>
</div>
<div v-if="error" class="error">
录制错误: {{ error }}
</div>
<div v-if="!hasSupport" class="no-support">
您的浏览器不支持屏幕录制功能
</div>
<div class="settings">
<h4>录制设置</h4>
<div class="checkbox-group">
<label>
<input v-model="autoMinimize" type="checkbox" />
开始录制后自动最小化
</label>
</div>
<div class="checkbox-group">
<label>
<input v-model="recordAudio" type="checkbox" />
录制音频
</label>
</div>
<button class="btn auto-start-btn" @click="autoStartRecording">
自动选择RDP窗口录制
</button>
<p class="tip">提示: 自动选择功能需要您在弹出框中选择RDP窗口</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch } from 'vue';
import { useRDPScreenRecorder } from './useRDPScreenRecorder';
// 组件名称定义
defineOptions({
name: 'RDPRecorderComponent',
});
// 状态
const hasSupport = ref(false);
const minimalUI = ref(false);
const autoMinimize = ref(true);
const recordAudio = ref(true);
// 引入录屏功能
const {
isRecording,
isPaused,
recordingTime,
recordingURL,
error,
startRecording,
autoStartRDPRecording,
pauseRecording,
resumeRecording,
stopRecording,
downloadRecording,
} = useRDPScreenRecorder();
// 检查浏览器支持
onMounted(() => {
hasSupport.value = !!(navigator.mediaDevices &&
navigator.mediaDevices.getDisplayMedia);
});
// 在录制开始后自动最小化UI
watch(isRecording, (newValue) => {
if (newValue && autoMinimize.value) {
minimalUI.value = true;
}
});
// 格式化时间显示
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// 开始RDP会话录制
const startRDPRecording = () => {
startRecording({
videoConstraints: {
video: {
frameRate: { ideal: 15 },
cursor: 'always',
displaySurface: 'window', // 窗口模式,便于选择RDP窗口
logicalSurface: true,
width: { ideal: 1280 },
height: { ideal: 720 },
},
},
includeAudio: recordAudio.value,
captureApplicationAudio: recordAudio.value,
mimeType: 'video/webm;codecs=vp9',
});
};
// 自动开始RDP会话录制
const autoStartRecording = () => {
autoStartRDPRecording();
};
</script>
<style scoped>
.rdp-recorder {
position: relative;
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.floating-controls {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
align-items: center;
padding: 8px 12px;
background-color: rgba(33, 33, 33, 0.8);
border-radius: 50px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
z-index: 9999;
color: white;
}
.recording-status {
display: flex;
align-items: center;
gap: 8px;
}
.record-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #F44336;
animation: blink 1s infinite;
}
.record-indicator.paused {
background-color: #FFC107;
animation: none;
}
.button-group {
display: flex;
margin: 0 8px;
}
.icon-btn {
background-color: transparent;
border: none;
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin: 0 2px;
font-size: 14px;
}
.icon-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.minimize-btn, .expand-btn {
font-size: 12px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.panel-header h3 {
margin: 0;
}
.controls {
margin: 20px 0;
display: flex;
gap: 10px;
}
.btn {
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.3s;
}
.start-btn {
background-color: #4CAF50;
color: white;
}
.pause-btn {
background-color: #FFC107;
color: #333;
}
.resume-btn {
background-color: #2196F3;
color: white;
}
.stop-btn {
background-color: #F44336;
color: white;
}
.download-btn {
background-color: #673AB7;
color: white;
margin-top: 10px;
}
.auto-start-btn {
background-color: #2196F3;
color: white;
}
.btn:hover {
opacity: 0.9;
}
.btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.recording-info {
display: flex;
align-items: center;
justify-content: space-between;
margin: 20px 0;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
}
.timer {
font-family: monospace;
font-size: 1.2rem;
}
.floating-controls .timer {
font-size: 1rem;
}
.preview {
margin-top: 20px;
}
.error {
color: #F44336;
margin: 10px 0;
padding: 10px;
background-color: #FFEBEE;
border-radius: 4px;
}
.no-support {
color: #F44336;
margin: 10px 0;
padding: 10px;
background-color: #FFEBEE;
border-radius: 4px;
}
.settings {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 4px;
}
.settings h4 {
margin-top: 0;
margin-bottom: 15px;
}
.checkbox-group {
margin-bottom: 10px;
}
.tip {
font-size: 12px;
color: #666;
margin-top: 10px;
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
</style>
// useRDPScreenRecorder.ts
import { ref, onUnmounted } from 'vue';
export function useRDPScreenRecorder() {
const isRecording = ref<boolean>(false);
const isPaused = ref<boolean>(false);
const recordingTime = ref<number>(0);
const recordingURL = ref<string>('');
const error = ref<string | null>(null);
let mediaRecorder: MediaRecorder | null = null;
let recordedChunks: Blob[] = [];
let startTime = 0;
let timerInterval: number | null = null;
let combinedStream: MediaStream | null = null;
interface RecordingOptions {
videoConstraints?: MediaStreamConstraints;
audioConstraints?: MediaStreamConstraints;
mimeType?: string;
timeslice?: number;
includeAudio?: boolean;
captureApplicationAudio?: boolean;
}
// 开始录制屏幕和音频
const startRecording = async (options: RecordingOptions = {}) => {
const defaultOptions: RecordingOptions = {
videoConstraints: {
video: {
// 针对RDP窗口的优化设置
frameRate: { ideal: 15 }, // 降低帧率以减少RDP连接的负担
cursor: 'always', // 始终捕获光标
displaySurface: 'window', // 优先尝试捕获窗口而非整个屏幕
logicalSurface: true, // 捕获逻辑窗口表面而非物理显示
width: { ideal: 1920 }, // 设置理想分辨率
height: { ideal: 1080 },
},
},
audioConstraints: {
audio: {
// 音频设置优化
echoCancellation: true, // 回声消除
noiseSuppression: true, // 噪声抑制
autoGainControl: true, // 自动增益控制
},
},
mimeType: 'video/webm;codecs=vp9', // VP9编码器在低带宽环境下表现更好
timeslice: 1000, // 每秒获取一次数据
includeAudio: true, // 是否包含麦克风音频
captureApplicationAudio: true, // 是否尝试捕获应用程序音频
};
const config = { ...defaultOptions, ...options };
recordedChunks = [];
error.value = null;
try {
// 获取屏幕流,用户在选择对话框中应该选择RDP窗口
const screenStream = await navigator.mediaDevices.getDisplayMedia(config.videoConstraints);
// 处理录制结束
screenStream.getVideoTracks()[0].onended = () => {
stopRecording();
};
let streams = [screenStream];
// 尝试捕获系统音频(RDP会话中的声音)
// 注意:这依赖于浏览器和系统支持,可能不适用于所有环境
if (config.captureApplicationAudio) {
try {
// 在某些浏览器中,系统音频可以通过getDisplayMedia的audio选项获取
// 检查是否已经有音频轨道
const hasAudioTrack = screenStream.getAudioTracks().length > 0;
if (!hasAudioTrack) {
console.warn('无法直接捕获RDP会话的系统音频,这在某些浏览器中是正常现象');
}
} catch (err) {
console.warn('捕获系统音频失败:', err);
}
}
// 如果需要麦克风音频,获取麦克风流
if (config.includeAudio) {
try {
const audioStream = await navigator.mediaDevices.getUserMedia(config.audioConstraints);
streams.push(audioStream);
} catch (err) {
console.warn('无法获取麦克风权限,继续仅录制视频', err);
}
}
// 合并流
combinedStream = new MediaStream();
// 添加所有视频轨道
streams.forEach(stream => {
stream.getVideoTracks().forEach(track => {
combinedStream.addTrack(track);
});
});
// 添加所有音频轨道
streams.forEach(stream => {
stream.getAudioTracks().forEach(track => {
combinedStream.addTrack(track);
});
});
// 创建MediaRecorder,优化RDP场景的录制设置
const recorderOptions: MediaRecorderOptions = {};
if (config.mimeType) {
// 检查MIME类型支持
if (MediaRecorder.isTypeSupported(config.mimeType)) {
recorderOptions.mimeType = config.mimeType;
} else if (MediaRecorder.isTypeSupported('video/webm')) {
recorderOptions.mimeType = 'video/webm'; // 降级
}
}
// 可以添加录制质量设置
// 对于RDP场景,可以考虑降低视频比特率以减少资源占用
// 初始化录制器
mediaRecorder = new MediaRecorder(combinedStream, recorderOptions);
// 处理数据可用事件
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
// 处理录制结束事件
mediaRecorder.onstop = () => {
const mimeType = mediaRecorder?.mimeType || 'video/webm';
const blob = new Blob(recordedChunks, { type: mimeType });
recordingURL.value = URL.createObjectURL(blob);
isRecording.value = false;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
};
// 开始录制
mediaRecorder.start(config.timeslice);
isRecording.value = true;
isPaused.value = false;
startTime = Date.now();
// 启动计时器
timerInterval = window.setInterval(() => {
recordingTime.value = Math.floor((Date.now() - startTime) / 1000);
}, 1000);
} catch (err: any) {
error.value = err.message || '录制失败';
console.error('录制失败:', err);
}
};
// 自动开始录制RDP窗口(无感知方式)
const autoStartRDPRecording = async () => {
try {
// 尝试自动开始录制,但需要用户交互触发
// 建议在用户点击页面其他地方或进行其他交互后调用
await startRecording({
videoConstraints: {
video: {
// 针对RDP会话的优化设置
frameRate: { ideal: 10 }, // 降低帧率可减轻网络负担
cursor: 'always',
displaySurface: 'window', // 设置为窗口模式,方便用户选择RDP窗口
logicalSurface: true,
// 减少分辨率可以降低CPU使用率
width: { ideal: 1280 },
height: { ideal: 720 },
},
},
// 降低录制频率,减少资源占用
timeslice: 2000,
captureApplicationAudio: true,
});
console.log('RDP录制已自动开始,请在弹出的对话框中选择RDP窗口');
} catch (err) {
console.error('自动开始RDP录制失败:', err);
}
};
// 暂停录制
const pauseRecording = () => {
if (mediaRecorder && isRecording.value && !isPaused.value && mediaRecorder.state === 'recording') {
mediaRecorder.pause();
isPaused.value = true;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
};
// 继续录制
const resumeRecording = () => {
if (mediaRecorder && isRecording.value && isPaused.value && mediaRecorder.state === 'paused') {
mediaRecorder.resume();
isPaused.value = false;
// 重启计时器
timerInterval = window.setInterval(() => {
recordingTime.value = Math.floor((Date.now() - startTime) / 1000);
}, 1000);
}
};
// 停止录制
const stopRecording = () => {
if (mediaRecorder && isRecording.value &&
(mediaRecorder.state === 'recording' || mediaRecorder.state === 'paused')) {
mediaRecorder.stop();
// 停止所有轨道
if (combinedStream) {
combinedStream.getTracks().forEach(track => track.stop());
}
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
};
// 下载录制内容
const downloadRecording = (filename = 'rdp-recording.webm') => {
if (recordingURL.value) {
const a = document.createElement('a');
document.body.appendChild(a);
a.style.display = 'none';
a.href = recordingURL.value;
a.download = filename;
a.click();
document.body.removeChild(a);
}
};
// 清理函数
onUnmounted(() => {
stopRecording();
if (recordingURL.value) {
URL.revokeObjectURL(recordingURL.value);
}
});
return {
isRecording,
isPaused,
recordingTime,
recordingURL,
error,
startRecording,
autoStartRDPRecording,
pauseRecording,
resumeRecording,
stopRecording,
downloadRecording,
};
}