|
|
@@ -0,0 +1,435 @@
|
|
|
+<template>
|
|
|
+ <teleport :to="appEl" v-if="appEl">
|
|
|
+ <Toolbar :showBottomBar="showBottomBar" class="shot-ctrl">
|
|
|
+ <el-button type="primary" class="btn" @click="close">取消</el-button>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ class="btn"
|
|
|
+ :class="{ disabled: blobs.length === 0 }"
|
|
|
+ @click="complete"
|
|
|
+ >合并视频</el-button
|
|
|
+ >
|
|
|
+ <div :style="{ bottom: barHeight }" class="other">
|
|
|
+ <span @click="start" class="other-icon" style="color: #000;"><i class="iconfont icon-record" />继续录制</span>
|
|
|
+ </div>
|
|
|
+ <div class="video-list" v-if="videoList.length">
|
|
|
+ <div class="shot-layout" :style="{ width: `${videoList.length * 130}px` }">
|
|
|
+ <div v-for="video in videoList" :key="video.cover" class="cover">
|
|
|
+ <img :src="video.cover" />
|
|
|
+ <el-icon color="#fff" class="preview" @click="openVideo(video.origin)"><VideoPlay /></el-icon>
|
|
|
+ <!-- <ui-icon
|
|
|
+ type="preview"
|
|
|
+ ctrl
|
|
|
+ class="preview"
|
|
|
+ @click="palyUrl = video.origin"
|
|
|
+ /> -->
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Toolbar>
|
|
|
+ <!-- <Preview
|
|
|
+ v-if="palyUrl"
|
|
|
+ :items="[{ type: MetaType.video, url: palyUrl }]"
|
|
|
+ @close="palyUrl = null"
|
|
|
+ /> -->
|
|
|
+ </teleport>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import {
|
|
|
+ ref,
|
|
|
+ defineComponent,
|
|
|
+ onUnmounted,
|
|
|
+ watch,
|
|
|
+ shallowReactive,
|
|
|
+ computed,
|
|
|
+ watchEffect,
|
|
|
+} from "vue";
|
|
|
+import Toolbar from "./Toolbar.vue";
|
|
|
+import { ElMessage } from "element-plus";
|
|
|
+// import { VideoRecorder as videorecorder } from "simaqcore";
|
|
|
+// import { VideoRecorder } from "@/util/simaqcore";
|
|
|
+import { VideoRecorder } from "simaqcore";
|
|
|
+import { getVideoCover } from "@/util/video-cover";
|
|
|
+import { getRecordFragmentBlobs, appEl, getResource } from "@/store/system";
|
|
|
+
|
|
|
+type VideoItem = { origin: Blob | string; cover: string };
|
|
|
+const props = defineProps<{ record?: any }>();
|
|
|
+const emit = defineEmits<{
|
|
|
+ 'append': [blobs: Blob[]],
|
|
|
+ 'updateCover': [cover: any],
|
|
|
+ 'close': [value: any],
|
|
|
+ 'preview': [value: any],
|
|
|
+ 'deleteRecord': [value: any],
|
|
|
+ 'playVideo': [value: any],
|
|
|
+}>()
|
|
|
+const isFirefox = /firefox/i.test(navigator.userAgent);
|
|
|
+
|
|
|
+function getSupportedMimeType() {
|
|
|
+ const firefoxMimeTypes = [
|
|
|
+ 'video/webm;codecs=vp8,opus',
|
|
|
+ 'video/webm'
|
|
|
+ ];
|
|
|
+ const defaultMimeTypes = [
|
|
|
+ 'video/webm;codecs=vp9,opus',
|
|
|
+ 'video/webm;codecs=vp8,opus',
|
|
|
+ 'video/webm',
|
|
|
+ 'video/mp4'
|
|
|
+ ];
|
|
|
+ const mimeTypes = isFirefox ? firefoxMimeTypes : defaultMimeTypes;
|
|
|
+
|
|
|
+ for (const type of mimeTypes) {
|
|
|
+ if (MediaRecorder && MediaRecorder.isTypeSupported(type)) {
|
|
|
+ return type;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+const config: any = {
|
|
|
+ uploadUrl: "",
|
|
|
+ resolution: "4k",
|
|
|
+ debug: false,
|
|
|
+};
|
|
|
+const MetaType = 'video';
|
|
|
+
|
|
|
+const videoRecorder = new VideoRecorder(config);
|
|
|
+videoRecorder.codecType = 'video/webm;codecs=vp8,opus';
|
|
|
+videoRecorder._codecType = 'video/webm;codecs=vp8,opus';
|
|
|
+let showBottomBar = ref(false);
|
|
|
+const MAX_SIZE = 2 * 1024 * 1024 * 1024;
|
|
|
+const MAX_TIME = 30 * 60 * 1000;
|
|
|
+const previewVisible = ref(false);
|
|
|
+
|
|
|
+const countdown = ref(0);
|
|
|
+let interval: NodeJS.Timer;
|
|
|
+let recordIng = ref(false);
|
|
|
+ const start = () => {
|
|
|
+ // 3. 检测兼容的编码格式
|
|
|
+ const mimeType = getSupportedMimeType();
|
|
|
+ if (!mimeType) {
|
|
|
+ throw new Error('当前设备不支持任何视频录制格式');
|
|
|
+ }
|
|
|
+ if (size.value > MAX_SIZE || pauseTime.value < 2000) {
|
|
|
+ return ElMessage.warning("已超出限制大小无法继续录制,可保存后继续录制!");
|
|
|
+ }
|
|
|
+
|
|
|
+ showBottomBar.value = false;
|
|
|
+ countdown.value = 2;
|
|
|
+ const timeiffe = () => {
|
|
|
+ if (--countdown.value <= 0) {
|
|
|
+ clearInterval(interval);
|
|
|
+ videoRecorder.startRecord();
|
|
|
+ recordIng.value = true;
|
|
|
+ } else {
|
|
|
+ interval = setTimeout(timeiffe, 300);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ timeiffe();
|
|
|
+};
|
|
|
+
|
|
|
+const pause = () => {
|
|
|
+ console.log('appEl:', appEl.value)
|
|
|
+ if (countdown.value === 0 && recordIng.value) {
|
|
|
+ videoRecorder.endRecord();
|
|
|
+ recordIng.value = false;
|
|
|
+ }
|
|
|
+ countdown.value = 0;
|
|
|
+ showBottomBar.value = true;
|
|
|
+ console.log(showBottomBar.value, 444)
|
|
|
+ clearInterval(interval);
|
|
|
+};
|
|
|
+
|
|
|
+watch(recordIng, (_n, _o, onCleanup) => {
|
|
|
+ if (recordIng.value) {
|
|
|
+ const timeout = setTimeout(() => videoRecorder.endRecord(), pauseTime.value);
|
|
|
+ onCleanup(() => clearTimeout(timeout));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+const blobs: File[] = shallowReactive([]);
|
|
|
+const size = computed(() => {
|
|
|
+ console.log('videoList:', videoList);
|
|
|
+ return videoList.reduce(
|
|
|
+ (t, f) => (typeof f.origin === "string" ? t : t + f.origin.size),
|
|
|
+ 0
|
|
|
+ );
|
|
|
+});
|
|
|
+const pauseTime = computed(() => (MAX_TIME / MAX_SIZE) * (MAX_SIZE - size.value));
|
|
|
+videoRecorder.off("*");
|
|
|
+videoRecorder.on("record", (blob) => {
|
|
|
+ console.log('videoList:', blob);
|
|
|
+ if (recordIng.value) {
|
|
|
+ blobs.push(new File([blob], "录屏.mp4", { type: "video/mp4; codecs=h264" }));
|
|
|
+ }
|
|
|
+});
|
|
|
+videoRecorder.on("cancelRecord", pause);
|
|
|
+videoRecorder.on("endRecord", pause);
|
|
|
+
|
|
|
+const videoList: any = [];
|
|
|
+let initial = false;
|
|
|
+watch([blobs, props], async () => {
|
|
|
+ const existsVideos = [];
|
|
|
+ if (props.record.url) {
|
|
|
+ existsVideos.push(getResource(props.record.url));
|
|
|
+ }
|
|
|
+ const fragmentBlobs = getRecordFragmentBlobs(props.record);
|
|
|
+ existsVideos.push(...fragmentBlobs, ...blobs);
|
|
|
+ for (const blob of existsVideos) {
|
|
|
+ if (videoList.some((item) => item.origin === blob)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const cover = await getVideoCover(blob, 3, 120, 80);
|
|
|
+ videoList.push({ origin: blob, cover });
|
|
|
+ }
|
|
|
+ for (let i = 0; i < videoList.length; i++) {
|
|
|
+ if (!existsVideos.some((blob) => videoList[i].origin === blob)) {
|
|
|
+ videoList.splice(i--, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!props.record.cover && videoList.length) {
|
|
|
+ emit("updateCover", videoList[0].cover);
|
|
|
+ }
|
|
|
+ if (!initial) {
|
|
|
+ initial = true;
|
|
|
+ start();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+const openVideo = (url: string | Blob) => {
|
|
|
+ const playUrl = typeof url === "string"
|
|
|
+ ? getResource(url)
|
|
|
+ : URL.createObjectURL(url)
|
|
|
+ emit('playVideo', playUrl)
|
|
|
+}
|
|
|
+
|
|
|
+const upHandler = (ev: KeyboardEvent) => ev.code === `Escape` && videoRecorder.endRecord();
|
|
|
+document.body.addEventListener("keyup", upHandler, { capture: true });
|
|
|
+
|
|
|
+const complete = () => {
|
|
|
+ emit("append", blobs);
|
|
|
+ close();
|
|
|
+};
|
|
|
+const close = () => {
|
|
|
+ pause();
|
|
|
+ emit("close");
|
|
|
+};
|
|
|
+const barHeight = computed(() => (videoList.length ? "180px" : "60px"));
|
|
|
+onUnmounted(() => {
|
|
|
+ document.body.removeEventListener("keyup", upHandler, { capture: true });
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+
|
|
|
+.btns {
|
|
|
+ display: flex;
|
|
|
+
|
|
|
+ .unit,
|
|
|
+ .start {
|
|
|
+ height: 38px;
|
|
|
+ }
|
|
|
+ .unit {
|
|
|
+ flex: none;
|
|
|
+ margin-left: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .start {
|
|
|
+ flex: 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.tree {
|
|
|
+ margin-top: 20px;
|
|
|
+ padding-bottom: 100px;
|
|
|
+}
|
|
|
+
|
|
|
+.header-btns {
|
|
|
+ margin: 0 -20px;
|
|
|
+ padding: 0 20px 20px;
|
|
|
+ border-bottom: 1px solid rgba(255,255,255,0.1600);;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.sign {
|
|
|
+ padding: 20px 0;
|
|
|
+ border-top: 1px solid rgba(255,255,255,0.1600);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 0 !important;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ border-bottom: 1px solid rgba(255,255,255,0.1600);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.content {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .cover {
|
|
|
+ display: flex;
|
|
|
+ position: relative;
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #fff;
|
|
|
+ font-size: 16px;
|
|
|
+ margin-right: 10px;
|
|
|
+ flex: none;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ img,
|
|
|
+ &::before {
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ top: 0;
|
|
|
+ bottom: 0;
|
|
|
+ right: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ &::before{
|
|
|
+ content: '';
|
|
|
+ z-index: 2;
|
|
|
+ background: rgba(0,0,0,0.5);
|
|
|
+ }
|
|
|
+
|
|
|
+ img {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 1;
|
|
|
+ left: 0;
|
|
|
+ top: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .preview {
|
|
|
+ position: relative;
|
|
|
+ z-index: 3;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .title {
|
|
|
+ p {
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: rgba(255,255,255,0.4000);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.action {
|
|
|
+ color: #fff;
|
|
|
+ font-size: 14px;
|
|
|
+ flex: none;
|
|
|
+ margin-left: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.countdown {
|
|
|
+ font-size: 14px;
|
|
|
+ color: rgba(255,255,255,0.6);
|
|
|
+ background-color: rgba(27, 27, 28, .8);
|
|
|
+ position: absolute;
|
|
|
+ z-index: 99;
|
|
|
+ left: 50%;
|
|
|
+ top: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ padding: 30px 60px;
|
|
|
+ pointer-events: none;
|
|
|
+
|
|
|
+ p:not(:last-child) {
|
|
|
+ margin-bottom: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .title {
|
|
|
+ color: #fff;
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 32px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #00C8AF;
|
|
|
+ margin-right: 14px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.shot-ctrl {
|
|
|
+ z-index: 2;
|
|
|
+ .btn {
|
|
|
+ flex: none;
|
|
|
+ width: 160px;
|
|
|
+
|
|
|
+ &:not(:last-child) {
|
|
|
+ margin-right: 20px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .other {
|
|
|
+ width: 100vw;
|
|
|
+ height: 56px;
|
|
|
+ position: absolute;
|
|
|
+ bottom: calc(100% + 120px);
|
|
|
+ box-shadow: 0px -6px 12px -8px rgba(0, 0, 0, 0.2);
|
|
|
+ left: 0;
|
|
|
+ background-color: #fff;
|
|
|
+ .other-icon{
|
|
|
+ display: flex;
|
|
|
+ height: 100%;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #000;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.video-list {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 100%;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 120px;
|
|
|
+ overflow-x: auto;
|
|
|
+ background-color: #fff;
|
|
|
+ border-bottom: 1px solid #E6E6E6;
|
|
|
+ border-top: 1px solid #E6E6E6;
|
|
|
+
|
|
|
+ .shot-layout {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 100%;
|
|
|
+ justify-content: space-around;
|
|
|
+ }
|
|
|
+
|
|
|
+ .cover {
|
|
|
+ height: 80px;
|
|
|
+ position: relative;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ &::before {
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ top: 0;
|
|
|
+ bottom: 0;
|
|
|
+ right: 0;
|
|
|
+ content: '';
|
|
|
+ z-index: 2;
|
|
|
+ background: rgba(0,0,0,0.3);
|
|
|
+ }
|
|
|
+ .preview {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 3;
|
|
|
+ left: 50%;
|
|
|
+ top: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ font-size: 22px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|