Files
horror/frontend/src/components/ClipTrimmer.vue
2026-10-01 00:45:19 +02:00

363 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// Der Zeitstrahl der Aufnahme rund um die Marke. Ein Balken zeigt das
// Fenster des Clips; an seinen Enden zieht man Anfang und Ende, in der
// Mitte schiebt man das ganze Fenster. Daneben zieht man den Strahl
// selbst nach links und rechts. Zeiten in Sekunden relativ zur Marke:
// 0 ist der Moment, an dem C gedrückt wurde.
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
// Stücke der Aufnahme: [{ von, bis }] relativ zur Marke
segmente: { type: Array, default: () => [] },
maxVorher: { type: Number, default: 4 * 3600 },
minDauer: { type: Number, default: 3 },
maxDauer: { type: Number, default: 180 },
// Uhrzeit der Marke, für die Beschriftung
marke: { type: String, default: '' },
})
const vorher = defineModel('vorher', { type: Number, default: 30 })
const dauer = defineModel('dauer', { type: Number, default: 35 })
const emit = defineEmits(['ende'])
// Der sichtbare Ausschnitt des Strahls, zoombar
const ZOOMS = [120, 300, 900, 1800, 7200, 14400]
const zoom = ref(1)
const mitte = ref(0)
const spanne = computed(() => ZOOMS[zoom.value])
const links = computed(() => mitte.value - spanne.value / 2)
const rechts = computed(() => mitte.value + spanne.value / 2)
const strahl = ref(null)
function prozent(t) {
return ((t - links.value) / spanne.value) * 100
}
const start = computed(() => -vorher.value)
const ende = computed(() => -vorher.value + dauer.value)
// Wo die Aufnahme überhaupt Material hat — so weit lässt sich der Strahl schieben
const grenzen = computed(() => {
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
return {
von: Math.min(...props.segmente.map((s) => s.von)),
bis: Math.max(...props.segmente.map((s) => s.bis)),
}
})
// Das Stück der Aufnahme, in dem das Fenster liegt. Zwischen zwei
// Stücken ist Loch — dort gibt es kein Bild, dorthin darf das Fenster
// nicht. Liegt es in einem Loch, gilt das nähere Stück.
function abstand(s, t) {
return t < s.von ? s.von - t : t > s.bis ? t - s.bis : 0
}
function stueckUm(t) {
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
const s = props.segmente.reduce((a, b) => (abstand(a, t) <= abstand(b, t) ? a : b))
return { von: Math.max(s.von, -props.maxVorher), bis: Math.min(s.bis, props.maxVorher) }
}
function zoomen(schritt) {
zoom.value = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + schritt))
mitte.value = (start.value + ende.value) / 2
}
// Beschriftung: runde Schritte, etwa sechs bis acht Striche
const striche = computed(() => {
const schritt = [10, 30, 60, 300, 900, 1800][zoom.value]
const aus = []
for (let t = Math.ceil(links.value / schritt) * schritt; t <= rechts.value; t += schritt) {
aus.push({ t, text: uhrzeit(t) })
}
return aus
})
function uhrzeit(t) {
if (!props.marke) return `${t >= 0 ? '+' : '−'}${Math.abs(t)} s`
const d = new Date(new Date(props.marke).getTime() + t * 1000)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
// ── Ziehen ────────────────────────────────────────────────────────────
let griff = null
let ausgang = null
// Ob sich die Hand seit dem Anfassen bewegt hat — sonst war es ein Klick
let bewegt = false
function zeitBei(clientX) {
const box = strahl.value.getBoundingClientRect()
return links.value + ((clientX - box.left) / box.width) * spanne.value
}
// Das Fenster an eine Stelle setzen: sein Anfang liegt dort, es bleibt im Stück
function setzen(t) {
const g = stueckUm(t)
const d = Math.max(props.minDauer, Math.min(dauer.value, g.bis - g.von))
const s = Math.max(g.von, Math.min(g.bis - d, t))
vorher.value = Math.round(-s)
dauer.value = Math.round(d)
emit('ende')
}
function anfassen(e, was) {
griff = was
bewegt = false
ausgang = {
x: e.clientX,
vorher: vorher.value,
dauer: dauer.value,
mitte: mitte.value,
// Das Stück, in dem das Fenster beim Anfassen liegt — darin bleibt es
stueck: stueckUm(-vorher.value + dauer.value / 2),
}
e.currentTarget.setPointerCapture(e.pointerId)
}
function ziehen(e) {
if (!griff || !strahl.value) return
const dt = ((e.clientX - ausgang.x) / strahl.value.clientWidth) * spanne.value
if (Math.abs(e.clientX - ausgang.x) > 4) bewegt = true
const g = ausgang.stueck
if (griff === 'bahn') {
// Den Strahl selbst schieben — die Zeiten laufen der Hand entgegen
const r = grenzen.value
mitte.value = Math.max(r.von, Math.min(r.bis, ausgang.mitte - dt))
} else if (griff === 'links') {
// Der Anfang wandert, das Ende bleibt stehen
const altesEnde = -ausgang.vorher + ausgang.dauer
let neuerStart = -ausgang.vorher + dt
neuerStart = Math.max(g.von, neuerStart)
neuerStart = Math.min(
altesEnde - props.minDauer,
Math.max(altesEnde - props.maxDauer, neuerStart),
)
vorher.value = Math.round(-neuerStart)
dauer.value = Math.round(altesEnde - neuerStart)
} else if (griff === 'rechts') {
let neuesEnde = -ausgang.vorher + ausgang.dauer + dt
neuesEnde = Math.min(g.bis, neuesEnde)
const d = Math.max(props.minDauer, Math.min(props.maxDauer, neuesEnde + ausgang.vorher))
dauer.value = Math.round(d)
} else {
// Das ganze Fenster verschieben, aber nicht aus dem Stück hinaus
let neuerStart = -ausgang.vorher + dt
neuerStart = Math.max(g.von, Math.min(g.bis - ausgang.dauer, neuerStart))
vorher.value = Math.round(-neuerStart)
}
}
function loslassen(e) {
if (!griff) return
const war = griff
griff = null
e.currentTarget.releasePointerCapture?.(e.pointerId)
if (war === 'bahn') {
// Ein Klick ohne Ziehen setzt das Fenster hierhin; Schieben braucht keine neue Vorschau
if (!bewegt) setzen(zeitBei(e.clientX))
return
}
emit('ende')
}
// Mausrad: zoomen, der Punkt unter dem Zeiger bleibt stehen
function rad(e) {
const neu = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + (e.deltaY > 0 ? 1 : -1)))
if (neu === zoom.value || !strahl.value) return
const box = strahl.value.getBoundingClientRect()
const anteil = (e.clientX - box.left) / box.width
const t = links.value + anteil * spanne.value
zoom.value = neu
const r = grenzen.value
mitte.value = Math.max(r.von, Math.min(r.bis, t - (anteil - 0.5) * ZOOMS[neu]))
}
// Pfeiltasten: 1 s, mit Shift 10 s. Die Vorschau lädt erst, wenn Ruhe ist
let tastenTimer = null
function taste(e) {
if (e.target instanceof Element && e.target.matches('input, textarea')) return
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const schritt = (e.shiftKey ? 10 : 1) * (e.key === 'ArrowLeft' ? -1 : 1)
const g = stueckUm(start.value + dauer.value / 2)
const s = Math.max(g.von, Math.min(g.bis - dauer.value, start.value + schritt))
vorher.value = Math.round(-s)
clearTimeout(tastenTimer)
tastenTimer = setTimeout(() => emit('ende'), 400)
}
onMounted(() => window.addEventListener('keydown', taste))
onUnmounted(() => {
window.removeEventListener('keydown', taste)
clearTimeout(tastenTimer)
})
</script>
<template>
<div class="trimmer">
<div class="kopf">
<span class="fenster">
{{ uhrzeit(start) }} – {{ uhrzeit(ende) }} · <b>{{ dauer }} s</b>
</span>
<span class="zoom">
<button type="button" :disabled="zoom === 0" title="Näher" @click="zoomen(-1)">+</button>
<button
type="button"
:disabled="zoom === ZOOMS.length - 1"
title="Weiter"
@click="zoomen(1)"
>
−
</button>
</span>
</div>
<div
ref="strahl"
class="strahl"
@pointerdown="anfassen($event, 'bahn')"
@wheel.prevent="rad"
@pointermove="ziehen"
@pointerup="loslassen"
@pointercancel="loslassen"
>
<!-- Wo Aufnahme liegt -->
<span
v-for="(s, i) in segmente"
:key="i"
class="aufnahme"
:style="{ left: prozent(s.von) + '%', width: prozent(s.bis) - prozent(s.von) + '%' }"
></span>
<!-- Die Marke: der Moment, an dem C gedrückt wurde -->
<span class="marke" :style="{ left: prozent(0) + '%' }" title="Marke"></span>
<!-- Das Fenster des Clips mit seinen Griffen -->
<span
class="bereich"
:style="{ left: prozent(start) + '%', width: prozent(ende) - prozent(start) + '%' }"
@pointerdown.stop="anfassen($event, 'mitte')"
>
<span class="griff links" @pointerdown.stop="anfassen($event, 'links')"></span>
<span class="griff rechts" @pointerdown.stop="anfassen($event, 'rechts')"></span>
</span>
<span v-for="s in striche" :key="s.t" class="strich" :style="{ left: prozent(s.t) + '%' }">
<span class="text">{{ s.text }}</span>
</span>
</div>
</div>
</template>
<style scoped>
.trimmer {
display: flex;
flex-direction: column;
gap: 0.4rem;
user-select: none;
}
.kopf {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.fenster b {
color: var(--text);
}
.zoom button {
width: 1.7rem;
height: 1.7rem;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
}
.zoom button:disabled {
opacity: 0.4;
}
.strahl {
position: relative;
height: 3.4rem;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
cursor: grab;
touch-action: none;
}
.aufnahme {
position: absolute;
top: 0.4rem;
height: 1.6rem;
background: rgba(255, 255, 255, 0.1);
}
.marke {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: #f5c518;
}
.bereich {
position: absolute;
top: 0.4rem;
/* Bei vier Stunden Ansicht wäre das Fenster ein Strich — so bleibt es greifbar */
min-width: 2.2rem;
height: 1.6rem;
background: rgba(229, 9, 20, 0.35);
border: 2px solid var(--accent);
border-radius: 4px;
cursor: grab;
}
.griff {
position: absolute;
top: -2px;
bottom: -2px;
width: 0.7rem;
background: var(--accent);
cursor: ew-resize;
}
.griff.links {
left: -2px;
border-radius: 4px 0 0 4px;
}
.griff.rechts {
right: -2px;
border-radius: 0 4px 4px 0;
}
.strich {
position: absolute;
top: 2.2rem;
bottom: 0;
width: 1px;
background: var(--line);
pointer-events: none;
}
.text {
position: absolute;
top: 0.3rem;
left: 0.25rem;
color: var(--muted);
font-size: 0.68rem;
white-space: nowrap;
}
</style>