Files
RUWIO/src/components/FullscreenPreview.vue
T
2026-04-19 20:31:53 +02:00

459 lines
11 KiB
Vue

<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>