|
@@ -1,216 +1,274 @@
|
|
|
<template>
|
|
|
- <div class="recorder-mask" v-show="props.show">
|
|
|
+ <div class="recorder-mask" v-show="props.show">
|
|
|
+ <!-- 录制模式 -->
|
|
|
+ <template v-if="!previewUrl">
|
|
|
<video ref="videoEl" autoplay playsinline class="video-preview"></video>
|
|
|
-
|
|
|
+
|
|
|
<div class="controls">
|
|
|
<div class="timer-style">
|
|
|
<div>长按开始录制</div>
|
|
|
<div>{{ recordTime }}s</div>
|
|
|
</div>
|
|
|
- <div class="record-btn"
|
|
|
- @mousedown.prevent="startRecorderVideo"
|
|
|
- @mouseup.prevent="stopRecorderVideo"
|
|
|
- @touchstart.prevent="startRecorderVideo"
|
|
|
- @touchend.prevent="stopRecorderVideo">
|
|
|
+ <div
|
|
|
+ class="record-btn"
|
|
|
+ @mousedown.prevent="startRecorderVideo"
|
|
|
+ @mouseup.prevent="stopRecorderVideo"
|
|
|
+ @touchstart.prevent="startRecorderVideo"
|
|
|
+ @touchend.prevent="stopRecorderVideo"
|
|
|
+ >
|
|
|
<div class="inner-circle" :class="{ recording }"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
-
|
|
|
- <button class="close-btn" @click="closeRecorder">×</button>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
-
|
|
|
- <script setup>
|
|
|
-
|
|
|
-
|
|
|
- const emit = defineEmits(['close', 'sendVideo']);
|
|
|
- const props = defineProps({
|
|
|
- show: Boolean
|
|
|
- })
|
|
|
- watch(
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 预览模式 -->
|
|
|
+ <template v-else>
|
|
|
+ <div class="preview-box">
|
|
|
+ <video
|
|
|
+ :src="previewUrl"
|
|
|
+ controls
|
|
|
+ autoplay
|
|
|
+ playsinline
|
|
|
+ class="video-preview preview">
|
|
|
+ </video>
|
|
|
+ <!-- 悬浮按钮 -->
|
|
|
+ <div class="preview-actions">
|
|
|
+ <button class="cancel-btn" @click="redoRecorder">重新录制</button>
|
|
|
+ <button class="send-btn" @click="confirmSend">发送</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <button class="close-btn" @click="closeRecorder">×</button>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+const emit = defineEmits(['closeVideo', 'sendVideo']);
|
|
|
+const props = defineProps({
|
|
|
+ show: Boolean,
|
|
|
+});
|
|
|
+
|
|
|
+watch(
|
|
|
() => props.show,
|
|
|
- (newVal,oldVal) => {
|
|
|
+ (newVal) => {
|
|
|
if (newVal) {
|
|
|
- // setTimeout(() => {
|
|
|
- // openVideo();
|
|
|
- // }, 200);
|
|
|
openVideo();
|
|
|
- }else{
|
|
|
-
|
|
|
}
|
|
|
}
|
|
|
);
|
|
|
- const videoEl = ref(null);
|
|
|
- const recorderRef = ref(null);
|
|
|
- const recordTime = ref(0);
|
|
|
- const recording = ref(false);
|
|
|
- let timer = null;
|
|
|
- let chunks = [];
|
|
|
-
|
|
|
- const startRecorderVideo = () => {
|
|
|
- if (recorderRef.value) {
|
|
|
- chunks = [];
|
|
|
- recorderRef.value.start();
|
|
|
- recording.value = true;
|
|
|
- recordTime.value = 0;
|
|
|
- timer = setInterval(() => recordTime.value++, 1000);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const stopRecorderVideo = () => {
|
|
|
- if (recorderRef.value && recording.value) {
|
|
|
- recorderRef.value.stop();
|
|
|
- recording.value = false;
|
|
|
- clearInterval(timer);
|
|
|
-
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const closeRecorder = () => {
|
|
|
- stopRecorderVideo();
|
|
|
- if (videoEl.value && videoEl.value.srcObject) {
|
|
|
- videoEl.value.srcObject.getTracks().forEach(track => track.stop());
|
|
|
- }
|
|
|
- emit('closeVideo');
|
|
|
- };
|
|
|
-
|
|
|
- const openVideo = async () => {
|
|
|
- recordTime.value = 0;
|
|
|
- const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
|
|
|
- videoEl.value.srcObject = stream;
|
|
|
-
|
|
|
- // 创建 canvas
|
|
|
- const canvas = document.createElement("canvas");
|
|
|
- const ctx = canvas.getContext("2d");
|
|
|
-
|
|
|
- // 设置 canvas 尺寸
|
|
|
- const [track] = stream.getVideoTracks();
|
|
|
- const settings = track.getSettings();
|
|
|
- canvas.width = settings.width || 640;
|
|
|
- canvas.height = settings.height || 480;
|
|
|
-
|
|
|
- // 取视频第一帧
|
|
|
- const frame = [];
|
|
|
-
|
|
|
- // 镜像绘制(左右翻转)
|
|
|
- function drawFrame() {
|
|
|
- ctx.save();
|
|
|
- ctx.scale(-1, 1); // 横向翻转
|
|
|
- ctx.drawImage(videoEl.value, -canvas.width, 0, canvas.width, canvas.height);
|
|
|
- if (recording.value && frame.length === 0){
|
|
|
- canvas.toBlob(blob=>{
|
|
|
- console.log(blob)
|
|
|
- frame.push(blob);
|
|
|
- },"image/png")
|
|
|
- }
|
|
|
- ctx.restore();
|
|
|
- requestAnimationFrame(drawFrame);
|
|
|
- }
|
|
|
- drawFrame();
|
|
|
-
|
|
|
- // 用 canvas 生成新的 video track
|
|
|
- const canvasStream = canvas.captureStream(30); // 30fps
|
|
|
- // const audioTrack = stream.getAudioTracks()[0];
|
|
|
- // if (audioTrack) {
|
|
|
- // canvasStream.addTrack(audioTrack);
|
|
|
- // }
|
|
|
-
|
|
|
- const options = { mimeType: 'video/webm;codecs=vp8,opus' };
|
|
|
- const recorder = new MediaRecorder(canvasStream, options);
|
|
|
- recorderRef.value = recorder;
|
|
|
-
|
|
|
- recorder.ondataavailable = (e) => {
|
|
|
- if (e.data && e.data.size > 0) chunks.push(e.data);
|
|
|
- };
|
|
|
-
|
|
|
- recorder.onstop = () => {
|
|
|
- const blob = new Blob(chunks, { type: 'video/webm' });
|
|
|
- if(recordTime.value < 1){
|
|
|
- recordTime.value = 0;
|
|
|
- return;
|
|
|
- }
|
|
|
- videoEl.value.srcObject.getTracks().forEach(track => track.stop());
|
|
|
- emit('closeVideo');
|
|
|
- emit('sendVideo', blob);
|
|
|
- };
|
|
|
- }
|
|
|
- // onBeforeUnmount(() => {
|
|
|
- // clearInterval(timer);
|
|
|
- // if (videoEl.value && videoEl.value.srcObject) {
|
|
|
- // videoEl.value.srcObject.getTracks().forEach(track => track.stop());
|
|
|
- // }
|
|
|
- // });
|
|
|
- </script>
|
|
|
-
|
|
|
- <style scoped>
|
|
|
- .recorder-mask {
|
|
|
- position: fixed;
|
|
|
- top: 0; left: 0;
|
|
|
- width: 100%; height: 100%;
|
|
|
- background-color: rgba(0,0,0,0.8);
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- justify-content: flex-end;
|
|
|
- align-items: center;
|
|
|
- padding-bottom: 50px;
|
|
|
- z-index: 999;
|
|
|
- }
|
|
|
-
|
|
|
- .video-preview {
|
|
|
- position: absolute;
|
|
|
- top: 0; left: 0;
|
|
|
- width: 100%; height: 100%;
|
|
|
- object-fit: cover;
|
|
|
- z-index: 0;
|
|
|
- }
|
|
|
-
|
|
|
- .controls {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- align-items: center;
|
|
|
- z-index: 99;
|
|
|
- }
|
|
|
-
|
|
|
- .record-btn {
|
|
|
- width: 70px;
|
|
|
- height: 70px;
|
|
|
- border-radius: 50%;
|
|
|
- border: 3px solid white;
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- align-items: center;
|
|
|
- margin-bottom: 8px;
|
|
|
- cursor: pointer;
|
|
|
+
|
|
|
+const videoEl = ref(null);
|
|
|
+const recorderRef = ref(null);
|
|
|
+const recordTime = ref(0);
|
|
|
+const recording = ref(false);
|
|
|
+let timer = null;
|
|
|
+let chunks = [];
|
|
|
+let lastBlob = null;
|
|
|
+const previewUrl = ref(null);
|
|
|
+
|
|
|
+const startRecorderVideo = () => {
|
|
|
+ if (recorderRef.value) {
|
|
|
+ chunks = [];
|
|
|
+ recorderRef.value.start();
|
|
|
+ recording.value = true;
|
|
|
+ recordTime.value = 0;
|
|
|
+ timer = setInterval(() => recordTime.value++, 1000);
|
|
|
}
|
|
|
-
|
|
|
- .inner-circle {
|
|
|
- width: 50px;
|
|
|
- height: 50px;
|
|
|
- border-radius: 50%;
|
|
|
- background: white;
|
|
|
- transition: all 0.2s;
|
|
|
+};
|
|
|
+
|
|
|
+const stopRecorderVideo = () => {
|
|
|
+ if (recorderRef.value && recording.value) {
|
|
|
+ recorderRef.value.stop();
|
|
|
+ recording.value = false;
|
|
|
+ clearInterval(timer);
|
|
|
}
|
|
|
-
|
|
|
- .inner-circle.recording {
|
|
|
- background: red;
|
|
|
- transform: scale(0.8);
|
|
|
+};
|
|
|
+
|
|
|
+const closeRecorder = () => {
|
|
|
+ stopRecorderVideo();
|
|
|
+ if (videoEl.value && videoEl.value.srcObject) {
|
|
|
+ videoEl.value.srcObject.getTracks().forEach((track) => track.stop());
|
|
|
}
|
|
|
-
|
|
|
- .timer-style {
|
|
|
- color: white;
|
|
|
- font-size: 16px;
|
|
|
- margin-bottom: 16px;
|
|
|
- text-align: center;
|
|
|
+ previewUrl.value = null;
|
|
|
+ emit('closeVideo');
|
|
|
+};
|
|
|
+
|
|
|
+const redoRecorder = () => {
|
|
|
+ previewUrl.value = null;
|
|
|
+ lastBlob = null;
|
|
|
+ openVideo();
|
|
|
+};
|
|
|
+
|
|
|
+const confirmSend = () => {
|
|
|
+ if (lastBlob) {
|
|
|
+ emit('sendVideo', lastBlob);
|
|
|
+ closeRecorder();
|
|
|
}
|
|
|
-
|
|
|
- .close-btn {
|
|
|
- position: absolute;
|
|
|
- top: 40px; right: 20px;
|
|
|
- font-size: 28px;
|
|
|
- color: white;
|
|
|
- background: transparent;
|
|
|
- border: none;
|
|
|
- cursor: pointer;
|
|
|
+};
|
|
|
+
|
|
|
+const openVideo = async () => {
|
|
|
+ recordTime.value = 0;
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
+ video: { facingMode: 'environment' },
|
|
|
+ audio: false,
|
|
|
+ });
|
|
|
+ videoEl.value.srcObject = stream;
|
|
|
+
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ const [track] = stream.getVideoTracks();
|
|
|
+ const settings = track.getSettings();
|
|
|
+ canvas.width = settings.width || 640;
|
|
|
+ canvas.height = settings.height || 480;
|
|
|
+
|
|
|
+ function drawFrame() {
|
|
|
+ ctx.save();
|
|
|
+ ctx.scale(-1, 1);
|
|
|
+ ctx.drawImage(videoEl.value, -canvas.width, 0, canvas.width, canvas.height);
|
|
|
+ ctx.restore();
|
|
|
+ requestAnimationFrame(drawFrame);
|
|
|
}
|
|
|
- </style>
|
|
|
-
|
|
|
+ drawFrame();
|
|
|
+
|
|
|
+ const canvasStream = canvas.captureStream(30);
|
|
|
+ const options = { mimeType: 'video/webm;codecs=vp8,opus' };
|
|
|
+ const recorder = new MediaRecorder(canvasStream, options);
|
|
|
+ recorderRef.value = recorder;
|
|
|
+
|
|
|
+ recorder.ondataavailable = (e) => {
|
|
|
+ if (e.data && e.data.size > 0) chunks.push(e.data);
|
|
|
+ };
|
|
|
+
|
|
|
+ recorder.onstop = () => {
|
|
|
+ if (recordTime.value < 1) {
|
|
|
+ recordTime.value = 0;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const blob = new Blob(chunks, { type: 'video/webm' });
|
|
|
+ lastBlob = blob;
|
|
|
+ previewUrl.value = URL.createObjectURL(blob);
|
|
|
+ videoEl.value.srcObject?.getTracks().forEach((track) => track.stop());
|
|
|
+ };
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.recorder-mask {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ background-color: rgba(0, 0, 0, 0.8);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: flex-end;
|
|
|
+ align-items: center;
|
|
|
+ padding-bottom: 50px;
|
|
|
+ z-index: 999;
|
|
|
+}
|
|
|
+.video-preview {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+ z-index: 0;
|
|
|
+}
|
|
|
+.controls,
|
|
|
+.preview-controls {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ z-index: 99;
|
|
|
+}
|
|
|
+.preview-controls {
|
|
|
+ flex-direction: row;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+.record-btn {
|
|
|
+ width: 70px;
|
|
|
+ height: 70px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 3px solid white;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.inner-circle {
|
|
|
+ width: 50px;
|
|
|
+ height: 50px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: white;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+.inner-circle.recording {
|
|
|
+ background: red;
|
|
|
+ transform: scale(0.8);
|
|
|
+}
|
|
|
+.timer-style {
|
|
|
+ color: white;
|
|
|
+ font-size: 16px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.close-btn {
|
|
|
+ position: absolute;
|
|
|
+ top: 40px;
|
|
|
+ right: 20px;
|
|
|
+ font-size: 28px;
|
|
|
+ color: white;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.preview-box {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.video-preview.preview {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+ background: black;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-actions {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 120px;
|
|
|
+ /* left: 50%;
|
|
|
+ transform: translateX(-50%); */
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 30px;
|
|
|
+ z-index: 20;
|
|
|
+}
|
|
|
+
|
|
|
+.cancel-btn,
|
|
|
+.send-btn {
|
|
|
+ padding: 8px 22px;
|
|
|
+ border-radius: 20px;
|
|
|
+ font-size: 16px;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.cancel-btn {
|
|
|
+ background: rgb(255, 255, 255);
|
|
|
+ /* color: white; */
|
|
|
+}
|
|
|
+
|
|
|
+.send-btn {
|
|
|
+ background: #4765dd;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+</style>
|