This commit is contained in:
team3
2026-09-30 00:59:59 +02:00
parent 7bf9667007
commit c589e389df
20 changed files with 1814 additions and 29 deletions

View File

@@ -5,10 +5,12 @@ import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
import KiTab from './components/KiTab.vue'
import PersonenTab from './components/PersonenTab.vue'
import ClipsTab from './components/ClipsTab.vue'
import MerklisteTab from './components/MerklisteTab.vue'
import SammlungFilter from './components/SammlungFilter.vue'
import MovieDetail from './components/MovieDetail.vue'
import NamensWahl from './components/NamensWahl.vue'
import FilmLogin from './components/FilmLogin.vue'
import VerwaltungTab from './components/VerwaltungTab.vue'
import WuenscheTab from './components/WuenscheTab.vue'
import SearchTab from './components/SearchTab.vue'
@@ -22,7 +24,8 @@ import { matches, useKatalog } from './composables/useKatalog'
import { REGLER, useFilter } from './composables/useFilter'
import { useLangeListe } from './composables/useLangeListe'
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden } =
useEinstellungen()
const {
runde,
@@ -140,6 +143,7 @@ const tabs = computed(() => [
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null },
...(angemeldet.value || host.value ? [{ id: 'clips', label: 'Clips', count: null }] : []),
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{
id: 'wuensche',
@@ -428,7 +432,14 @@ onMounted(load)
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
<StreamTab v-else-if="activeTab === 'stream'" />
<StreamTab v-else-if="activeTab === 'stream'" :angemeldet="angemeldet" />
<ClipsTab
v-else-if="activeTab === 'clips'"
:angemeldet="angemeldet"
:host="host"
:abende="gesehen"
/>
<div v-else class="layout">
<div class="results">
@@ -502,6 +513,14 @@ onMounted(load)
Fertig
</button>
<!-- Die Tastenkombination ohne Freischaltung: den Host-Film finden -->
<FilmLogin
v-if="hostLogin"
name="Host"
:pruefen="hostAnmelden"
@abbrechen="hostLogin = false"
/>
<MovieDetail
v-if="selected"
:movie="selected"

View File

@@ -0,0 +1,211 @@
<script setup>
// Ein Clip im Großen: Vorschau, der Zeitstrahl zum Zuschneiden, Titel,
// Schneiden. Liegt als Fenster über der Seite.
import { onMounted, onUnmounted, ref } from 'vue'
import ClipTrimmer from './ClipTrimmer.vue'
const props = defineProps({
clip: { type: Object, required: true },
})
const emit = defineEmits(['zu', 'geaendert'])
const vorher = ref(props.clip.vorher)
const dauer = ref(props.clip.dauer)
const titel = ref(props.clip.titel || '')
const segmente = ref([])
const vorschauUrl = ref('')
const schneidet = ref(false)
const fehler = ref('')
function vorschauLaden() {
vorschauUrl.value = `/api/clips/${props.clip.id}/vorschau?vorher=${vorher.value}&dauer=${dauer.value}&t=${Date.now()}`
}
async function patch(felder) {
const resp = await fetch(`/api/clips/${props.clip.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(felder),
})
if (!resp.ok) fehler.value = 'Das hat nicht geklappt.'
else emit('geaendert')
return resp.ok
}
async function schneiden() {
schneidet.value = true
fehler.value = ''
await patch({ vorher: vorher.value, dauer: dauer.value, titel: titel.value })
const resp = await fetch(`/api/clips/${props.clip.id}/schneiden`, { method: 'POST' })
if (!resp.ok) fehler.value = 'Die Aufnahme liefert diesen Ausschnitt nicht.'
else emit('geaendert')
schneidet.value = false
}
async function loeschen() {
await fetch(`/api/clips/${props.clip.id}`, { method: 'DELETE' })
emit('geaendert')
emit('zu')
}
function escape(e) {
if (e.key === 'Escape') emit('zu')
}
onMounted(async () => {
window.addEventListener('keydown', escape)
vorschauLaden()
try {
const resp = await fetch(`/api/clips/${props.clip.id}/aufnahme`)
if (resp.ok) segmente.value = await resp.json()
} catch {
/* dann ohne Aufnahmebalken */
}
})
onUnmounted(() => window.removeEventListener('keydown', escape))
</script>
<template>
<Teleport to="body">
<div class="editor-overlay" @click.self="emit('zu')">
<div class="editor">
<header>
<input
v-model="titel"
type="text"
maxlength="80"
placeholder="Titel des Clips"
class="titel"
@change="patch({ titel })"
/>
<button type="button" class="zu" title="Schließen" @click="emit('zu')">×</button>
</header>
<video :src="vorschauUrl" controls playsinline class="vorschau"></video>
<ClipTrimmer
v-model:vorher="vorher"
v-model:dauer="dauer"
:segmente="segmente"
:marke="clip.marke_at"
@ende="vorschauLaden"
/>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<div class="knoepfe">
<button type="button" class="haupt" :disabled="schneidet" @click="schneiden">
{{ schneidet ? 'Schneidet …' : clip.geschnitten ? 'Speichern' : 'Schneiden' }}
</button>
<a v-if="clip.geschnitten" :href="`/api/clips/${clip.id}/video`" download class="link"
>Herunterladen</a
>
<span class="rest"></span>
<button type="button" class="weg" @click="loeschen">Löschen</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.editor-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.8);
}
.editor {
display: flex;
width: min(100%, 960px);
max-height: 95vh;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
header {
display: flex;
gap: 0.6rem;
align-items: center;
}
.titel {
flex: 1;
padding: 0.5rem 0.7rem;
font-size: 1rem;
font-weight: 600;
}
.zu {
color: var(--muted);
font-size: 1.8rem;
line-height: 1;
}
.zu:hover {
color: var(--text);
}
.vorschau {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
}
.fehler {
color: var(--accent);
font-size: 0.85rem;
}
.knoepfe {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.knoepfe button,
.knoepfe .link {
padding: 0.5rem 1rem;
color: var(--text);
font-size: 0.88rem;
font-weight: 600;
text-decoration: none;
border: 1px solid var(--line);
border-radius: 8px;
}
.knoepfe button:hover,
.knoepfe .link:hover {
border-color: var(--accent);
}
.knoepfe .haupt {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.knoepfe .haupt:disabled {
opacity: 0.5;
}
.rest {
flex: 1;
}
.weg {
color: var(--muted);
}
</style>

View File

@@ -0,0 +1,274 @@
<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. Zeiten in Sekunden relativ zur
// Marke: 0 ist der Moment, an dem C gedrückt wurde.
import { computed, ref } from 'vue'
const props = defineProps({
// Stücke der Aufnahme: [{ von, bis }] relativ zur Marke
segmente: { type: Array, default: () => [] },
maxVorher: { type: Number, default: 600 },
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
const grenzen = computed(() => {
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxDauer }
return {
von: Math.min(...props.segmente.map((s) => s.von)),
bis: Math.max(...props.segmente.map((s) => s.bis)),
}
})
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
function anfassen(e, was) {
griff = was
ausgang = { x: e.clientX, vorher: vorher.value, dauer: dauer.value }
e.currentTarget.setPointerCapture(e.pointerId)
}
function ziehen(e) {
if (!griff || !strahl.value) return
const dt = ((e.clientX - ausgang.x) / strahl.value.clientWidth) * spanne.value
const g = grenzen.value
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, -props.maxVorher, 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
let neuerStart = -ausgang.vorher + dt
neuerStart = Math.max(g.von, -props.maxVorher, Math.min(g.bis - ausgang.dauer, neuerStart))
vorher.value = Math.round(-neuerStart)
}
}
function loslassen(e) {
if (!griff) return
griff = null
e.currentTarget.releasePointerCapture?.(e.pointerId)
emit('ende')
}
</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"
@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="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;
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;
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>

View File

@@ -0,0 +1,407 @@
<script setup>
// Die eigenen Marken und Clips als Karten, nach Film gruppiert. Ein Klick
// öffnet den Editor mit Zeitstrahl. Der Host sieht die Clips aller.
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren } from '../live'
import { backdropUrl, posterUrl } from '../images'
import ClipEditor from './ClipEditor.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
angemeldet: Boolean,
host: Boolean,
// Die gesehenen Abende — für Clips aus alten Aufnahmen
abende: { type: Array, default: () => [] },
})
// "Clip erstellen": erst den Abend wählen, dann öffnet der Editor.
// Zur Wahl stehen nur Abende, zu denen es eine Aufnahme gibt.
const wahl = ref(false)
const wahlFehler = ref('')
const tage = ref(null)
async function wahlOeffnen() {
wahl.value = true
wahlFehler.value = ''
tage.value = null
try {
const resp = await fetch('/api/clips/aufnahmen')
tage.value = resp.ok ? (await resp.json()).tage : []
} catch {
tage.value = []
}
}
const abendeMitAufnahme = computed(() =>
tage.value ? props.abende.filter((a) => tage.value.includes(a.watched_at.slice(0, 10))) : [],
)
async function erstellen(abend) {
wahlFehler.value = ''
const resp = await fetch('/api/clips/von-film', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: abend.movie_id }),
})
if (!resp.ok) {
const daten = await resp.json().catch(() => ({}))
wahlFehler.value = daten.detail || 'Das hat nicht geklappt.'
return
}
wahl.value = false
offen.value = await resp.json()
laden_()
}
const clips = ref([])
const laden = ref(true)
const fehler = ref('')
const offen = ref(null)
async function laden_() {
try {
const resp = await fetch('/api/clips')
if (!resp.ok) throw new Error('Clips konnten nicht geladen werden')
clips.value = await resp.json()
fehler.value = ''
// Der offene Clip soll den frischen Stand zeigen
if (offen.value) offen.value = clips.value.find((c) => c.id === offen.value.id) || null
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
// Nach Film gruppiert, der jüngste Abend zuerst
const gruppen = computed(() => {
const nach = new Map()
for (const c of clips.value) {
const key = c.movie_id ?? 0
if (!nach.has(key)) nach.set(key, { key, titel: c.title || 'Ohne Film', clips: [] })
nach.get(key).clips.push(c)
}
return [...nach.values()]
})
function uhrzeit(iso) {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
let abbestellen = null
onMounted(() => {
laden_()
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'clips') laden_()
})
})
onUnmounted(() => abbestellen?.())
</script>
<template>
<div class="clips-tab">
<div v-if="angemeldet" class="oben">
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
</div>
<!-- Abend wählen: nur, wo es eine Aufnahme gibt, klappt es -->
<Teleport to="body">
<div v-if="wahl" class="abendwahl-overlay" @click.self="wahl = false">
<div class="abendwahl">
<header>
<span>Zu welchem Abend?</span>
<button type="button" class="zu" @click="wahl = false">×</button>
</header>
<p v-if="wahlFehler" class="abendwahl-fehler">{{ wahlFehler }}</p>
<p v-if="tage === null" class="abendwahl-hinweis">Suche Aufnahmen …</p>
<p v-else-if="!abendeMitAufnahme.length" class="abendwahl-hinweis">
Zu keinem Abend gibt es eine Aufnahme.
</p>
<ul>
<li v-for="a in abendeMitAufnahme" :key="a.id">
<button type="button" @click="erstellen(a)">
<img v-if="a.poster_path" :src="posterUrl(a.poster_path, 'w154')" alt="" />
<span class="abend-text">
<span class="abend-titel">{{ a.title }}</span>
<span class="abend-datum">{{ datum(a.watched_at) }}</span>
</span>
</button>
</li>
</ul>
</div>
</div>
</Teleport>
<p v-if="laden" class="note">Lade Clips…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!clips.length" class="note">
Noch keine Marken. Im Stream-Reiter setzt die Taste C eine.
</p>
<section v-for="g in gruppen" :key="g.key">
<h3>{{ g.titel }}</h3>
<div class="grid">
<button v-for="c in g.clips" :key="c.id" type="button" class="karte" @click="offen = c">
<!-- Geschnitten: das Video selbst als Bild. Sonst das Filmbild
mit dem Hinweis, dass hier erst eine Marke steht. -->
<span class="bild">
<video
v-if="c.geschnitten"
:src="`/api/clips/${c.id}/video#t=0.5`"
preload="metadata"
muted
playsinline
></video>
<img v-else-if="c.poster_path" :src="backdropUrl(c.poster_path, 'w342')" alt="" />
<span v-else class="leer"></span>
<span class="dauer">{{ Math.round(c.dauer) }} s</span>
<span v-if="!c.geschnitten" class="stand">Marke</span>
</span>
<span class="text">
<span class="titel">{{ c.titel || 'Ohne Titel' }}</span>
<span class="meta">
<PersonName :name="c.name" /><span
>· {{ datum(c.marke_at) }} · {{ uhrzeit(c.marke_at) }}</span
>
</span>
</span>
</button>
</div>
</section>
<ClipEditor v-if="offen" :clip="offen" @zu="offen = null" @geaendert="laden_" />
</div>
</template>
<style scoped>
.oben {
display: flex;
justify-content: flex-end;
margin-bottom: 1.2rem;
}
.erstellen {
padding: 0.55rem 1.1rem;
color: #fff;
font-size: 0.88rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.erstellen:hover {
filter: brightness(1.15);
}
section {
margin-bottom: 2rem;
}
h3 {
margin-bottom: 0.7rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.karte {
display: flex;
flex-direction: column;
overflow: hidden;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
transition:
transform 0.15s ease,
border-color 0.15s ease;
}
.karte:hover {
transform: translateY(-3px);
border-color: var(--accent);
}
.bild {
position: relative;
display: block;
aspect-ratio: 16 / 9;
background: #000;
}
.bild video,
.bild img,
.bild .leer {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.bild img {
opacity: 0.5;
}
.dauer,
.stand {
position: absolute;
bottom: 0.45rem;
padding: 0.1rem 0.45rem;
font-size: 0.72rem;
font-weight: 600;
background: rgba(0, 0, 0, 0.75);
border-radius: 4px;
}
.dauer {
right: 0.45rem;
}
.stand {
left: 0.45rem;
color: #f5c518;
}
.text {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.7rem 0.85rem 0.8rem;
}
.titel {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0 0.3rem;
align-items: center;
color: var(--muted);
font-size: 0.78rem;
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
<style>
/* Die Abendwahl liegt per Teleport im body — daher ohne Scope */
.abendwahl-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.8);
}
.abendwahl {
display: flex;
width: min(100%, 520px);
max-height: 90vh;
flex-direction: column;
gap: 0.6rem;
padding: 1rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.abendwahl header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.abendwahl .zu {
color: var(--muted);
font-size: 1.6rem;
line-height: 1;
}
.abendwahl-fehler {
color: var(--accent);
font-size: 0.85rem;
}
.abendwahl-hinweis {
padding: 1rem 0;
color: var(--muted);
font-size: 0.9rem;
text-align: center;
}
.abendwahl ul {
list-style: none;
}
.abendwahl li button {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.45rem 0.5rem;
text-align: left;
border-radius: 8px;
}
.abendwahl li button:hover {
background: rgba(255, 255, 255, 0.06);
}
.abendwahl li img {
width: 40px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.abend-text {
display: flex;
flex-direction: column;
}
.abend-datum {
color: var(--muted);
font-size: 0.8rem;
}
</style>

View File

@@ -5,6 +5,11 @@ import Hls from 'hls.js'
// Alle Zuschauer halten dieselbe Distanz zur Live-Kante. Damit laufen sie
// synchron, ohne dass ein Server sie taktet. Ausgeglichen wird nur über
// Sprünge, nicht über die Geschwindigkeit — siehe maxLiveSyncPlaybackRate.
const props = defineProps({
// Marken setzen darf nur, wer einen Namen hat
angemeldet: Boolean,
})
const ZIEL_LATENZ = 4
const video = ref(null)
@@ -86,6 +91,31 @@ function tasteAbfangen(e) {
if (e.code === 'Space' && e.target === video.value) e.preventDefault()
}
// Eine Marke setzen: der Moment wird gemerkt, den Clip schneidet man
// später im Clips-Reiter. Taste C, damit nichts vom Film ablenkt.
const markeStand = ref('')
let markeTimer = null
async function markieren() {
if (!props.angemeldet || markeStand.value === 'läuft') return
markeStand.value = 'läuft'
try {
const resp = await fetch('/api/clips', { method: 'POST' })
markeStand.value = resp.ok ? 'gesetzt' : 'fehler'
} catch {
markeStand.value = 'fehler'
}
clearTimeout(markeTimer)
markeTimer = setTimeout(() => (markeStand.value = ''), 1800)
}
function tasteC(e) {
if (e.key !== 'c' && e.key !== 'C') return
if (e.ctrlKey || e.metaKey || e.altKey) return
if (e.target instanceof Element && e.target.matches('input, textarea')) return
markieren()
}
function verbinden() {
const quelle = '/stream/filmabend/index.m3u8'
@@ -163,6 +193,7 @@ function vollbildGewechselt() {
}
onMounted(() => {
window.addEventListener('keydown', tasteC)
verbinden()
wacheStarten()
document.addEventListener('fullscreenchange', vollbildGewechselt)
@@ -170,6 +201,7 @@ onMounted(() => {
})
onUnmounted(() => {
window.removeEventListener('keydown', tasteC)
document.removeEventListener('fullscreenchange', vollbildGewechselt)
window.removeEventListener('keydown', tasteAbfangen)
window.clearInterval(wache)
@@ -214,6 +246,16 @@ onUnmounted(() => {
@input="lautstaerkeSetzen(Number($event.target.value))"
/>
<span class="live">live</span>
<button
v-if="angemeldet"
type="button"
class="knopf clip"
:class="{ an: markeStand === 'gesetzt' }"
title="Marke setzen (Taste C)"
@click="markieren"
>
{{ markeStand === 'gesetzt' ? '✓ Marke' : markeStand === 'fehler' ? 'Fehler' : 'Clip' }}
</button>
<button
type="button"
class="knopf"
@@ -305,6 +347,19 @@ onUnmounted(() => {
}
}
.knopf.clip {
padding: 0 0.7rem;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 6px;
}
.knopf.clip.an {
color: #6bbf7a;
border-color: #6bbf7a;
}
.knopf {
padding: 0.1rem 0.3rem;
font-size: 1.05rem;

View File

@@ -12,7 +12,7 @@ const props = defineProps({
})
const liste = computed(() =>
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id)
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id),
)
const neuerName = ref('')
@@ -47,6 +47,30 @@ async function schutzZeigen(user) {
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
}
// Der Host-Film: das Passwort für den Hostmodus, gesetzt vom Host
const hostFilmDialog = ref(false)
const hostFilm = ref(null)
async function hostFilmZeigen() {
if (hostFilm.value) {
hostFilm.value = null
return
}
hostFilm.value = { laden: true }
const resp = await fetch('/api/host/film')
hostFilm.value = resp.ok ? await resp.json() : { fehler: true }
}
async function hostFilmSetzen(movieId) {
await fetch('/api/host/film', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
hostFilm.value = null
hostFilmDialog.value = false
}
async function anlegen() {
fehler.value = ''
const resp = await fetch('/api/users', {
@@ -69,8 +93,8 @@ async function entfernen(user) {
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
<p v-if="!host" class="erklaerung">
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen
Namen danach. Ohne Film bleibt dein Name für alle offen.
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen Namen danach. Ohne Film
bleibt dein Name für alle offen.
</p>
<div v-if="host" class="anlegen">
@@ -148,6 +172,43 @@ async function entfernen(user) {
<p v-else class="leer">Noch niemand in der Runde.</p>
</section>
<section v-if="host" class="hostfilm">
<h3>Host-Film</h3>
<p class="erklaerung">
Die Tastenkombination fragt ohne Freischaltung nach diesem Film. Wer ihn anklickt, ist Host
— ohne Link.
</p>
<div class="zeile">
<button type="button" class="klein" @click="hostFilmDialog = true">Film setzen</button>
<button type="button" class="klein" title="Anzeigen" @click="hostFilmZeigen">👁</button>
<button type="button" class="klein" @click="hostFilmSetzen(null)">Aufheben</button>
<span v-if="hostFilm" class="film-anzeige">
<template v-if="hostFilm.laden">…</template>
<template v-else-if="hostFilm.fehler">nicht lesbar</template>
<template v-else-if="hostFilm.film"
>{{ hostFilm.film.title }} ({{ hostFilm.film.year }})</template
>
<template v-else>keiner gesetzt</template>
</span>
</div>
</section>
<Teleport to="body">
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Host-Film</p>
<h2>Passwort für den Hostmodus</h2>
<p class="frage">Diesen Film klickt man nach der Tastenkombination an.</p>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="hostFilmSetzen($event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="hostFilmDialog = false">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
<div class="fenster" role="dialog" aria-modal="true">
@@ -161,15 +222,10 @@ async function entfernen(user) {
}}
</p>
<FilmSuche
platzhalter="Film als Passwort …"
@waehlen="schutzSetzen(setzt, $event.id)"
/>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="schutzSetzen(setzt, $event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="setzt = null">
Abbrechen
</button>
<button type="button" class="abbrechen" @click="setzt = null">Abbrechen</button>
</div>
</div>
</div>
@@ -415,4 +471,16 @@ h3 {
border-radius: 0;
}
}
.hostfilm .zeile {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.film-anzeige {
color: var(--muted);
font-size: 0.88rem;
}
</style>

View File

@@ -5,12 +5,12 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
// Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem
// Schlüssel aus der .env. Sie bleibt es, bis jemand sie abgibt.
async function hostSetzen(an, schluessel = null) {
async function hostSetzen(an, schluessel = null, movieId = null) {
try {
const resp = await fetch('/api/host', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ an, schluessel }),
body: JSON.stringify({ an, schluessel, movie_id: movieId }),
})
if (!resp.ok) return false
return (await resp.json()).host
@@ -29,7 +29,15 @@ async function hostStand() {
}
const TAB_IDS = [
'katalog', 'suche', 'wheel', 'merkliste', 'watched', 'wuensche', 'verwaltung', 'stream',
'katalog',
'suche',
'wheel',
'merkliste',
'watched',
'wuensche',
'verwaltung',
'stream',
'clips',
]
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
@@ -50,9 +58,7 @@ export function useEinstellungen() {
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus')
watch(ohneGesehene, (an) =>
localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus')
)
watch(ohneGesehene, (an) => localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus'))
// Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter
// alter Reiter "netflix" oder "canon" landet im Katalog.
@@ -101,18 +107,31 @@ export function useEinstellungen() {
watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0'))
// Ohne Freischaltung fragt die Tastenkombination nach dem Host-Film —
// wie der Anmeldefilm bei einem Namen
const hostLogin = ref(false)
async function hostAnmelden(movieId) {
const an = await hostSetzen(true, null, movieId)
if (!an) return false
freigeschaltet.value = true
anzeige.value = true
hostLogin.value = false
return true
}
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
// Ohne freigeschaltete Sitzung gibt es nichts umzuschalten
if (freigeschaltet.value) anzeige.value = !anzeige.value
else hostLogin.value = true
}
}
onMounted(() => window.addEventListener('keydown', tastatur))
onUnmounted(() => window.removeEventListener('keydown', tastatur))
return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host }
return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden }
}