// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React: // Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung; // React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren. // // R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird // jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer« // zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die // Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. import { istKernNachricht, type BeschaffungStand, type BibliothekEintrag, type DiscInfoStand, type GeraeteInfo, type KernNachricht, type MetaVorschlag, type RipStatus, type WerkzeugAuskunft, } from '../gemeinsam/nachrichten' export interface Verbindung { verbunden: boolean kern: Extract | null pongLatenzMs: number | null laufwerke: GeraeteInfo[] /** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen, * bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */ rips: Record /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */ discInfos: Record /** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */ vorschlaege: Record /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ ripStart: Record /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare * Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer * Vorgang beginnt. */ ripProtokoll: Record einstellungen: Record werkzeuge: WerkzeugAuskunft | null bibliothek: BibliothekEintrag[] schluessel: { quelle: string angenommen: boolean | null weiterBetrieb: boolean 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 = 20 const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null, laufwerke: [], rips: {}, discInfos: {}, vorschlaege: {}, ripStart: {}, ripProtokoll: {}, einstellungen: {}, werkzeuge: null, bibliothek: [], schluessel: null, beschaffung: null, meldungen: [], } const horcher = new Set() let gestartet = false let port: MessagePort | null = null function melden(): void { for (const h of horcher) { h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, discInfos: { ...stand.discInfos }, vorschlaege: { ...stand.vorschlaege }, einstellungen: { ...stand.einstellungen }, bibliothek: [...stand.bibliothek], meldungen: [...stand.meldungen], }) } } function meldung(text: string): void { stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) } function kernNachricht(nachricht: KernNachricht): void { switch (nachricht.art) { case 'kern-bereit': stand.kern = nachricht break case 'pong': stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit) // Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück. window.rippy.melden('kern-pong') break case 'kern-fehler': meldung(`⚠ ${nachricht.text}`) break case 'laufwerke': stand.laufwerke = nachricht.liste for (const ereignis of nachricht.ereignisse) { if (ereignis.art === 'disc-eingelegt') { meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`) } else if (ereignis.art === 'disc-entnommen') { meldung(`Disc entnommen aus ${ereignis.geraet.name}`) } else if (ereignis.art === 'laufwerk-weg') { meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) } } break case 'auswurf-ergebnis': { const text = nachricht.grund === 'ok' ? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)` : `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}` meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`) break } case 'disc-info': { stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } if (nachricht.info.titel.length > 0) { meldung( `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, ) } break } case 'metadaten-vorschlaege': stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } break case 'einstellungen': stand.einstellungen = nachricht.werte break case 'werkzeuge': stand.werkzeuge = nachricht.auskunft break case 'bibliothek': stand.bibliothek = nachricht.eintraege break case 'schluessel-status': 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)` + (nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''), ) } else if (nachricht.status.phase === 'fehler') { meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`) } else if (nachricht.status.phase === 'abgebrochen') { meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`) } break } } melden() } function portAnnehmen(neuerPort: MessagePort): void { port = neuerPort stand.verbunden = true neuerPort.onmessage = (ereignis: MessageEvent) => { const nachricht: unknown = ereignis.data if (istKernNachricht(nachricht)) kernNachricht(nachricht) } neuerPort.start() neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) neuerPort.postMessage({ art: 'einstellungen-laden' }) neuerPort.postMessage({ art: 'werkzeuge-laden' }) neuerPort.postMessage({ art: 'bibliothek-laden' }) melden() } /** Idempotent — der erste Aufruf registriert den Horcher und meldet der * Brücke, dass gepufferte Ports jetzt zugestellt werden können. */ export function verbindungStarten(): void { if (gestartet) return gestartet = true window.addEventListener('message', (ereignis: MessageEvent) => { const daten: unknown = ereignis.data if ( typeof daten === 'object' && daten !== null && (daten as Record).art === 'kern-port' && ereignis.ports.length > 0 ) { portAnnehmen(ereignis.ports[0]) } }) window.rippy.bereit() } export function aufVerbindung(h: Horcher): () => void { horcher.add(h) h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, discInfos: { ...stand.discInfos }, vorschlaege: { ...stand.vorschlaege }, einstellungen: { ...stand.einstellungen }, bibliothek: [...stand.bibliothek], meldungen: [...stand.meldungen], }) return () => horcher.delete(h) } function anKern(nachricht: { art: string; id: string }, wasWar: string): void { if (port === null) { meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`) melden() return } port.postMessage(nachricht) } export function auswerfen(id: string): void { anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) } export function ripStarten(id: string): void { anKern({ art: 'rip-start', id }, `Rip ${id}`) } export function ripAbbrechen(id: string): void { anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`) } export function metadatenSuchen(id: string, text: string): void { if (port === null) return port.postMessage({ art: 'metadaten-suchen', id, text }) } export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void { if (port === null) return 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`) melden() return } port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) }