update
This commit is contained in:
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "horror-frontend",
|
||||
"dependencies": {
|
||||
"hls.js": "^1.7.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1134,6 +1135,12 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.1.tgz",
|
||||
"integrity": "sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.7.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import FilterSidebar from './components/FilterSidebar.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import SearchTab from './components/SearchTab.vue'
|
||||
import StreamTab from './components/StreamTab.vue'
|
||||
import WatchedTab from './components/WatchedTab.vue'
|
||||
import WheelTab from './components/WheelTab.vue'
|
||||
import SeriesCard from './components/SeriesCard.vue'
|
||||
|
||||
@@ -14,7 +16,13 @@ const error = ref('')
|
||||
const search = ref('')
|
||||
const sort = ref('rating')
|
||||
const selected = ref(null)
|
||||
const activeTab = ref('netflix')
|
||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
||||
const TAB_IDS = ['netflix', 'canon', 'search', 'wheel', 'watched', 'stream']
|
||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
||||
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
||||
|
||||
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
|
||||
const include = ref([])
|
||||
const exclude = ref([])
|
||||
|
||||
@@ -187,13 +195,48 @@ const netflixEntries = computed(() => {
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
|
||||
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||
{ id: 'stream', label: 'Stream', count: null },
|
||||
])
|
||||
|
||||
const rad = ref(null)
|
||||
|
||||
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
|
||||
// ob der Film schon lief
|
||||
const gesehen = ref([])
|
||||
|
||||
async function ladeGesehen() {
|
||||
try {
|
||||
const resp = await fetch('/api/watched')
|
||||
if (resp.ok) gesehen.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
// Ausgeblendete Filme zählen für Gäste nicht mit
|
||||
const gesehenZahl = computed(
|
||||
() => gesehen.value.filter((e) => host.value || !e.hidden).length
|
||||
)
|
||||
|
||||
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
|
||||
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
|
||||
const HOST_SCHLUESSEL = 'filmabend_host'
|
||||
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
|
||||
|
||||
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()
|
||||
host.value = !host.value
|
||||
localStorage.setItem(HOST_SCHLUESSEL, host.value ? '1' : '0')
|
||||
}
|
||||
}
|
||||
|
||||
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
|
||||
const meinVorschlag = ref(null)
|
||||
|
||||
@@ -326,11 +369,15 @@ async function load() {
|
||||
onMounted(() => {
|
||||
load()
|
||||
ladeVorschlag()
|
||||
ladeGesehen()
|
||||
window.addEventListener('keydown', tastatur)
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('keydown', tastatur))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
|
||||
<div class="page" :class="{ breit: ['search', 'wheel', 'stream', 'watched'].includes(activeTab) }">
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
@@ -342,11 +389,27 @@ onMounted(() => {
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||
</button>
|
||||
<span v-if="host" class="host">Host</span>
|
||||
</nav>
|
||||
|
||||
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
||||
|
||||
<WheelTab v-else-if="activeTab === 'wheel'" ref="rad" @open="selected = $event" />
|
||||
<WheelTab
|
||||
v-else-if="activeTab === 'wheel'"
|
||||
ref="rad"
|
||||
:host="host"
|
||||
@open="selected = $event"
|
||||
@reset="meinVorschlag = null"
|
||||
/>
|
||||
|
||||
<WatchedTab
|
||||
v-else-if="activeTab === 'watched'"
|
||||
:host="host"
|
||||
@open="selected = $event"
|
||||
@changed="ladeGesehen"
|
||||
/>
|
||||
|
||||
<StreamTab v-else-if="activeTab === 'stream'" />
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="results">
|
||||
@@ -402,7 +465,10 @@ onMounted(() => {
|
||||
@close="selected = null"
|
||||
@open="selected = $event"
|
||||
:mein-vorschlag="meinVorschlag"
|
||||
:host="host"
|
||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||
@suggested="nachVorschlag"
|
||||
@watched="ladeGesehen"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -446,6 +512,19 @@ onMounted(() => {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.host {
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
padding: 0.15rem 0.5rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tabs .count {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -6,9 +6,11 @@ const props = defineProps({
|
||||
movie: { type: Object, required: true },
|
||||
series: { type: Array, default: () => [] },
|
||||
meinVorschlag: { type: Object, default: null },
|
||||
host: { type: Boolean, default: false },
|
||||
gesehen: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'open', 'suggested'])
|
||||
const emit = defineEmits(['close', 'open', 'suggested', 'watched'])
|
||||
|
||||
const vorschlagen = ref({ zustand: 'bereit', name: '' })
|
||||
|
||||
@@ -35,6 +37,24 @@ async function abschicken() {
|
||||
}
|
||||
}
|
||||
|
||||
const merken = ref({ zustand: 'bereit' })
|
||||
|
||||
async function alsGesehen() {
|
||||
merken.value = { zustand: 'laeuft' }
|
||||
try {
|
||||
const resp = await fetch('/api/watched', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ movie_id: props.movie.id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error()
|
||||
merken.value = { zustand: 'fertig' }
|
||||
emit('watched')
|
||||
} catch {
|
||||
merken.value = { zustand: 'fehler' }
|
||||
}
|
||||
}
|
||||
|
||||
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||
const shots = computed(() =>
|
||||
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
||||
@@ -143,6 +163,23 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="host || gesehen" class="gesehen">
|
||||
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
|
||||
Steht unter „Gesehen“.
|
||||
</span>
|
||||
<button
|
||||
v-else-if="host"
|
||||
type="button"
|
||||
:disabled="merken.zustand === 'laeuft'"
|
||||
@click="alsGesehen"
|
||||
>
|
||||
Als gesehen markieren
|
||||
</button>
|
||||
<span v-if="merken.zustand === 'fehler'" class="fehler">
|
||||
Hat nicht geklappt.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -348,6 +385,32 @@ h2 {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gesehen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.gesehen button {
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.gesehen button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.gesehen button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hinweis {
|
||||
flex-basis: 100%;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -63,11 +63,6 @@ async function open(movie) {
|
||||
<p v-else-if="searched && !results.length" class="note">
|
||||
Nichts gefunden für „{{ query }}“.
|
||||
</p>
|
||||
<p v-else-if="!searched" class="note">
|
||||
Tippe einen Titel ein. Gesucht wird der ganze TMDB-Bestand, nicht nur
|
||||
der Katalog.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<MovieCard
|
||||
v-for="movie in results"
|
||||
|
||||
180
frontend/src/components/StreamTab.vue
Normal file
180
frontend/src/components/StreamTab.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
// Alle Zuschauer regeln auf dieselbe Distanz zur Live-Kante. Damit laufen
|
||||
// sie synchron, ohne dass ein Server sie taktet. hls.js passt dafür die
|
||||
// Abspielgeschwindigkeit an (maxLiveSyncPlaybackRate).
|
||||
const ZIEL_LATENZ = 4
|
||||
|
||||
const video = ref(null)
|
||||
const zustand = ref('lade')
|
||||
const fehler = ref('')
|
||||
|
||||
let hls = null
|
||||
|
||||
function verbinden() {
|
||||
const quelle = '/stream/filmabend/index.m3u8'
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
// Safari spielt HLS selbst ab, dann übernimmt der Browser die Regelung
|
||||
if (video.value.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.value.src = quelle
|
||||
zustand.value = 'laeuft'
|
||||
return
|
||||
}
|
||||
fehler.value = 'Dieser Browser kann den Stream nicht abspielen.'
|
||||
zustand.value = 'fehler'
|
||||
return
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
lowLatencyMode: true,
|
||||
liveSyncDuration: ZIEL_LATENZ,
|
||||
// Ab hier springt hls.js selbst auf die Zielposition zurück, statt
|
||||
// ewig mit 1,5-fachem Tempo aufzuholen. Zu dicht an ZIEL_LATENZ
|
||||
// führt laut Doku zu Stockern.
|
||||
liveMaxLatencyDuration: ZIEL_LATENZ + 3,
|
||||
// Über 1 erlaubt hls.js, die Geschwindigkeit anzuheben, um die
|
||||
// Ziel-Latenz zu halten. Standard ist 1, also aus.
|
||||
maxLiveSyncPlaybackRate: 1.5,
|
||||
backBufferLength: 30,
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, daten) => {
|
||||
if (!daten.fatal) return
|
||||
if (daten.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
zustand.value = 'wartet'
|
||||
// Nicht sofort neu anfragen — sonst hämmert der Browser den Server
|
||||
window.setTimeout(() => hls?.startLoad(), 3000)
|
||||
} else if (daten.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
hls.recoverMediaError()
|
||||
} else {
|
||||
fehler.value = 'Stream nicht erreichbar.'
|
||||
zustand.value = 'fehler'
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
zustand.value = 'laeuft'
|
||||
video.value.play().catch(() => {
|
||||
// Autoplay ohne Ton ist erlaubt, mit Ton nicht — dann wartet es
|
||||
// auf einen Klick
|
||||
zustand.value = 'bereit'
|
||||
})
|
||||
})
|
||||
|
||||
hls.loadSource(quelle)
|
||||
hls.attachMedia(video.value)
|
||||
// Für Diagnose im Betrieb: Latenzwerte in der Konsole nachsehen
|
||||
window.__stream = hls
|
||||
|
||||
}
|
||||
|
||||
function starten() {
|
||||
video.value?.play()
|
||||
zustand.value = 'laeuft'
|
||||
}
|
||||
|
||||
onMounted(verbinden)
|
||||
|
||||
onUnmounted(() => {
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stream-tab">
|
||||
<div class="buehne">
|
||||
<!-- Ohne Stream zeigt der Browser nur seinen eigenen Ladekringel und
|
||||
verrät nicht, warum nichts kommt -->
|
||||
<video
|
||||
v-show="zustand === 'laeuft' || zustand === 'bereit'"
|
||||
ref="video"
|
||||
class="player"
|
||||
controls
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
|
||||
<div v-if="zustand === 'wartet' || zustand === 'lade'" class="platzhalter">
|
||||
<p class="gross">
|
||||
{{ zustand === 'lade' ? 'Verbinde…' : 'Es wird gerade nichts gesendet.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button v-if="zustand === 'bereit'" class="start" type="button" @click="starten">
|
||||
Wiedergabe starten
|
||||
</button>
|
||||
|
||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stream-tab {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buehne {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.platzhalter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.gross {
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.start {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 0.8rem 1.8rem;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 1rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
425
frontend/src/components/WatchedTab.vue
Normal file
425
frontend/src/components/WatchedTab.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { abonnieren } from '../live'
|
||||
|
||||
const props = defineProps({ host: Boolean })
|
||||
const emit = defineEmits(['open', 'changed'])
|
||||
|
||||
const items = ref([])
|
||||
|
||||
// Ausgeblendete Filme bleiben für den Host sichtbar, damit er sie
|
||||
// zurückholen kann
|
||||
const sichtbar = computed(() =>
|
||||
props.host ? items.value : items.value.filter((e) => !e.hidden)
|
||||
)
|
||||
const fehler = ref('')
|
||||
const laden = ref(true)
|
||||
|
||||
// Der Name gilt für alle Bewertungen und überlebt das Neuladen
|
||||
const NAME_SCHLUESSEL = 'filmabend_name'
|
||||
const name = ref(localStorage.getItem(NAME_SCHLUESSEL) || '')
|
||||
|
||||
const STERNE = [1, 2, 3, 4, 5]
|
||||
|
||||
// Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt
|
||||
const schwebt = ref({ id: null, wert: 0 })
|
||||
|
||||
function gefuellt(item, stern) {
|
||||
if (schwebt.value.id === item.id) return stern <= schwebt.value.wert
|
||||
return item.mine >= stern
|
||||
}
|
||||
|
||||
let abbestellen = null
|
||||
|
||||
async function laden_() {
|
||||
try {
|
||||
const resp = await fetch('/api/watched')
|
||||
if (!resp.ok) throw new Error('Liste konnte nicht geladen werden')
|
||||
items.value = await resp.json()
|
||||
} catch (err) {
|
||||
fehler.value = err.message
|
||||
} finally {
|
||||
laden.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bewerten(item, sterne) {
|
||||
localStorage.setItem(NAME_SCHLUESSEL, name.value)
|
||||
// Sofort anzeigen, der Live-Kanal liefert gleich die richtigen Zahlen
|
||||
item.mine = sterne
|
||||
try {
|
||||
await fetch(`/api/watched/${item.id}/rating`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stars: sterne, name: name.value || null }),
|
||||
})
|
||||
} catch {
|
||||
fehler.value = 'Bewerten hat nicht geklappt.'
|
||||
}
|
||||
}
|
||||
|
||||
async function sichtbarkeit(item) {
|
||||
await fetch(`/api/watched/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hidden: !item.hidden }),
|
||||
})
|
||||
}
|
||||
|
||||
async function entfernen(item) {
|
||||
await fetch(`/api/watched/${item.id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
|
||||
// Für das Datumsfeld zählt der lokale Tag, nicht der in UTC
|
||||
function alsFeld(iso) {
|
||||
const d = new Date(iso)
|
||||
const zwei = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
|
||||
}
|
||||
|
||||
async function datumSetzen(item, tag) {
|
||||
if (!tag) return
|
||||
await fetch(`/api/watched/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ watched_at: tag }),
|
||||
})
|
||||
}
|
||||
|
||||
function datum(iso) {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
laden_()
|
||||
abbestellen = abonnieren((nachricht) => {
|
||||
if (nachricht.type !== 'watched') return
|
||||
laden_()
|
||||
emit('changed')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => abbestellen?.())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="watched-tab">
|
||||
<div class="kopf">
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="name"
|
||||
placeholder="Dein Name (optional)"
|
||||
maxlength="40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||
<p v-else-if="!laden && !sichtbar.length" class="note">
|
||||
Noch kein Film geschaut.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<article
|
||||
v-for="item in sichtbar"
|
||||
:key="item.id"
|
||||
class="karte"
|
||||
:class="{ versteckt: item.hidden }"
|
||||
>
|
||||
<button type="button" class="cover" @click="oeffnen(item)">
|
||||
<img
|
||||
v-if="posterUrl(item.poster_path, 'w342')"
|
||||
:src="posterUrl(item.poster_path, 'w342')"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="kein-cover">Kein Cover</span>
|
||||
</button>
|
||||
|
||||
<div class="text">
|
||||
<h3 @click="oeffnen(item)">{{ item.title }}</h3>
|
||||
<p class="meta">
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<span v-if="host" class="datum-feld">
|
||||
Geschaut am
|
||||
<input
|
||||
type="date"
|
||||
:value="alsFeld(item.watched_at)"
|
||||
@change="datumSetzen(item, $event.target.value)"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>Geschaut am {{ datum(item.watched_at) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="schnitt">
|
||||
<template v-if="item.count">
|
||||
<span class="wert">{{ item.average.toFixed(1) }}</span>
|
||||
<span class="von">von 5 ({{ item.count }})</span>
|
||||
</template>
|
||||
<span v-else class="von">Noch keine Wertung</span>
|
||||
</p>
|
||||
|
||||
<div class="wertung" @mouseleave="schwebt = { id: null, wert: 0 }">
|
||||
<button
|
||||
v-for="s in STERNE"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="stern"
|
||||
:class="{ an: gefuellt(item, s) }"
|
||||
:aria-label="`${s} von 5`"
|
||||
@mouseenter="schwebt = { id: item.id, wert: s }"
|
||||
@click="bewerten(item, s)"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="item.ratings.length" class="stimmen">
|
||||
<li v-for="(r, i) in item.ratings" :key="i">
|
||||
<span class="wer">{{ r.name || 'Anonym' }}</span>
|
||||
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="host" class="aktionen">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
:title="item.hidden ? 'Wieder einblenden' : 'Ausblenden'"
|
||||
@click="sichtbarkeit(item)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M1 12s4-7.5 11-7.5S23 12 23 12s-4 7.5-11 7.5S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<line v-if="item.hidden" x1="3" y1="21" x2="21" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon loeschen"
|
||||
title="Samt Bewertungen löschen"
|
||||
@click="entfernen(item)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kopf {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 14rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.karte {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
padding: 0.8rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cover img,
|
||||
.kein-cover {
|
||||
width: 96px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kein-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h3:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.datum-feld {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.datum-feld input {
|
||||
padding: 0.15rem 0.35rem;
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.schnitt {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.wert {
|
||||
color: #f5c518;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.von {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.wertung {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stern {
|
||||
padding: 0 0.05rem;
|
||||
color: var(--line);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stern.an {
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
.stimmen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
margin-top: 0.25rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stimmen li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.wer {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.punkte {
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
.aktionen {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.icon.loeschen:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Der Host sieht sofort, was für die anderen weg ist */
|
||||
.karte.versteckt {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 2.5rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { abonnieren } from '../live'
|
||||
|
||||
const emit = defineEmits(['open'])
|
||||
defineProps({ host: Boolean })
|
||||
const emit = defineEmits(['open', 'reset'])
|
||||
|
||||
const items = ref([])
|
||||
const mine = ref(null)
|
||||
@@ -26,16 +28,14 @@ const FARBEN = [
|
||||
]
|
||||
|
||||
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||
let quelle = null
|
||||
let abbestellen = null
|
||||
|
||||
function verbinden() {
|
||||
quelle = new EventSource('/api/events')
|
||||
quelle.onmessage = (ereignis) => {
|
||||
const nachricht = JSON.parse(ereignis.data)
|
||||
abbestellen = abonnieren((nachricht) => {
|
||||
if (nachricht.type === 'suggestions') laden_()
|
||||
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||
}
|
||||
// Der Browser verbindet nach einem Abbruch von selbst neu
|
||||
else if (nachricht.type === 'reset') zurueckgesetzt()
|
||||
})
|
||||
}
|
||||
|
||||
function abspielen(ergebnis, uebersprungen = 0) {
|
||||
@@ -101,7 +101,7 @@ onMounted(async () => {
|
||||
verbinden()
|
||||
})
|
||||
|
||||
onUnmounted(() => quelle?.close())
|
||||
onUnmounted(() => abbestellen?.())
|
||||
|
||||
defineExpose({ neu: laden_ })
|
||||
|
||||
@@ -165,6 +165,25 @@ async function drehen() {
|
||||
}
|
||||
}
|
||||
|
||||
function zurueckgesetzt() {
|
||||
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
|
||||
dauer.value = 0
|
||||
winkel.value = 0
|
||||
gewinner.value = null
|
||||
dreht.value = false
|
||||
emit('reset')
|
||||
laden_()
|
||||
}
|
||||
|
||||
async function zuruecksetzen() {
|
||||
// Der Server meldet den Reset an alle zurück, auch an uns
|
||||
try {
|
||||
await fetch('/api/suggestions/alle', { method: 'DELETE' })
|
||||
} catch {
|
||||
fehler.value = 'Zurücksetzen hat nicht geklappt.'
|
||||
}
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
@@ -174,64 +193,70 @@ async function oeffnen(item) {
|
||||
<template>
|
||||
<div class="wheel-tab">
|
||||
<div class="buehne">
|
||||
<p v-if="laden" class="note">Lade Vorschläge…</p>
|
||||
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||
<p v-else-if="!items.length" class="note">
|
||||
Noch keine Vorschläge. Öffne einen Film und klicke auf „Vorschlagen“.
|
||||
</p>
|
||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="rad-halter">
|
||||
<div class="zeiger"></div>
|
||||
<svg
|
||||
class="rad"
|
||||
:class="{ dreht }"
|
||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
||||
:style="{
|
||||
transform: `rotate(${winkel}deg)`,
|
||||
transitionDuration: `${dauer}s`,
|
||||
}"
|
||||
>
|
||||
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||
<text
|
||||
v-if="seg.breit"
|
||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
||||
:y="MITTE + 4"
|
||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
||||
class="beschriftung"
|
||||
>
|
||||
{{ seg.kurz }}
|
||||
</text>
|
||||
</g>
|
||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rad-halter">
|
||||
<div class="zeiger"></div>
|
||||
<svg
|
||||
class="rad"
|
||||
:class="{ dreht }"
|
||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
||||
:style="{
|
||||
transform: `rotate(${winkel}deg)`,
|
||||
transitionDuration: `${dauer}s`,
|
||||
}"
|
||||
>
|
||||
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
|
||||
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||
<text
|
||||
v-if="seg.breit"
|
||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
||||
:y="MITTE + 4"
|
||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
||||
class="beschriftung"
|
||||
>
|
||||
{{ seg.kurz }}
|
||||
</text>
|
||||
</g>
|
||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button class="dreh" type="button" :disabled="dreht" @click="drehen">
|
||||
<div v-if="host" class="knoepfe">
|
||||
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
|
||||
{{ dreht ? 'dreht…' : 'Drehen' }}
|
||||
</button>
|
||||
<button
|
||||
class="reset"
|
||||
type="button"
|
||||
:disabled="dreht || !items.length"
|
||||
@click="zuruecksetzen"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
||||
<img
|
||||
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:src="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:alt="gewinner.title"
|
||||
/>
|
||||
<div>
|
||||
<p class="label">Der Film des Abends</p>
|
||||
<h2>{{ gewinner.title }}</h2>
|
||||
<p class="jahr">
|
||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
||||
<img
|
||||
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:src="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:alt="gewinner.title"
|
||||
/>
|
||||
<div>
|
||||
<p class="label">Der Film des Abends</p>
|
||||
<h2>{{ gewinner.title }}</h2>
|
||||
<p class="jahr">
|
||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside v-if="items.length" class="liste">
|
||||
<aside class="liste">
|
||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||
<ul>
|
||||
<li v-for="(item, i) in items" :key="item.id">
|
||||
<button
|
||||
@@ -300,6 +325,35 @@ async function oeffnen(item) {
|
||||
border-left: 11px solid transparent;
|
||||
}
|
||||
|
||||
.knoepfe {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.reset {
|
||||
padding: 0.7rem 1.4rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.reset:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.leer {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dreh {
|
||||
padding: 0.7rem 2.2rem;
|
||||
color: #fff;
|
||||
|
||||
18
frontend/src/live.js
Normal file
18
frontend/src/live.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Ein Kanal für die ganze Seite. Mehrere Komponenten lauschen mit, ohne
|
||||
// dass jede eine eigene Verbindung aufmacht.
|
||||
|
||||
let quelle = null
|
||||
const hoerer = new Set()
|
||||
|
||||
export function abonnieren(fn) {
|
||||
hoerer.add(fn)
|
||||
if (!quelle) {
|
||||
quelle = new EventSource('/api/events')
|
||||
quelle.onmessage = (ereignis) => {
|
||||
const nachricht = JSON.parse(ereignis.data)
|
||||
hoerer.forEach((h) => h(nachricht))
|
||||
}
|
||||
// Der Browser verbindet nach einem Abbruch von selbst neu
|
||||
}
|
||||
return () => hoerer.delete(fn)
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
// Nicht direkt zu MediaMTX: das antwortet mit einem Redirect auf
|
||||
// seinen eigenen Port, und der Browser landet damit außerhalb des
|
||||
// Proxys. Das Backend folgt dem Redirect selbst.
|
||||
'/stream': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user