update
This commit is contained in:
@@ -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"
|
||||
|
||||
211
frontend/src/components/ClipEditor.vue
Normal file
211
frontend/src/components/ClipEditor.vue
Normal 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>
|
||||
274
frontend/src/components/ClipTrimmer.vue
Normal file
274
frontend/src/components/ClipTrimmer.vue
Normal 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>
|
||||
407
frontend/src/components/ClipsTab.vue
Normal file
407
frontend/src/components/ClipsTab.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user