feat(v5): MakeMKV-Selbst-Update (§ 9) und Kino-Look fürs Fenster
Ampel / ampel (push) Successful in 1m34s
Ampel / ampel (push) Successful in 1m34s
Update-Strecke: Quellenkette (Hersteller 525 → Forum → Archiv, höchste gewinnt, 31.08. nachgemessen), MZ- + Versions-Ressourcen-Prüfung (GuinpinSoft inc, gemessen), Download in .neu, Start übers Haupt (shell/UAC — Installer hängt bewusst NICHT an der Leine), Warte-Poll bis die EXE-Version wechselt, dann Schlüssel neu prüfen. Ehrlicher Zweig: installierte == neueste Fassung → kein sinnloser Download. Fenster: Laufwerks-Kachel als Bühne (TMDb-Backdrop, Poster, Beschreibung, Phasen-Label, Restzeit, aufklappbares Protokoll), Bibliothek als Poster-Regal (DB-Schema 3: poster_pfad, Migration), Schlüssel-Karte mit Update-Knopf und Beschaffungs-Balken, MakeMKV-Version als Badge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5622d8e47d
commit
d5ea48cd1e
+399
-189
@@ -1,6 +1,8 @@
|
||||
// Die Oberfläche (W-5): Übersicht (Laufwerke + Gerüst-Status),
|
||||
// Einstellungen (EINE Quelle — alles läuft über den Kern, § 6.7) und die
|
||||
// Bibliothek (§ 6.6). Design-Sprache wie das heutige Rippy: dunkel, Kacheln.
|
||||
// Die Oberfläche — seit dieser Runde im Kino-Look: Die Laufwerks-Kachel
|
||||
// wird zur Bühne (TMDb-Backdrop als Panorama, Poster, Beschreibung), die
|
||||
// Bibliothek zum Poster-Regal, der MakeMKV-Schlüssel bekommt seinen
|
||||
// Update-Knopf (§ 9). Die Regeln bleiben: Übersicht/Einstellungen/
|
||||
// Bibliothek, EINE Einstellungs-Quelle über den Kern (§ 6.7), R2/R4.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
@@ -8,9 +10,11 @@ import {
|
||||
aufVerbindung,
|
||||
auswerfen,
|
||||
einstellungSetzen,
|
||||
makemkvUpdate,
|
||||
metadatenSuchen,
|
||||
ripAbbrechen,
|
||||
ripStarten,
|
||||
schluesselPruefen,
|
||||
verbindungStarten,
|
||||
zuordnungSetzen,
|
||||
type Verbindung,
|
||||
@@ -23,12 +27,24 @@ function posterUrlKlein(pfad: string): string {
|
||||
return `https://image.tmdb.org/t/p/w154${pfad}`
|
||||
}
|
||||
|
||||
function posterUrlGross(pfad: string): string {
|
||||
if (pfad.length === 0) return ''
|
||||
if (pfad.startsWith('http')) return pfad
|
||||
return `https://image.tmdb.org/t/p/w342${pfad}`
|
||||
}
|
||||
|
||||
function backdropUrl(pfad: string): string {
|
||||
if (pfad.length === 0) return ''
|
||||
if (pfad.startsWith('http')) return pfad
|
||||
return `https://image.tmdb.org/t/p/w780${pfad}`
|
||||
}
|
||||
|
||||
function restzeitText(prozent: number, startMs: number | undefined): string {
|
||||
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
|
||||
const verstrichen = (Date.now() - startMs) / 1000
|
||||
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
|
||||
if (restS < 90) return `· ~${restS} s`
|
||||
return `· ~${Math.round(restS / 60)} min`
|
||||
if (restS < 90) return `noch ~${restS} s`
|
||||
return `noch ~${Math.round(restS / 60)} min`
|
||||
}
|
||||
|
||||
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||
@@ -51,6 +67,24 @@ const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
|
||||
unknown: 'unbekannt',
|
||||
}
|
||||
|
||||
const TYP_SYMBOL: Record<string, string> = {
|
||||
cd: '💿',
|
||||
dvd: '📀',
|
||||
bluray: '📀',
|
||||
uhd: '📀',
|
||||
iso: '💽',
|
||||
}
|
||||
|
||||
const PHASEN_NAMEN: Record<RipStatus['phase'], string> = {
|
||||
'liest-info': 'liest Titel-Info',
|
||||
rippt: 'rippt (verlustfrei)',
|
||||
komprimiert: 'komprimiert',
|
||||
'legt-ab': 'legt ab',
|
||||
fertig: 'fertig',
|
||||
fehler: 'Fehler',
|
||||
abgebrochen: 'abgebrochen',
|
||||
}
|
||||
|
||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||
|
||||
function sicherheitsWort(confidence: number): string {
|
||||
@@ -60,6 +94,20 @@ function sicherheitsWort(confidence: number): string {
|
||||
return 'unsicher'
|
||||
}
|
||||
|
||||
function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) {
|
||||
const farben = {
|
||||
gruen: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300',
|
||||
gelb: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
|
||||
blau: 'border-sky-500/40 bg-sky-500/10 text-sky-300',
|
||||
grau: 'border-slate-600/60 bg-slate-800/60 text-slate-300',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-block rounded-full border px-2 py-0.5 text-[11px] leading-4 ${farben[ton]}`}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface KachelProps {
|
||||
titel: string
|
||||
zustand: 'ok' | 'fehler' | 'wartet'
|
||||
@@ -70,7 +118,7 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
||||
const punktFarbe =
|
||||
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-amber-400'
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
||||
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{titel}</h2>
|
||||
@@ -105,9 +153,12 @@ function AendernDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={schliessen}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
||||
onClick={schliessen}
|
||||
>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-xl border border-slate-600 bg-slate-900 p-4 shadow-2xl"
|
||||
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-200">Was ist das wirklich?</h3>
|
||||
@@ -167,109 +218,224 @@ function AendernDialog({
|
||||
)
|
||||
}
|
||||
|
||||
// Die Bühne: Backdrop als Panorama, Poster, Titel — und darunter der
|
||||
// laufende Vorgang mit Phase, Restzeit und aufklappbarem Protokoll.
|
||||
function LaufwerkKachel({
|
||||
geraet,
|
||||
rip,
|
||||
info,
|
||||
vorschlaege,
|
||||
ripStartMs,
|
||||
protokoll,
|
||||
}: {
|
||||
geraet: GeraeteInfo
|
||||
rip: RipStatus | undefined
|
||||
info: DiscInfoStand | undefined
|
||||
vorschlaege: MetaVorschlag[]
|
||||
ripStartMs: number | undefined
|
||||
protokoll: string[]
|
||||
}) {
|
||||
const [aendernOffen, setAendernOffen] = useState(false)
|
||||
const [protokollOffen, setProtokollOffen] = useState(false)
|
||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
|
||||
const punktFarbe = ripLaeuft
|
||||
? 'animate-pulse bg-sky-400'
|
||||
: zustand === 'ok'
|
||||
? 'bg-emerald-400'
|
||||
: zustand === 'fehler'
|
||||
? 'bg-red-400'
|
||||
: 'bg-slate-500'
|
||||
const discDa = geraet.status === 'ready'
|
||||
const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : ''
|
||||
const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : ''
|
||||
const erkannt = discDa && info !== undefined && info.titel.length > 0
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
||||
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{geraet.name}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{ripLaeuft ? (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/70">
|
||||
{backdrop.length > 0 && (
|
||||
<>
|
||||
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/85 to-slate-950/50" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||
ripLaeuft
|
||||
? 'animate-pulse bg-sky-400'
|
||||
: discDa
|
||||
? 'bg-emerald-400'
|
||||
: geraet.status === 'empty'
|
||||
? 'bg-slate-600'
|
||||
: 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<h2 className="text-sm font-semibold tracking-wide text-slate-200">
|
||||
Laufwerk {geraet.id}:
|
||||
<span className="ml-2 font-normal text-slate-500">{geraet.modell}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-none gap-2">
|
||||
{ripLaeuft ? (
|
||||
<button
|
||||
onClick={() => ripAbbrechen(geraet.id)}
|
||||
className="rounded-lg border border-red-500/60 px-3 py-1 text-xs text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => ripStarten(geraet.id)}
|
||||
disabled={!discDa}
|
||||
className="rounded-lg border border-emerald-500/70 bg-emerald-500/10 px-4 py-1 text-xs font-semibold text-emerald-300 hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
||||
>
|
||||
▶ Rippen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => ripAbbrechen(geraet.id)}
|
||||
className="rounded-lg border border-red-500/60 px-3 py-1 text-xs text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => auswerfen(geraet.id)}
|
||||
disabled={!discDa || ripLaeuft}
|
||||
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
|
||||
>
|
||||
Abbrechen
|
||||
⏏ Auswerfen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => ripStarten(geraet.id)}
|
||||
disabled={geraet.status !== 'ready'}
|
||||
className="rounded-lg border border-emerald-500/60 px-3 py-1 text-xs text-emerald-300 hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
||||
>
|
||||
Rippen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => auswerfen(geraet.id)}
|
||||
disabled={geraet.status !== 'ready' || ripLaeuft}
|
||||
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
|
||||
>
|
||||
Auswerfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-3">
|
||||
{geraet.status === 'ready' && info !== undefined && info.posterPfad.length > 0 && (
|
||||
<img
|
||||
src={posterUrlKlein(info.posterPfad)}
|
||||
alt=""
|
||||
className="h-24 w-16 flex-none rounded-lg object-cover shadow-lg shadow-black/40"
|
||||
/>
|
||||
|
||||
{!discDa && (
|
||||
<div className="mt-4 flex items-center gap-3 py-4 text-slate-500">
|
||||
<span className="text-3xl opacity-60">💿</span>
|
||||
<p className="font-mono text-xs">
|
||||
{geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt.' : geraet.grund}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discDa && (
|
||||
<div className="mt-4 flex gap-4">
|
||||
{poster.length > 0 ? (
|
||||
<img
|
||||
src={poster}
|
||||
alt=""
|
||||
className="h-40 w-[107px] flex-none rounded-xl object-cover shadow-xl shadow-black/60 ring-1 ring-white/10"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-40 w-[107px] flex-none items-center justify-center rounded-xl bg-slate-800/80 text-4xl ring-1 ring-white/5">
|
||||
{TYP_SYMBOL[geraet.typ] ?? '💿'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{erkannt ? (
|
||||
<>
|
||||
<h3 className="truncate text-xl font-bold text-slate-50 drop-shadow">
|
||||
{info.titel}
|
||||
{info.jahr !== null && <span className="ml-2 font-normal text-slate-400">({info.jahr})</span>}
|
||||
</h3>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<Badge text={TYP_NAMEN[geraet.typ]} ton="blau" />
|
||||
{geraet.groesseBytes > 0 && <Badge text={gb(geraet.groesseBytes)} ton="grau" />}
|
||||
<Badge
|
||||
text={`${sicherheitsWort(info.confidence)} · ${Math.round(info.confidence * 100)} %${info.quelle.length > 0 ? ` · ${info.quelle}` : ''}`}
|
||||
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
|
||||
/>
|
||||
</div>
|
||||
{info.beschreibung.length > 0 && (
|
||||
<p className="mt-2 line-clamp-2 max-w-xl text-xs leading-5 text-slate-300/90">
|
||||
{info.beschreibung}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-xl font-bold text-slate-200">
|
||||
{TYP_NAMEN[geraet.typ]}
|
||||
{geraet.groesseBytes > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">{gb(geraet.groesseBytes)}</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||
{geraet.typ === 'cd'
|
||||
? 'Titel kommen beim Rippen von MusicBrainz.'
|
||||
: 'Noch keine Zuordnung — „Ändern …" hilft nach.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAendernOffen(true)}
|
||||
disabled={ripLaeuft}
|
||||
className="rounded-lg border border-slate-600/80 bg-slate-900/60 px-2.5 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
|
||||
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
|
||||
>
|
||||
Ändern …
|
||||
</button>
|
||||
{info !== undefined && info.schonGerippt !== null && (
|
||||
<Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />
|
||||
)}
|
||||
</div>
|
||||
{geraet.grund.length > 0 && <p className="mt-2 font-mono text-xs text-amber-300">{geraet.grund}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rip !== undefined && (
|
||||
<div className="mt-4 rounded-xl border border-slate-800 bg-slate-950/70 p-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p
|
||||
className={`text-xs font-semibold ${
|
||||
rip.phase === 'fehler'
|
||||
? 'text-red-300'
|
||||
: rip.phase === 'fertig'
|
||||
? rip.lesefehler
|
||||
? 'text-amber-300'
|
||||
: 'text-emerald-300'
|
||||
: 'text-sky-300'
|
||||
}`}
|
||||
>
|
||||
{PHASEN_NAMEN[rip.phase]}
|
||||
{rip.lesefehler && rip.phase === 'fertig' ? ' — mit Lesefehlern' : ''}
|
||||
</p>
|
||||
{ripLaeuft && rip.prozent >= 0 && (
|
||||
<p className="font-mono text-xs text-slate-400">
|
||||
<span className="text-base font-bold text-slate-100">{rip.prozent} %</span>
|
||||
<span className="ml-2">{restzeitText(rip.prozent, ripStartMs)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{ripLaeuft && (
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-500 to-emerald-400 transition-all"
|
||||
style={{ width: `${Math.max(0, rip.prozent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 break-all font-mono text-xs text-slate-400">{rip.text}</p>
|
||||
{rip.dateien.map((datei) => (
|
||||
<p key={datei} className="break-all font-mono text-xs text-slate-500">
|
||||
{datei}
|
||||
</p>
|
||||
))}
|
||||
{protokoll.length > 1 && (
|
||||
<button
|
||||
onClick={() => setProtokollOffen((o) => !o)}
|
||||
className="mt-2 text-xs text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
{protokollOffen ? '▾ Protokoll ausblenden' : `▸ Protokoll (${protokoll.length} Zeilen)`}
|
||||
</button>
|
||||
)}
|
||||
{protokollOffen && (
|
||||
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-slate-950 p-2">
|
||||
{protokoll.map((zeile, i) => (
|
||||
<p key={i} className="break-all font-mono text-[11px] leading-4 text-slate-500">
|
||||
{zeile}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-0.5 font-mono text-xs text-slate-400">
|
||||
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
|
||||
{geraet.status === 'ready' && (
|
||||
<p className="text-slate-200">
|
||||
{TYP_NAMEN[geraet.typ]}
|
||||
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
|
||||
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
|
||||
{sicherheitsWort(info.confidence)}: {info.titel}
|
||||
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
|
||||
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && (
|
||||
<button
|
||||
onClick={() => setAendernOffen(true)}
|
||||
disabled={ripLaeuft}
|
||||
className="mt-1 rounded border border-slate-600 px-2 py-0.5 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
|
||||
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
|
||||
>
|
||||
Ändern …
|
||||
</button>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
|
||||
<p className="text-amber-300">
|
||||
⚠ Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) — siehe Bibliothek.
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
|
||||
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
|
||||
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
|
||||
<p className="text-amber-300">{geraet.grund}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aendernOffen && (
|
||||
<AendernDialog
|
||||
id={geraet.id}
|
||||
@@ -278,39 +444,77 @@ function LaufwerkKachel({
|
||||
schliessen={() => setAendernOffen(false)}
|
||||
/>
|
||||
)}
|
||||
{rip !== undefined && (
|
||||
<div className="mt-3 space-y-1.5 border-t border-slate-700/60 pt-3">
|
||||
{ripLaeuft && (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-400 transition-all"
|
||||
style={{ width: `${Math.max(0, rip.prozent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={`break-all font-mono text-xs ${
|
||||
rip.phase === 'fehler'
|
||||
? 'text-red-300'
|
||||
: rip.phase === 'fertig'
|
||||
? rip.lesefehler
|
||||
? 'text-amber-300'
|
||||
: 'text-emerald-300'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{ripLaeuft && rip.prozent >= 0
|
||||
? `${rip.prozent} % ${restzeitText(rip.prozent, ripStartMs)} — `
|
||||
: ''}
|
||||
{rip.text}
|
||||
</p>
|
||||
{rip.dateien.map((datei) => (
|
||||
<p key={datei} className="break-all font-mono text-xs text-slate-500">
|
||||
{datei}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Die MakeMKV-Schlüssel-Karte (§ 9.1) — mit dem Update-Weg aus § 9:
|
||||
// „MakeMKV aktualisieren" holt den Installer über die Quellenkette,
|
||||
// prüft ihn und lässt Windows fragen (UAC). Danach prüft Rippy neu.
|
||||
function SchluesselKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const s = verbindung.schluessel
|
||||
const b = verbindung.beschaffung
|
||||
const zustand: 'ok' | 'fehler' | 'wartet' =
|
||||
s === null ? 'wartet' : s.angenommen === true ? 'ok' : s.weiterBetrieb ? 'wartet' : 'fehler'
|
||||
const punktFarbe =
|
||||
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-amber-400'
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
|
||||
<h2 className="text-sm font-semibold tracking-wide text-slate-200">MakeMKV-Schlüssel</h2>
|
||||
{s !== null && s.version.length > 0 && <Badge text={`MakeMKV ${s.version}`} ton="grau" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{s !== null && s.angenommen === false && (
|
||||
<button
|
||||
onClick={() => makemkvUpdate()}
|
||||
disabled={b !== null && b.laeuft}
|
||||
className="rounded-lg border border-sky-500/60 bg-sky-500/10 px-3 py-1 text-xs font-semibold text-sky-300 hover:bg-sky-500/20 disabled:opacity-40"
|
||||
title="Holt den offiziellen Installer über die Quellenkette (Hersteller → Archiv), prüft ihn und startet ihn — Windows fragt einmal nach Adminrechten."
|
||||
>
|
||||
MakeMKV aktualisieren …
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => schluesselPruefen()}
|
||||
disabled={b !== null && b.laeuft}
|
||||
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
|
||||
title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird"
|
||||
>
|
||||
Neu prüfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{s === null ? (
|
||||
<p className="font-mono text-xs text-slate-400">wird geprüft …</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-mono text-xs leading-5 text-slate-300">{s.text}</p>
|
||||
<p className="font-mono text-xs text-slate-500">
|
||||
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
|
||||
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{b !== null && (
|
||||
<div className="mt-2 rounded-lg border border-slate-800 bg-slate-950/70 p-2">
|
||||
<p className={`font-mono text-xs leading-5 ${b.fehler ? 'text-red-300' : b.laeuft ? 'text-sky-300' : 'text-emerald-300'}`}>
|
||||
{b.text}
|
||||
</p>
|
||||
{b.laeuft && b.anteil !== null && (
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-400 transition-all"
|
||||
style={{ width: `${Math.round(b.anteil * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -517,7 +721,7 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Werkzeuge</h2>
|
||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4 font-mono text-xs">
|
||||
<div className="space-y-1 rounded-xl border border-slate-800 bg-slate-900/70 p-4 font-mono text-xs">
|
||||
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.makemkvcon.length > 0 ? 'text-emerald-300' : 'text-red-300'}>
|
||||
makemkvcon: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.makemkvcon || 'NICHT GEFUNDEN — MakeMKV installieren'}
|
||||
</p>
|
||||
@@ -536,7 +740,7 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bibliothek ──────────────────────────────────────────────────────────
|
||||
// ── Bibliothek: das Poster-Regal (§ 6.6) ────────────────────────────────
|
||||
function Bibliothek({ verbindung }: { verbindung: Verbindung }) {
|
||||
if (verbindung.bibliothek.length === 0) {
|
||||
return (
|
||||
@@ -545,37 +749,41 @@ function Bibliothek({ verbindung }: { verbindung: Verbindung }) {
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const gesamt = verbindung.bibliothek.reduce((summe, e) => summe + e.groesseBytes, 0)
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
|
||||
<table className="w-full text-left font-mono text-xs">
|
||||
<thead className="bg-slate-800/60 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Titel</th>
|
||||
<th className="px-3 py-2">Typ</th>
|
||||
<th className="px-3 py-2">Größe</th>
|
||||
<th className="px-3 py-2">Datum</th>
|
||||
<th className="px-3 py-2">Dauer</th>
|
||||
<th className="px-3 py-2">Ablageort</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-slate-300">
|
||||
{verbindung.bibliothek.map((eintrag) => (
|
||||
<tr key={eintrag.fingerprint} className="border-t border-slate-700/40">
|
||||
<td className="px-3 py-2">
|
||||
{eintrag.titel}
|
||||
{eintrag.jahr !== null ? ` (${eintrag.jahr})` : ''}
|
||||
</td>
|
||||
<td className="px-3 py-2">{eintrag.typ}</td>
|
||||
<td className="px-3 py-2">{gb(eintrag.groesseBytes)}</td>
|
||||
<td className="px-3 py-2">{eintrag.datum.slice(0, 16).replace('T', ' ')}</td>
|
||||
<td className="px-3 py-2">{Math.round(eintrag.dauerS / 60)} min</td>
|
||||
<td className="max-w-64 truncate px-3 py-2 text-slate-500" title={eintrag.ablageort}>
|
||||
{eintrag.ablageort}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-4">
|
||||
<p className="font-mono text-xs text-slate-500">
|
||||
{verbindung.bibliothek.length} {verbindung.bibliothek.length === 1 ? 'Eintrag' : 'Einträge'} ·{' '}
|
||||
{gb(gesamt)} gesamt
|
||||
</p>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-4">
|
||||
{verbindung.bibliothek.map((eintrag) => (
|
||||
<div
|
||||
key={eintrag.fingerprint}
|
||||
className="group"
|
||||
title={`${eintrag.ablageort}\ngerippt am ${eintrag.datum.slice(0, 16).replace('T', ' ')} · ${Math.round(eintrag.dauerS / 60)} min`}
|
||||
>
|
||||
{eintrag.posterPfad.length > 0 ? (
|
||||
<img
|
||||
src={posterUrlGross(eintrag.posterPfad)}
|
||||
alt=""
|
||||
className="aspect-[2/3] w-full rounded-xl object-cover shadow-lg shadow-black/40 ring-1 ring-white/10 transition-transform group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-[2/3] w-full items-center justify-center rounded-xl bg-slate-800/80 text-5xl ring-1 ring-white/5 transition-transform group-hover:scale-[1.03]">
|
||||
{TYP_SYMBOL[eintrag.typ] ?? '🎬'}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 truncate text-xs font-semibold text-slate-200">
|
||||
{eintrag.titel}
|
||||
{eintrag.jahr !== null ? ` (${eintrag.jahr})` : ''}
|
||||
</p>
|
||||
<p className="font-mono text-[11px] text-slate-500">
|
||||
{TYP_NAMEN[eintrag.typ as GeraeteInfo['typ']] ?? eintrag.typ} · {gb(eintrag.groesseBytes)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -757,10 +965,12 @@ export default function App() {
|
||||
discInfos: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
beschaffung: null,
|
||||
meldungen: [],
|
||||
})
|
||||
|
||||
@@ -773,6 +983,7 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
const db = verbindung.kern?.datenbank ?? null
|
||||
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||
|
||||
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
||||
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
||||
@@ -793,12 +1004,23 @@ export default function App() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-900 text-slate-100">
|
||||
<header className="border-b border-slate-700/60 px-6 py-4">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
|
||||
<span className="font-mono text-xs text-slate-400">v{haupt?.version ?? '…'}</span>
|
||||
<div className="flex min-h-screen flex-col bg-slate-950 text-slate-100">
|
||||
<header className="border-b border-slate-800 bg-slate-950/80 px-6 py-3.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500/30 to-emerald-500/20 text-lg ring-1 ring-white/10">
|
||||
💿
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-xl font-bold tracking-tight">Rippy</h1>
|
||||
<span className="font-mono text-xs text-slate-500">v{haupt?.version ?? '…'}</span>
|
||||
{einRipLaeuft && (
|
||||
<span className="ml-1 inline-flex items-center gap-1.5 rounded-full border border-sky-500/40 bg-sky-500/10 px-2 py-0.5 text-[11px] text-sky-300">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-sky-400" />
|
||||
arbeitet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex gap-1">
|
||||
{knopf('uebersicht', 'Übersicht')}
|
||||
@@ -821,7 +1043,7 @@ export default function App() {
|
||||
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{verbindung.laufwerke.map((geraet) => (
|
||||
<LaufwerkKachel
|
||||
key={geraet.id}
|
||||
@@ -830,6 +1052,7 @@ export default function App() {
|
||||
info={verbindung.discInfos[geraet.id]}
|
||||
vorschlaege={verbindung.vorschlaege[geraet.id] ?? []}
|
||||
ripStartMs={verbindung.ripStart[geraet.id]}
|
||||
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -838,11 +1061,18 @@ export default function App() {
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Status
|
||||
MakeMKV
|
||||
</h2>
|
||||
<div className="grid content-start gap-4 sm:grid-cols-2">
|
||||
<SchluesselKarte verbindung={verbindung} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
System
|
||||
</h2>
|
||||
<div className="grid content-start gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Kachel
|
||||
titel="Kern (utilityProcess)"
|
||||
titel="Kern"
|
||||
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
|
||||
zeilen={
|
||||
verbindung.kern !== null
|
||||
@@ -854,23 +1084,23 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Datenbank (node:sqlite)"
|
||||
titel="Datenbank"
|
||||
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
|
||||
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Prozess-Leine (Job Object)"
|
||||
titel="Prozess-Leine"
|
||||
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
|
||||
zeilen={
|
||||
haupt === null
|
||||
? ['wartet auf Haupt …']
|
||||
: haupt.leine.ok
|
||||
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
|
||||
? ['gesetzt — Werkzeuge sterben mit Rippy']
|
||||
: [haupt.leine.fehler ?? 'nicht gesetzt']
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Fenster ↔ Kern (MessagePort)"
|
||||
titel="Fenster ↔ Kern"
|
||||
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
|
||||
zeilen={
|
||||
verbindung.pongLatenzMs !== null
|
||||
@@ -878,26 +1108,6 @@ export default function App() {
|
||||
: ['Ping unterwegs …']
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="MakeMKV-Schlüssel (§ 9.1)"
|
||||
zustand={
|
||||
verbindung.schluessel === null
|
||||
? 'wartet'
|
||||
: verbindung.schluessel.angenommen === true
|
||||
? 'ok'
|
||||
: verbindung.schluessel.weiterBetrieb
|
||||
? 'wartet' // gelb: Rips laufen, aber mit Handlungsempfehlung
|
||||
: 'fehler'
|
||||
}
|
||||
zeilen={
|
||||
verbindung.schluessel === null
|
||||
? ['wird geprüft …']
|
||||
: [
|
||||
verbindung.schluessel.text,
|
||||
`zuletzt geprüft: ${verbindung.schluessel.geholtAm.slice(0, 16).replace('T', ' ')}`,
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -906,7 +1116,7 @@ export default function App() {
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Ereignisse
|
||||
</h2>
|
||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto rounded-xl border border-slate-800 bg-slate-900/70 p-4">
|
||||
{verbindung.meldungen.map((zeile, i) => (
|
||||
<p key={i} className="break-all font-mono text-xs text-slate-400">
|
||||
{zeile}
|
||||
@@ -922,7 +1132,7 @@ export default function App() {
|
||||
{!setupOffen && bereich === 'bibliothek' && <Bibliothek verbindung={verbindung} />}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
|
||||
<footer className="border-t border-slate-800 px-6 py-3 text-center text-xs text-slate-600">
|
||||
Rippy v5 · LucyAI · Claude · KrBrZ
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import {
|
||||
istKernNachricht,
|
||||
type BeschaffungStand,
|
||||
type BibliothekEintrag,
|
||||
type DiscInfoStand,
|
||||
type GeraeteInfo,
|
||||
@@ -31,6 +32,10 @@ export interface Verbindung {
|
||||
vorschlaege: Record<string, MetaVorschlag[]>
|
||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||
ripStart: Record<string, number>
|
||||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||||
* Vorgang beginnt. */
|
||||
ripProtokoll: Record<string, string[]>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
@@ -41,14 +46,17 @@ export interface Verbindung {
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
version: string
|
||||
} | null
|
||||
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||
beschaffung: BeschaffungStand | null
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
|
||||
type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const MAX_MELDUNGEN = 8
|
||||
const MAX_MELDUNGEN = 20
|
||||
|
||||
const stand: Verbindung = {
|
||||
verbunden: false,
|
||||
@@ -59,10 +67,12 @@ const stand: Verbindung = {
|
||||
discInfos: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
beschaffung: null,
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
@@ -146,15 +156,30 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
stand.schluessel = nachricht.stand
|
||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'beschaffung-status':
|
||||
stand.beschaffung = nachricht.stand
|
||||
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
|
||||
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
|
||||
} else if (!laeuft) {
|
||||
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||
stand.ripStart = rest
|
||||
}
|
||||
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
|
||||
// wiederholen den Text — nur echte Änderungen zählen).
|
||||
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
|
||||
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
|
||||
stand.ripProtokoll = {
|
||||
...stand.ripProtokoll,
|
||||
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
|
||||
}
|
||||
}
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||
@@ -251,6 +276,20 @@ export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function makemkvUpdate(): void {
|
||||
if (port === null) {
|
||||
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'makemkv-update' })
|
||||
}
|
||||
|
||||
export function schluesselPruefen(): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'schluessel-pruefen' })
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||
|
||||
Reference in New Issue
Block a user