mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 添加视频元素
This commit is contained in:
parent
cd35804313
commit
b0fffe5e06
@ -5,6 +5,7 @@ export const ELEMENT_TYPE_ZH = {
|
||||
line: '线条',
|
||||
chart: '图表',
|
||||
table: '表格',
|
||||
video: '视频',
|
||||
}
|
||||
|
||||
export const MIN_SIZE = {
|
||||
@ -13,4 +14,5 @@ export const MIN_SIZE = {
|
||||
shape: 15,
|
||||
chart: 200,
|
||||
table: 20,
|
||||
video: 250,
|
||||
}
|
@ -222,6 +222,22 @@ export default () => {
|
||||
if (data.isCurve) newElement.curve = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
||||
createElement(newElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建视频元素
|
||||
* @param src 视频地址
|
||||
*/
|
||||
const createVideoElement = (src: string) => {
|
||||
createElement({
|
||||
type: 'video',
|
||||
id: createRandomCode(),
|
||||
width: 500,
|
||||
height: 300,
|
||||
left: (VIEWPORT_SIZE - 500) / 2,
|
||||
top: (VIEWPORT_SIZE * viewportRatio.value - 300) / 2,
|
||||
src,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
createImageElement,
|
||||
@ -230,5 +246,6 @@ export default () => {
|
||||
createTextElement,
|
||||
createShapeElement,
|
||||
createLineElement,
|
||||
createVideoElement,
|
||||
}
|
||||
}
|
@ -84,6 +84,12 @@ import {
|
||||
AlignTextTopOne,
|
||||
AlignTextBottomOne,
|
||||
AlignTextMiddleOne,
|
||||
Pause,
|
||||
VolumeMute,
|
||||
VolumeNotice,
|
||||
VolumeSmall,
|
||||
LoopOnce,
|
||||
VideoTwo,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default {
|
||||
@ -95,6 +101,7 @@ export default {
|
||||
app.component('IconConnection', Connection)
|
||||
app.component('IconChartProportion', ChartProportion)
|
||||
app.component('IconInsertTable', InsertTable)
|
||||
app.component('IconVideoTwo', VideoTwo)
|
||||
|
||||
// 锁定与解锁
|
||||
app.component('IconLock', Lock)
|
||||
@ -195,5 +202,12 @@ export default {
|
||||
app.component('IconLogout', Logout)
|
||||
app.component('IconClear', Clear)
|
||||
app.component('IconFolderClose', FolderClose)
|
||||
|
||||
// 视频播放器
|
||||
app.component('IconPause', Pause)
|
||||
app.component('IconVolumeMute', VolumeMute)
|
||||
app.component('IconVolumeNotice', VolumeNotice)
|
||||
app.component('IconVolumeSmall', VolumeSmall)
|
||||
app.component('IconLoopOnce', LoopOnce)
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ export const enum ElementTypes {
|
||||
LINE = 'line',
|
||||
CHART = 'chart',
|
||||
TABLE = 'table',
|
||||
VIDEO = 'video',
|
||||
}
|
||||
|
||||
/**
|
||||
@ -461,7 +462,23 @@ export interface PPTTableElement extends PPTBaseElement {
|
||||
}
|
||||
|
||||
|
||||
export type PPTElement = PPTTextElement | PPTImageElement | PPTShapeElement | PPTLineElement | PPTChartElement | PPTTableElement
|
||||
/**
|
||||
* 视频元素
|
||||
*
|
||||
* type: 元素类型(video)
|
||||
*
|
||||
* src: 视频地址
|
||||
*
|
||||
* poster: 预览封面
|
||||
*/
|
||||
export interface PPTVideoElement extends PPTBaseElement {
|
||||
type: 'video';
|
||||
src: string;
|
||||
poster?: string;
|
||||
}
|
||||
|
||||
|
||||
export type PPTElement = PPTTextElement | PPTImageElement | PPTShapeElement | PPTLineElement | PPTChartElement | PPTTableElement | PPTVideoElement
|
||||
|
||||
|
||||
/**
|
||||
|
@ -37,6 +37,7 @@ import ShapeElement from '@/views/components/element/ShapeElement/index.vue'
|
||||
import LineElement from '@/views/components/element/LineElement/index.vue'
|
||||
import ChartElement from '@/views/components/element/ChartElement/index.vue'
|
||||
import TableElement from '@/views/components/element/TableElement/index.vue'
|
||||
import VideoElement from '@/views/components/element/VideoElement/index.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'editable-element',
|
||||
@ -71,6 +72,7 @@ export default defineComponent({
|
||||
[ElementTypes.LINE]: LineElement,
|
||||
[ElementTypes.CHART]: ChartElement,
|
||||
[ElementTypes.TABLE]: TableElement,
|
||||
[ElementTypes.VIDEO]: VideoElement,
|
||||
}
|
||||
return elementTypeMap[props.elementInfo.type] || null
|
||||
})
|
||||
|
74
src/views/Editor/Canvas/Operate/VideoElementOperate.vue
Normal file
74
src/views/Editor/Canvas/Operate/VideoElementOperate.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="video-element-operate">
|
||||
<BorderLine
|
||||
class="operate-border-line"
|
||||
v-for="line in borderLines"
|
||||
:key="line.type"
|
||||
:type="line.type"
|
||||
:style="line.style"
|
||||
/>
|
||||
<template v-if="!elementInfo.lock && (isActiveGroupElement || !isMultiSelect)">
|
||||
<ResizeHandler
|
||||
class="operate-resize-handler"
|
||||
v-for="point in resizeHandlers"
|
||||
:key="point.direction"
|
||||
:type="point.direction"
|
||||
:style="point.style"
|
||||
@mousedown.stop="$event => scaleElement($event, elementInfo, point.direction)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import { PPTShapeElement } from '@/types/slides'
|
||||
import { OperateResizeHandler } from '@/types/edit'
|
||||
import useCommonOperate from '../hooks/useCommonOperate'
|
||||
|
||||
import ResizeHandler from './ResizeHandler.vue'
|
||||
import BorderLine from './BorderLine.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'video-element-operate',
|
||||
inheritAttrs: false,
|
||||
components: {
|
||||
ResizeHandler,
|
||||
BorderLine,
|
||||
},
|
||||
props: {
|
||||
elementInfo: {
|
||||
type: Object as PropType<PPTShapeElement>,
|
||||
required: true,
|
||||
},
|
||||
isActiveGroupElement: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isMultiSelect: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
scaleElement: {
|
||||
type: Function as PropType<(e: MouseEvent, element: PPTShapeElement, command: OperateResizeHandler) => void>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore()
|
||||
const canvasScale = computed(() => store.state.canvasScale)
|
||||
|
||||
const scaleWidth = computed(() => props.elementInfo.width * canvasScale.value)
|
||||
const scaleHeight = computed(() => props.elementInfo.height * canvasScale.value)
|
||||
const { resizeHandlers, borderLines } = useCommonOperate(scaleWidth, scaleHeight)
|
||||
|
||||
return {
|
||||
scaleWidth,
|
||||
resizeHandlers,
|
||||
borderLines,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
@ -48,6 +48,7 @@ import ShapeElementOperate from './ShapeElementOperate.vue'
|
||||
import LineElementOperate from './LineElementOperate.vue'
|
||||
import ChartElementOperate from './ChartElementOperate.vue'
|
||||
import TableElementOperate from './TableElementOperate.vue'
|
||||
import VideoElementOperate from './VideoElementOperate.vue'
|
||||
import LinkHandler from './LinkHandler.vue'
|
||||
|
||||
export default defineComponent({
|
||||
@ -107,6 +108,7 @@ export default defineComponent({
|
||||
[ElementTypes.LINE]: LineElementOperate,
|
||||
[ElementTypes.CHART]: ChartElementOperate,
|
||||
[ElementTypes.TABLE]: TableElementOperate,
|
||||
[ElementTypes.VIDEO]: VideoElementOperate,
|
||||
}
|
||||
return elementTypeMap[props.elementInfo.type] || null
|
||||
})
|
||||
|
45
src/views/Editor/CanvasTool/VideoInput.vue
Normal file
45
src/views/Editor/CanvasTool/VideoInput.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="video-input">
|
||||
<Input v-model:value="src" placeholder="请输入视频地址,e.g. https://xxx.mp4"></Input>
|
||||
<div class="btns">
|
||||
<Button @click="close()" style="margin-right: 10px;">取消</Button>
|
||||
<Button type="primary" @click="intsertVideo()">确认</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'video-input',
|
||||
emits: ['insert', 'close'],
|
||||
setup(props, { emit }) {
|
||||
const src = ref('https://www.w3school.com.cn/i/movie.ogg')
|
||||
|
||||
const intsertVideo = () => {
|
||||
if (!src.value) return message.error('请先输入正确的视频地址')
|
||||
emit('insert', src.value)
|
||||
}
|
||||
|
||||
const close = () => emit('close')
|
||||
|
||||
return {
|
||||
src,
|
||||
intsertVideo,
|
||||
close,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.video-input {
|
||||
width: 480px;
|
||||
}
|
||||
.btns {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
@ -53,6 +53,17 @@
|
||||
<IconInsertTable class="handler-item" />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
<Popover trigger="click" v-model:visible="videoInputVisible">
|
||||
<template #content>
|
||||
<VideoInput
|
||||
@close="videoInputVisible = false"
|
||||
@insert="src => { createVideoElement(src); videoInputVisible = false }"
|
||||
/>
|
||||
</template>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="插入视频">
|
||||
<IconVideoTwo class="handler-item" />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="right-handler">
|
||||
@ -80,6 +91,7 @@ import ShapePool from './ShapePool.vue'
|
||||
import LinePool from './LinePool.vue'
|
||||
import ChartPool from './ChartPool.vue'
|
||||
import TableGenerator from './TableGenerator.vue'
|
||||
import VideoInput from './VideoInput.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'canvas-tool',
|
||||
@ -88,6 +100,7 @@ export default defineComponent({
|
||||
LinePool,
|
||||
ChartPool,
|
||||
TableGenerator,
|
||||
VideoInput,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
@ -100,7 +113,7 @@ export default defineComponent({
|
||||
const { scaleCanvas, setCanvasPercentage } = useScaleCanvas()
|
||||
const { redo, undo } = useHistorySnapshot()
|
||||
|
||||
const { createImageElement, createChartElement, createTableElement } = useCreateElement()
|
||||
const { createImageElement, createChartElement, createTableElement, createVideoElement } = useCreateElement()
|
||||
|
||||
const insertImageElement = (files: File[]) => {
|
||||
const imageFile = files[0]
|
||||
@ -112,6 +125,7 @@ export default defineComponent({
|
||||
const linePoolVisible = ref(false)
|
||||
const chartPoolVisible = ref(false)
|
||||
const tableGeneratorVisible = ref(false)
|
||||
const videoInputVisible = ref(false)
|
||||
|
||||
// 绘制文字范围
|
||||
const drawText = () => {
|
||||
@ -152,11 +166,13 @@ export default defineComponent({
|
||||
linePoolVisible,
|
||||
chartPoolVisible,
|
||||
tableGeneratorVisible,
|
||||
videoInputVisible,
|
||||
drawText,
|
||||
drawShape,
|
||||
drawLine,
|
||||
createChartElement,
|
||||
createTableElement,
|
||||
createVideoElement,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="element-animation-panel">
|
||||
<div class="element-animation" v-if="handleElement">
|
||||
<Popover trigger="click" v-model:visible="animationPoolVisible" v-if="handleElement.type !== 'chart'">
|
||||
<Popover trigger="click" v-model:visible="animationPoolVisible" v-if="!['chart', 'video'].includes(handleElement.type)">
|
||||
<template #content>
|
||||
<div class="animation-pool">
|
||||
<div class="pool-type" v-for="type in animations" :key="type.name">
|
||||
@ -34,7 +34,7 @@
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button class="element-animation-btn" v-else disabled>
|
||||
<IconEffects style="margin-right: 5px;" /> 图表元素暂不支持动画
|
||||
<IconEffects style="margin-right: 5px;" /> 该元素暂不支持动画
|
||||
</Button>
|
||||
|
||||
<div class="duration" v-if="handleElementAnimation">
|
||||
|
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="video-style-panel">
|
||||
<div class="title">视频预览封面</div>
|
||||
<div class="background-image-wrapper">
|
||||
<FileInput @change="files => setVideoPoster(files)">
|
||||
<div class="background-image">
|
||||
<div class="content" :style="{ backgroundImage: `url(${handleElement.poster})` }">
|
||||
<IconPlus />
|
||||
</div>
|
||||
</div>
|
||||
</FileInput>
|
||||
</div>
|
||||
<div class="row"><Button style="flex: 1;" @click="updateVideo({ poster: '' })">重置封面</Button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from 'vue'
|
||||
import { MutationTypes, useStore } from '@/store'
|
||||
import { PPTVideoElement } from '@/types/slides'
|
||||
import { getImageDataURL } from '@/utils/image'
|
||||
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'video-style-panel',
|
||||
setup() {
|
||||
const store = useStore()
|
||||
const handleElement = computed<PPTVideoElement>(() => store.getters.handleElement)
|
||||
|
||||
const { addHistorySnapshot } = useHistorySnapshot()
|
||||
|
||||
const updateVideo = (props: Partial<PPTVideoElement>) => {
|
||||
store.commit(MutationTypes.UPDATE_ELEMENT, { id: handleElement.value.id, props })
|
||||
addHistorySnapshot()
|
||||
}
|
||||
|
||||
// 设置视频预览封面
|
||||
const setVideoPoster = (files: File[]) => {
|
||||
const imageFile = files[0]
|
||||
if (!imageFile) return
|
||||
getImageDataURL(imageFile).then(dataURL => updateVideo({ poster: dataURL }))
|
||||
}
|
||||
|
||||
return {
|
||||
handleElement,
|
||||
updateVideo,
|
||||
setVideoPoster,
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.background-image-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.background-image {
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
border: 1px dashed $borderColor;
|
||||
border-radius: $borderRadius;
|
||||
position: relative;
|
||||
transition: all $transitionDelay;
|
||||
|
||||
&:hover {
|
||||
border-color: $themeColor;
|
||||
color: $themeColor;
|
||||
}
|
||||
|
||||
.content {
|
||||
@include absolute-0();
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -18,6 +18,7 @@ import ShapeStylePanel from './ShapeStylePanel.vue'
|
||||
import LineStylePanel from './LineStylePanel.vue'
|
||||
import ChartStylePanel from './ChartStylePanel/index.vue'
|
||||
import TableStylePanel from './TableStylePanel.vue'
|
||||
import VideoStylePanel from './VideoStylePanel.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'element-style-panel',
|
||||
@ -35,6 +36,7 @@ export default defineComponent({
|
||||
[ElementTypes.LINE]: LineStylePanel,
|
||||
[ElementTypes.CHART]: ChartStylePanel,
|
||||
[ElementTypes.TABLE]: TableStylePanel,
|
||||
[ElementTypes.VIDEO]: VideoStylePanel,
|
||||
}
|
||||
return panelMap[handleElement.value.type] || null
|
||||
})
|
||||
|
@ -30,6 +30,7 @@ import BaseShapeElement from '@/views/components/element/ShapeElement/BaseShapeE
|
||||
import BaseLineElement from '@/views/components/element/LineElement/BaseLineElement.vue'
|
||||
import ScreenChartElement from '@/views/components/element/ChartElement/ScreenChartElement.vue'
|
||||
import BaseTableElement from '@/views/components/element/TableElement/BaseTableElement.vue'
|
||||
import ScreenVideoElement from '@/views/components/element/VideoElement/ScreenVideoElement.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'screen-element',
|
||||
@ -56,6 +57,7 @@ export default defineComponent({
|
||||
[ElementTypes.LINE]: BaseLineElement,
|
||||
[ElementTypes.CHART]: ScreenChartElement,
|
||||
[ElementTypes.TABLE]: BaseTableElement,
|
||||
[ElementTypes.VIDEO]: ScreenVideoElement,
|
||||
}
|
||||
return elementTypeMap[props.elementInfo.type] || null
|
||||
})
|
||||
|
@ -24,6 +24,7 @@ import BaseShapeElement from '@/views/components/element/ShapeElement/BaseShapeE
|
||||
import BaseLineElement from '@/views/components/element/LineElement/BaseLineElement.vue'
|
||||
import BaseChartElement from '@/views/components/element/ChartElement/BaseChartElement.vue'
|
||||
import BaseTableElement from '@/views/components/element/TableElement/BaseTableElement.vue'
|
||||
import BaseVideoElement from '@/views/components/element/VideoElement/BaseVideoElement.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'base-element',
|
||||
@ -46,6 +47,7 @@ export default defineComponent({
|
||||
[ElementTypes.LINE]: BaseLineElement,
|
||||
[ElementTypes.CHART]: BaseChartElement,
|
||||
[ElementTypes.TABLE]: BaseTableElement,
|
||||
[ElementTypes.VIDEO]: BaseVideoElement,
|
||||
}
|
||||
return elementTypeMap[props.elementInfo.type] || null
|
||||
})
|
||||
|
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="base-element-video"
|
||||
:style="{
|
||||
top: elementInfo.top + 'px',
|
||||
left: elementInfo.left + 'px',
|
||||
width: elementInfo.width + 'px',
|
||||
height: elementInfo.height + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="element-content" :style="{ backgroundImage: `url(${elementInfo.poster})` }">
|
||||
<IconPlayOne class="icon" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue'
|
||||
import { PPTTableElement } from '@/types/slides'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'base-element-video',
|
||||
props: {
|
||||
elementInfo: {
|
||||
type: Object as PropType<PPTTableElement>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-element-video {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.element-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 140px;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="screen-element-video"
|
||||
:style="{
|
||||
top: elementInfo.top + 'px',
|
||||
left: elementInfo.left + 'px',
|
||||
width: elementInfo.width + 'px',
|
||||
height: elementInfo.height + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="element-content">
|
||||
<VideoPlayer
|
||||
:width="elementInfo.width"
|
||||
:height="elementInfo.height"
|
||||
:src="elementInfo.src"
|
||||
:poster="elementInfo.poster"
|
||||
:scale="scale"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject, PropType, Ref, ref } from 'vue'
|
||||
import { PPTTableElement } from '@/types/slides'
|
||||
|
||||
import VideoPlayer from './VideoPlayer/index.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'screen-element-video',
|
||||
components: {
|
||||
VideoPlayer,
|
||||
},
|
||||
props: {
|
||||
elementInfo: {
|
||||
type: Object as PropType<PPTTableElement>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const scale: Ref<number> = inject('slideScale') || ref(1)
|
||||
|
||||
return {
|
||||
scale,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screen-element-video {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.element-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
381
src/views/components/element/VideoElement/VideoPlayer/index.vue
Normal file
381
src/views/components/element/VideoElement/VideoPlayer/index.vue
Normal file
@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div
|
||||
class="player"
|
||||
:class="{ 'hide-controller': hideController }"
|
||||
:style="{
|
||||
width: width * scale + 'px',
|
||||
height: height * scale + 'px',
|
||||
transform: `scale(${1 / scale})`,
|
||||
}"
|
||||
ref="containerRef"
|
||||
@mousemove="autoHideController()"
|
||||
@click="autoHideController()"
|
||||
>
|
||||
<div class="video-wrap" @click="toggle()">
|
||||
<video
|
||||
class="video"
|
||||
ref="videoRef"
|
||||
:src="src"
|
||||
:poster="poster"
|
||||
webkit-playsinline
|
||||
playsinline
|
||||
@durationchange="handleDurationchange()"
|
||||
@timeupdate="handleTimeupdate()"
|
||||
@ended="handleEnded()"
|
||||
@progress="handleProgress()"
|
||||
@play="autoHideController()"
|
||||
@pause="autoHideController()"
|
||||
@error="handleError()"
|
||||
></video>
|
||||
<div class="bezel">
|
||||
<span class="bezel-icon" :class="{ 'bezel-transition': bezelTransition }" @animationend="bezelTransition = false">
|
||||
<IconPause v-if="paused" />
|
||||
<IconPlayOne v-else />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controller-mask"></div>
|
||||
<div class="controller">
|
||||
<div class="icons icons-left">
|
||||
<div class="icon play-icon" @click="toggle()">
|
||||
<span class="icon-content">
|
||||
<IconPlayOne v-if="paused" />
|
||||
<IconPause v-else />
|
||||
</span>
|
||||
</div>
|
||||
<div class="volume">
|
||||
<div class="icon volume-icon" @click="toggleVolume()">
|
||||
<span class="icon-content">
|
||||
<IconVolumeMute v-if="volume === 0" />
|
||||
<IconVolumeNotice v-else-if="volume === 1" />
|
||||
<IconVolumeSmall v-else />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="volume-bar-wrap"
|
||||
@mousedown="$event => handleMousedownVolumeBar($event)"
|
||||
@touchstart="$event => handleMousedownVolumeBar($event)"
|
||||
@click="$event => handleClickVolumeBar($event)"
|
||||
>
|
||||
<div class="volume-bar" ref="volumeBarRef">
|
||||
<div class="volume-bar-inner" :style="{ width: volumeBarWidth }">
|
||||
<span class="thumb"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="time">
|
||||
<span class="ptime">{{ptime}}</span> / <span class="dtime">{{dtime}}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="icons icons-right">
|
||||
<div class="loop" @click="toggleLoop()">
|
||||
<div class="icon loop-icon" :class="{ 'active': loop }">
|
||||
<span class="icon-content"><IconLoopOnce /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bar-wrap"
|
||||
ref="playBarWrap"
|
||||
@mousedown="$event => handleMousedownPlayBar($event)"
|
||||
@touchstart="$event => handleMousedownPlayBar($event)"
|
||||
@mousemove="$event => handleMousemovePlayBar($event)"
|
||||
@mouseenter="playBarTimeVisible = true"
|
||||
@mouseleave="playBarTimeVisible = false"
|
||||
>
|
||||
<div class="bar-time" :class="{ 'hidden': !playBarTimeVisible }" :style="{ left: playBarTimeLeft }">{{playBarTime}}</div>
|
||||
<div class="bar">
|
||||
<div class="loaded" :style="{ width: loadedBarWidth }"></div>
|
||||
<div class="played" :style="{ width: playedBarWidth }">
|
||||
<span class="thumb"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
import useMSE from './useMSE'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const secondToTime = (second = 0) => {
|
||||
if (second === 0 || isNaN(second)) return '00:00'
|
||||
|
||||
const add0 = (num: number) => (num < 10 ? '0' + num : '' + num)
|
||||
const hour = Math.floor(second / 3600)
|
||||
const min = Math.floor((second - hour * 3600) / 60)
|
||||
const sec = Math.floor(second - hour * 3600 - min * 60)
|
||||
return (hour > 0 ? [hour, min, sec] : [min, sec]).map(add0).join(':')
|
||||
}
|
||||
|
||||
const getBoundingClientRectViewLeft = (element: HTMLElement) => {
|
||||
return element.getBoundingClientRect().left
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Player',
|
||||
props: {
|
||||
width: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
poster: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
const playBarWrap = ref<HTMLElement>()
|
||||
const volumeBarRef = ref<HTMLElement>()
|
||||
|
||||
const volume = ref(0.5)
|
||||
const paused = ref(true)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const loaded = ref(0)
|
||||
const loop = ref(false)
|
||||
const bezelTransition = ref(false)
|
||||
|
||||
const playBarTimeVisible = ref(false)
|
||||
const playBarTime = ref('00:00')
|
||||
const playBarTimeLeft = ref('0')
|
||||
|
||||
const ptime = computed(() => secondToTime(currentTime.value))
|
||||
const dtime = computed(() => secondToTime(duration.value))
|
||||
const playedBarWidth = computed(() => currentTime.value / duration.value * 100 + '%')
|
||||
const loadedBarWidth = computed(() => loaded.value / duration.value * 100 + '%')
|
||||
const volumeBarWidth = computed(() => volume.value * 100 + '%')
|
||||
|
||||
const seek = (time: number) => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
time = Math.max(time, 0)
|
||||
time = Math.min(time, duration.value)
|
||||
|
||||
videoRef.value.currentTime = time
|
||||
currentTime.value = time
|
||||
}
|
||||
|
||||
const play = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
paused.value = false
|
||||
videoRef.value.play()
|
||||
bezelTransition.value = true
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
paused.value = true
|
||||
videoRef.value.pause()
|
||||
bezelTransition.value = true
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
if (paused.value) play()
|
||||
else pause()
|
||||
}
|
||||
|
||||
const setVolume = (percentage: number) => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
percentage = Math.max(percentage, 0)
|
||||
percentage = Math.min(percentage, 1)
|
||||
|
||||
videoRef.value.volume = percentage
|
||||
volume.value = percentage
|
||||
if (videoRef.value.muted && percentage !== 0) videoRef.value.muted = false
|
||||
}
|
||||
|
||||
const handleDurationchange = () => {
|
||||
duration.value = videoRef.value?.duration || 0
|
||||
}
|
||||
|
||||
const handleTimeupdate = () => {
|
||||
currentTime.value = videoRef.value?.currentTime || 0
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
if (!loop.value) pause()
|
||||
else {
|
||||
seek(0)
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgress = () => {
|
||||
loaded.value = videoRef.value?.buffered.length ? videoRef.value.buffered.end(videoRef.value.buffered.length - 1) : 0
|
||||
}
|
||||
|
||||
const handleError = () => message.error('视频加载失败')
|
||||
|
||||
const thumbMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!videoRef.value || !playBarWrap.value) return
|
||||
const clientX = 'clientX' in e ? e.clientX : e.changedTouches[0].clientX
|
||||
let percentage = (clientX - getBoundingClientRectViewLeft(playBarWrap.value)) / playBarWrap.value.clientWidth
|
||||
percentage = Math.max(percentage, 0)
|
||||
percentage = Math.min(percentage, 1)
|
||||
const time = percentage * duration.value
|
||||
|
||||
videoRef.value.currentTime = time
|
||||
currentTime.value = time
|
||||
}
|
||||
|
||||
const thumbUp = (e: MouseEvent | TouchEvent) => {
|
||||
if (!videoRef.value || !playBarWrap.value) return
|
||||
|
||||
const clientX = 'clientX' in e ? e.clientX : e.changedTouches[0].clientX
|
||||
let percentage = (clientX - getBoundingClientRectViewLeft(playBarWrap.value)) / playBarWrap.value.clientWidth
|
||||
percentage = Math.max(percentage, 0)
|
||||
percentage = Math.min(percentage, 1)
|
||||
const time = percentage * duration.value
|
||||
|
||||
videoRef.value.currentTime = time
|
||||
currentTime.value = time
|
||||
|
||||
document.removeEventListener('mousemove', thumbMove)
|
||||
document.removeEventListener('touchmove', thumbMove)
|
||||
document.removeEventListener('mouseup', thumbUp)
|
||||
document.removeEventListener('touchend', thumbUp)
|
||||
}
|
||||
|
||||
const handleMousedownPlayBar = () => {
|
||||
document.addEventListener('mousemove', thumbMove)
|
||||
document.addEventListener('touchmove', thumbMove)
|
||||
document.addEventListener('mouseup', thumbUp)
|
||||
document.addEventListener('touchend', thumbUp)
|
||||
}
|
||||
|
||||
const volumeMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!volumeBarRef.value) return
|
||||
const clientX = 'clientX' in e ? e.clientX : e.changedTouches[0].clientX
|
||||
const percentage = (clientX - getBoundingClientRectViewLeft(volumeBarRef.value) - 5.5) / 35
|
||||
setVolume(percentage)
|
||||
}
|
||||
|
||||
const volumeUp = () => {
|
||||
document.removeEventListener('mousemove', volumeMove)
|
||||
document.removeEventListener('touchmove', volumeMove)
|
||||
document.removeEventListener('mouseup', volumeUp)
|
||||
document.removeEventListener('touchend', volumeUp)
|
||||
}
|
||||
|
||||
const handleMousedownVolumeBar = () => {
|
||||
document.addEventListener('mousemove', volumeMove)
|
||||
document.addEventListener('touchmove', volumeMove)
|
||||
document.addEventListener('mouseup', volumeUp)
|
||||
document.addEventListener('touchend', volumeUp)
|
||||
}
|
||||
|
||||
const handleClickVolumeBar = (e: MouseEvent) => {
|
||||
if (!volumeBarRef.value) return
|
||||
const percentage = (e.clientX - getBoundingClientRectViewLeft(volumeBarRef.value) - 5.5) / 35
|
||||
setVolume(percentage)
|
||||
}
|
||||
|
||||
const handleMousemovePlayBar = (e: MouseEvent) => {
|
||||
if (duration.value && playBarWrap.value) {
|
||||
const px = playBarWrap.value.getBoundingClientRect().left
|
||||
const tx = e.clientX - px
|
||||
if (tx < 0 || tx > playBarWrap.value.offsetWidth) return
|
||||
|
||||
const time = duration.value * (tx / playBarWrap.value.offsetWidth)
|
||||
playBarTimeLeft.value = `${tx - (time >= 3600 ? 25 : 20)}px`
|
||||
playBarTime.value = secondToTime(time)
|
||||
playBarTimeVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVolume = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
if (videoRef.value.muted) {
|
||||
videoRef.value.muted = false
|
||||
setVolume(0.5)
|
||||
}
|
||||
else {
|
||||
videoRef.value.muted = true
|
||||
setVolume(0)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLoop = () => {
|
||||
loop.value = !loop.value
|
||||
}
|
||||
|
||||
const autoHideControllerTimer = ref(-1)
|
||||
const hideController = ref(false)
|
||||
const autoHideController = () => {
|
||||
hideController.value = false
|
||||
clearTimeout(autoHideControllerTimer.value)
|
||||
autoHideControllerTimer.value = setTimeout(() => {
|
||||
if (videoRef.value?.played.length) hideController.value = true
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
useMSE(props.src, videoRef)
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
videoRef,
|
||||
playBarWrap,
|
||||
volumeBarRef,
|
||||
volume,
|
||||
loop,
|
||||
paused,
|
||||
ptime,
|
||||
dtime,
|
||||
playBarTime,
|
||||
playBarTimeVisible,
|
||||
playBarTimeLeft,
|
||||
playedBarWidth,
|
||||
loadedBarWidth,
|
||||
volumeBarWidth,
|
||||
hideController,
|
||||
bezelTransition,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
setVolume,
|
||||
handleDurationchange,
|
||||
handleTimeupdate,
|
||||
handleEnded,
|
||||
handleProgress,
|
||||
handleMousedownPlayBar,
|
||||
handleMousedownVolumeBar,
|
||||
handleClickVolumeBar,
|
||||
handleMousemovePlayBar,
|
||||
toggleVolume,
|
||||
toggleLoop,
|
||||
autoHideController,
|
||||
handleError,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import './style.scss';
|
||||
</style>
|
353
src/views/components/element/VideoElement/VideoPlayer/style.scss
Normal file
353
src/views/components/element/VideoElement/VideoPlayer/style.scss
Normal file
@ -0,0 +1,353 @@
|
||||
// player
|
||||
.player {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
transform-origin: 0 0;
|
||||
|
||||
&.playing {
|
||||
@media (min-width: 900px) {
|
||||
.controller-mask {
|
||||
opacity: 0;
|
||||
}
|
||||
.controller {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.controller-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
.controller {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
.bezel .diplayer-loading-icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-controller {
|
||||
cursor: none;
|
||||
|
||||
.controller-mask {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
.controller {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-wrap {
|
||||
position: relative;
|
||||
background: #000;
|
||||
font-size: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// controller
|
||||
.controller-mask {
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==) repeat-x bottom;
|
||||
height: 98px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.controller {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 41px;
|
||||
padding: 0 20px;
|
||||
user-select: none;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.bar-wrap {
|
||||
padding: 5px 0;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
bottom: 33px;
|
||||
width: calc(100% - 40px);
|
||||
height: 3px;
|
||||
|
||||
&:hover .bar .played .thumb {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.bar-time {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -20px;
|
||||
border-radius: 4px;
|
||||
padding: 5px 7px;
|
||||
background-color: rgba(0, 0, 0, 0.62);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
word-wrap: normal;
|
||||
word-break: normal;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.bar {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
|
||||
.loaded {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
height: 3px;
|
||||
transition: all 0.5s ease;
|
||||
will-change: width;
|
||||
}
|
||||
.played {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
will-change: width;
|
||||
background-color: $themeColor;
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 5px;
|
||||
margin-top: -4px;
|
||||
margin-right: -10px;
|
||||
height: 11px;
|
||||
width: 11px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: scale(0);
|
||||
background-color: $themeColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.icons {
|
||||
height: 38px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.icons-right {
|
||||
right: 8px;
|
||||
}
|
||||
.time {
|
||||
line-height: 38px;
|
||||
color: #eee;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||
vertical-align: middle;
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
}
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 22px;
|
||||
|
||||
&.play-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.icon-content {
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0.8;
|
||||
color: #fff;
|
||||
}
|
||||
&.active .icon-content {
|
||||
color: $themeColor;
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover .icon-content {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.volume {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
.volume-bar-wrap .volume-bar {
|
||||
width: 45px;
|
||||
}
|
||||
.volume-bar-wrap .volume-bar .volume-bar-inner .thumb {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
&.volume-active {
|
||||
.volume-bar-wrap .volume-bar {
|
||||
width: 45px;
|
||||
}
|
||||
.volume-bar-wrap .volume-bar .volume-bar-inner .thumb {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.volume-bar-wrap {
|
||||
display: inline-block;
|
||||
margin: 0 15px 0 -5px;
|
||||
vertical-align: middle;
|
||||
height: 100%;
|
||||
}
|
||||
.volume-bar {
|
||||
position: relative;
|
||||
top: 17px;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
background: #aaa;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
.volume-bar-inner {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: all 0.1s ease;
|
||||
will-change: width;
|
||||
background-color: $themeColor;
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 5px;
|
||||
margin-top: -4px;
|
||||
margin-right: -10px;
|
||||
height: 11px;
|
||||
width: 11px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: scale(0);
|
||||
background-color: $themeColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
.loop {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bezel
|
||||
.bezel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
|
||||
.bezel-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -26px 0 0 -26px;
|
||||
height: 52px;
|
||||
width: 52px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
font-size: 40px;
|
||||
|
||||
&.bezel-transition {
|
||||
animation: bezel-hide 0.5s linear;
|
||||
}
|
||||
|
||||
@keyframes bezel-hide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.loading-icon {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -18px 0 0 -18px;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
pointer-events: none;
|
||||
|
||||
.loading-hide {
|
||||
display: none;
|
||||
}
|
||||
.loading-dot {
|
||||
animation: loading-dot-fade 0.8s ease infinite;
|
||||
opacity: 0;
|
||||
transform-origin: 4px 4px;
|
||||
|
||||
@for $i from 7 through 1 {
|
||||
&.loading-dot-#{$i} {
|
||||
animation-delay: 0.1s * $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes loading-dot-fade {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.2, 1.2);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.9, 0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.85, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { onMounted, Ref } from 'vue'
|
||||
|
||||
export default (
|
||||
src: string,
|
||||
videoRef: Ref<HTMLVideoElement | undefined>,
|
||||
) => {
|
||||
onMounted(() => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
let type = 'normal'
|
||||
if (/m3u8(#|\?|$)/i.exec(src)) type = 'hls'
|
||||
else if (/.flv(#|\?|$)/i.exec(src)) type = 'flv'
|
||||
|
||||
if (videoRef.value && type === 'hls' && (videoRef.value.canPlayType('application/x-mpegURL') || videoRef.value.canPlayType('application/vnd.apple.mpegURL'))) {
|
||||
type = 'normal'
|
||||
}
|
||||
|
||||
if (type === 'hls') {
|
||||
const Hls = (window as any).Hls
|
||||
|
||||
if (Hls && Hls.isSupported()) {
|
||||
const hls = new Hls()
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(videoRef.value)
|
||||
}
|
||||
}
|
||||
else if (type === 'flv') {
|
||||
const flvjs = (window as any).flvjs
|
||||
if (flvjs && flvjs.isSupported()) {
|
||||
const flvPlayer = flvjs.createPlayer({
|
||||
type: 'flv',
|
||||
url: src,
|
||||
})
|
||||
flvPlayer.attachMediaElement(videoRef.value)
|
||||
flvPlayer.load()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
121
src/views/components/element/VideoElement/index.vue
Normal file
121
src/views/components/element/VideoElement/index.vue
Normal file
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="editable-element-video"
|
||||
:class="{ 'lock': elementInfo.lock }"
|
||||
:style="{
|
||||
top: elementInfo.top + 'px',
|
||||
left: elementInfo.left + 'px',
|
||||
width: elementInfo.width + 'px',
|
||||
height: elementInfo.height + 'px',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="element-content"
|
||||
v-contextmenu="contextmenus"
|
||||
@mousedown="$event => handleSelectElement($event, false)"
|
||||
>
|
||||
<VideoPlayer
|
||||
:width="elementInfo.width"
|
||||
:height="elementInfo.height"
|
||||
:src="elementInfo.src"
|
||||
:poster="elementInfo.poster"
|
||||
:scale="scale"
|
||||
/>
|
||||
<div
|
||||
:class="['handler-border', item]"
|
||||
v-for="item in ['t', 'b', 'l', 'r']"
|
||||
:key="item"
|
||||
@mousedown="$event => handleSelectElement($event)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue'
|
||||
import { useStore } from '@/store'
|
||||
import { PPTTableElement } from '@/types/slides'
|
||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||
|
||||
import VideoPlayer from './VideoPlayer/index.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'editable-element-video',
|
||||
components: {
|
||||
VideoPlayer,
|
||||
},
|
||||
props: {
|
||||
elementInfo: {
|
||||
type: Object as PropType<PPTTableElement>,
|
||||
required: true,
|
||||
},
|
||||
selectElement: {
|
||||
type: Function as PropType<(e: MouseEvent, element: PPTTableElement, canMove?: boolean) => void>,
|
||||
required: true,
|
||||
},
|
||||
contextmenus: {
|
||||
type: Function as PropType<() => ContextmenuItem[]>,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore()
|
||||
const scale = computed(() => store.state.canvasScale)
|
||||
|
||||
const handleSelectElement = (e: MouseEvent, canMove = true) => {
|
||||
if (props.elementInfo.lock) return
|
||||
e.stopPropagation()
|
||||
|
||||
props.selectElement(e, props.elementInfo, canMove)
|
||||
}
|
||||
|
||||
return {
|
||||
scale,
|
||||
handleSelectElement,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editable-element-video {
|
||||
position: absolute;
|
||||
|
||||
&.lock .handler-border {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.element-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.handler-border {
|
||||
position: absolute;
|
||||
cursor: move;
|
||||
|
||||
&.t {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
&.b {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
&.l {
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
&.r {
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user