updates 33.1 23
This commit is contained in:
143
soleprint/common/ui/src/components/VideoPlayer.vue
Normal file
143
soleprint/common/ui/src/components/VideoPlayer.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agnostic seekable media player.
|
||||||
|
*
|
||||||
|
* Two-way bindable playback position and play state:
|
||||||
|
* <VideoPlayer :src="url" v-model:currentTime="t" v-model:playing="p" />
|
||||||
|
*
|
||||||
|
* External `currentTime` changes seek the element (guarded against the
|
||||||
|
* feedback loop with the element's own `timeupdate` events).
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Media source URL */
|
||||||
|
src: string
|
||||||
|
/** Playback position in seconds (v-model:currentTime) */
|
||||||
|
currentTime?: number
|
||||||
|
/** Whether the media is playing (v-model:playing) */
|
||||||
|
playing?: boolean
|
||||||
|
}>(), {
|
||||||
|
currentTime: 0,
|
||||||
|
playing: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:currentTime': [value: number]
|
||||||
|
'update:playing': [value: boolean]
|
||||||
|
/** Media duration once metadata has loaded */
|
||||||
|
duration: [value: number]
|
||||||
|
/** Playback reached the end */
|
||||||
|
ended: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const video = ref<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
|
/** True while we are applying an external seek, so the resulting
|
||||||
|
* `timeupdate` does not echo back out as an update. */
|
||||||
|
let seekingFromProp = false
|
||||||
|
/** Tolerance (s) below which we treat positions as already in sync. */
|
||||||
|
const EPSILON = 0.25
|
||||||
|
|
||||||
|
watch(() => props.currentTime, (t) => {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
if (Math.abs(el.currentTime - t) < EPSILON) return
|
||||||
|
seekingFromProp = true
|
||||||
|
el.currentTime = t
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.playing, (shouldPlay) => {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
if (shouldPlay && el.paused) {
|
||||||
|
void el.play().catch(() => { /* autoplay may be blocked; ignore */ })
|
||||||
|
} else if (!shouldPlay && !el.paused) {
|
||||||
|
el.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTimeUpdate() {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
if (seekingFromProp) {
|
||||||
|
seekingFromProp = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:currentTime', el.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSeeked() {
|
||||||
|
// Clear the guard once the browser settles the requested seek.
|
||||||
|
seekingFromProp = false
|
||||||
|
const el = video.value
|
||||||
|
if (el) emit('update:currentTime', el.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoadedMetadata() {
|
||||||
|
const el = video.value
|
||||||
|
if (!el) return
|
||||||
|
emit('duration', el.duration)
|
||||||
|
// Honour an initial position requested before metadata was ready.
|
||||||
|
if (props.currentTime && Math.abs(el.currentTime - props.currentTime) >= EPSILON) {
|
||||||
|
seekingFromProp = true
|
||||||
|
el.currentTime = props.currentTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlay() { emit('update:playing', true) }
|
||||||
|
function onPause() { emit('update:playing', false) }
|
||||||
|
function onEnded() {
|
||||||
|
emit('update:playing', false)
|
||||||
|
emit('ended')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// Release the media resource promptly.
|
||||||
|
const el = video.value
|
||||||
|
if (el) {
|
||||||
|
el.pause()
|
||||||
|
el.removeAttribute('src')
|
||||||
|
el.load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="video-player">
|
||||||
|
<video
|
||||||
|
ref="video"
|
||||||
|
class="video-el"
|
||||||
|
:src="src"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
@timeupdate="onTimeUpdate"
|
||||||
|
@seeked="onSeeked"
|
||||||
|
@loadedmetadata="onLoadedMetadata"
|
||||||
|
@play="onPlay"
|
||||||
|
@pause="onPause"
|
||||||
|
@ended="onEnded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-player {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--surface-0);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-el {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,7 @@ export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
|||||||
export { default as SplitPane } from './components/SplitPane.vue'
|
export { default as SplitPane } from './components/SplitPane.vue'
|
||||||
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||||
export type { ConfigField } from './components/ParameterEditor.vue'
|
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||||
|
export { default as VideoPlayer } from './components/VideoPlayer.vue'
|
||||||
|
|
||||||
// Renderers
|
// Renderers
|
||||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||||
|
|||||||
Reference in New Issue
Block a user