bak
This commit is contained in:
1 parent
e4aa0dce86
commit
cc6b11e61d
23 files changed
+2047
-524
No files matched your search
@@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const frameEl = ref<HTMLDivElement | null>(null)
|
||||
const rootEl = ref<HTMLDivElement | null>(null)
|
||||
const progressTrackEl = ref<HTMLDivElement | null>(null)
|
||||
const viewportSize = ref({ w: 0, h: 0 })
|
||||
const images = ref<Record<string, HTMLImageElement>>({})
|
||||
const localTime = ref(0)
|
||||
const playing = ref(false)
|
||||
const showControls = ref(false)
|
||||
const scrubbing = ref(false)
|
||||
|
||||
const HOVER_ZONE_PX = 160
|
||||
let isClosing = false
|
||||
|
||||
const aspect = computed(
|
||||
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
|
||||
)
|
||||
|
||||
const sceneEnd = computed(() => store.effectiveEnd)
|
||||
|
||||
let raf = 0
|
||||
let lastFrame = 0
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
function resize() {
|
||||
if (!canvasEl.value || !frameEl.value) return
|
||||
const rect = frameEl.value.getBoundingClientRect()
|
||||
viewportSize.value = { w: rect.width, h: rect.height }
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvasEl.value.width = Math.max(1, Math.round(rect.width * dpr))
|
||||
canvasEl.value.height = Math.max(1, Math.round(rect.height * dpr))
|
||||
draw()
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const canvas = canvasEl.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const lw = store.scene.canvas.width
|
||||
const lh = store.scene.canvas.height
|
||||
const cw = viewportSize.value.w
|
||||
const ch = viewportSize.value.h
|
||||
if (!cw || !ch) return
|
||||
const scale = Math.min(cw / lw, ch / lh)
|
||||
const ox = (cw - lw * scale) / 2
|
||||
const oy = (ch - lh * scale) / 2
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, cw, ch)
|
||||
ctx.save()
|
||||
ctx.translate(ox, oy)
|
||||
ctx.scale(scale, scale)
|
||||
drawSceneFrame(ctx, store.scene, localTime.value, images.value)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function loop(now: number) {
|
||||
if (!props.modelValue) return
|
||||
if (playing.value) {
|
||||
const end = sceneEnd.value
|
||||
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
|
||||
if (end > 0) {
|
||||
let next = localTime.value + dt
|
||||
if (next >= end) next = 0
|
||||
localTime.value = next
|
||||
}
|
||||
}
|
||||
lastFrame = now
|
||||
draw()
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function startLoop() {
|
||||
cancelAnimationFrame(raf)
|
||||
lastFrame = 0
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
|
||||
async function startPreview() {
|
||||
localTime.value = store.playhead
|
||||
playing.value = true
|
||||
showControls.value = false
|
||||
images.value = await loadSceneImages(store.scene)
|
||||
resize()
|
||||
startLoop()
|
||||
await requestBrowserFullscreen()
|
||||
}
|
||||
|
||||
function stopPreview() {
|
||||
stopLoop()
|
||||
playing.value = false
|
||||
showControls.value = false
|
||||
exitBrowserFullscreen()
|
||||
}
|
||||
|
||||
async function requestBrowserFullscreen() {
|
||||
const el = rootEl.value
|
||||
if (!el) return
|
||||
if (document.fullscreenElement === el) return
|
||||
try {
|
||||
await el.requestFullscreen?.()
|
||||
} catch {
|
||||
/* user gesture missing or not supported — gracefully keep overlay */
|
||||
}
|
||||
}
|
||||
|
||||
function exitBrowserFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
isClosing = true
|
||||
document.exitFullscreen?.().catch(() => {
|
||||
/* ignore */
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (!props.modelValue) return
|
||||
if (document.fullscreenElement === rootEl.value) return
|
||||
if (isClosing) {
|
||||
isClosing = false
|
||||
return
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
function onRootMouseMove(e: MouseEvent) {
|
||||
const nearBottom = e.clientY > window.innerHeight - HOVER_ZONE_PX
|
||||
if (nearBottom || scrubbing.value) showControls.value = true
|
||||
else showControls.value = false
|
||||
}
|
||||
|
||||
function onRootMouseLeave() {
|
||||
if (!scrubbing.value) showControls.value = false
|
||||
}
|
||||
|
||||
const progressPct = computed(() => {
|
||||
const end = sceneEnd.value
|
||||
if (!end) return 0
|
||||
return Math.max(0, Math.min(1, localTime.value / end)) * 100
|
||||
})
|
||||
|
||||
function seekFromEvent(e: PointerEvent) {
|
||||
const el = progressTrackEl.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const end = sceneEnd.value
|
||||
localTime.value = ratio * end
|
||||
draw()
|
||||
}
|
||||
|
||||
function onProgressPointerDown(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
scrubbing.value = true
|
||||
seekFromEvent(e)
|
||||
}
|
||||
|
||||
function onProgressPointerMove(e: PointerEvent) {
|
||||
if (!scrubbing.value) return
|
||||
seekFromEvent(e)
|
||||
}
|
||||
|
||||
function onProgressPointerUp(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
scrubbing.value = false
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function rewind() {
|
||||
localTime.value = 0
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (!props.modelValue) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v) startPreview()
|
||||
else stopPreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [store.scene.canvas.width, store.scene.canvas.height],
|
||||
() => {
|
||||
if (props.modelValue) resize()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (props.modelValue) startPreview()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
stopLoop()
|
||||
exitBrowserFullscreen()
|
||||
if (ro) ro.disconnect()
|
||||
ro = null
|
||||
})
|
||||
|
||||
watch(frameEl, (el, _old, onCleanup) => {
|
||||
if (ro) ro.disconnect()
|
||||
ro = null
|
||||
if (el) {
|
||||
ro = new ResizeObserver(() => resize())
|
||||
ro.observe(el)
|
||||
}
|
||||
onCleanup(() => {
|
||||
if (ro) ro.disconnect()
|
||||
ro = null
|
||||
})
|
||||
})
|
||||
|
||||
const timeLabel = computed(() => {
|
||||
const end = sceneEnd.value || 0
|
||||
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
ref="rootEl"
|
||||
class="fs-root"
|
||||
:class="{ 'controls-visible': showControls || scrubbing }"
|
||||
@mousemove="onRootMouseMove"
|
||||
@mouseleave="onRootMouseLeave"
|
||||
@dblclick.self="close"
|
||||
>
|
||||
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
|
||||
<canvas ref="canvasEl" />
|
||||
</div>
|
||||
|
||||
<div class="fs-controls" @mousedown.stop>
|
||||
<div
|
||||
ref="progressTrackEl"
|
||||
class="fs-progress"
|
||||
@pointerdown="onProgressPointerDown"
|
||||
@pointermove="onProgressPointerMove"
|
||||
@pointerup="onProgressPointerUp"
|
||||
@pointercancel="onProgressPointerUp"
|
||||
>
|
||||
<div class="fs-progress-track">
|
||||
<div class="fs-progress-fill" :style="{ width: progressPct + '%' }"></div>
|
||||
</div>
|
||||
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
|
||||
</div>
|
||||
<div class="fs-toolbar">
|
||||
<button class="fs-btn" :title="playing ? 'Pause' : 'Play'" @click="toggle">
|
||||
<PhPause v-if="playing" :size="15" weight="fill" />
|
||||
<PhPlay v-else :size="15" weight="fill" />
|
||||
</button>
|
||||
<button class="fs-btn" title="Zum Anfang" @click="rewind">
|
||||
<PhSkipBack :size="15" weight="fill" />
|
||||
</button>
|
||||
<span class="fs-time">{{ timeLabel }}</span>
|
||||
<div class="fs-spacer"></div>
|
||||
<button class="fs-btn close" title="Schließen (Esc)" @click="close">
|
||||
<PhX :size="15" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fs-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2500;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-frame {
|
||||
width: min(100vw, 100vh * (16 / 9));
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fs-frame canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fs-controls {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
width: min(720px, calc(100vw - 48px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 10px;
|
||||
background: rgba(20, 20, 24, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fs-root.controls-visible .fs-controls {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fs-progress {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-progress-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent, #6ea8ff);
|
||||
border-radius: 999px;
|
||||
transition: width 60ms linear;
|
||||
}
|
||||
|
||||
.fs-progress-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
margin-top: -6px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--accent, #6ea8ff);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fs-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fs-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fs-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fs-btn.close:hover {
|
||||
background: rgba(255, 85, 85, 0.2);
|
||||
color: #ff7a7a;
|
||||
}
|
||||
|
||||
.fs-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
padding: 0 6px;
|
||||
min-width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 160ms var(--ease, ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user