feat(v5): Einstellungen als erklärende Karten-Seite — Version 5.1.0
Ampel / ampel (push) Successful in 1m35s

Der Einstellungs-Tab im Kino-Mix, statt magerer Feldliste: links eine
Sektions-Navigation, rechts Karten mit je einem Satz Einordnung —
Ablage (mit Struktur-Vorschau Filme/Musik/ISO/roh), Kompression
(Schalter, gemessene Encoder als Badges, Empfehlungs-Erklärung),
Metadaten (Key-Quellen im Klartext, hinterlegt/fehlt-Punkte), MakeMKV
(eigene Karte: Live-Schlüsselstand, Update- und Prüf-Knopf,
Dauerlizenz), Medienserver, Programm (Schalter, Update-Stand,
Versionszeile) und Werkzeuge — NEU mit den drei „Eigener
Pfad'-Feldern (werkzeug.*, vom Katalog seit je unterstützt, bisher
ohne UI).

Dazu: #einstellungen/#bibliothek als Startanker (RIPPY_START_BEREICH),
RIPPY_SMOKE_WARTE_MS für Bild-Beweise, die auf späte Kern-Antworten
warten (Einstellungen+Encoder-Messung kommen nach den 750 ms).
Version 5.1.0 — erste Fassung ohne Vorab-Suffix, der Bau erzeugt
damit erstmals latest.yml (der Update-Kanal aus § 6.8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-31 22:06:38 +02:00
co-authored by Claude Fable 5
parent c12b1be448
commit b64ddf5f06
5 changed files with 392 additions and 134 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rippy", "name": "rippy",
"productName": "Rippy", "productName": "Rippy",
"version": "5.0.0-w0", "version": "5.1.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js", "main": "out/haupt/index.js",
"author": "KrBrZ", "author": "KrBrZ",
+378 -129
View File
@@ -609,6 +609,59 @@ function Kicker({ text }: { text: string }) {
return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2> return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2>
} }
/** Ein richtiger Schalter statt einer nackten Checkbox. */
function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) {
return (
<button
onClick={() => aufWechsel(!an)}
className="flex items-center gap-3 text-left"
role="switch"
aria-checked={an}
>
<span
className={`relative inline-block h-5 w-9 flex-none rounded-full transition-colors ${
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-white/12'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-schnee shadow transition-all ${an ? 'left-[18px]' : 'left-0.5'}`}
/>
</span>
<span className="text-xs leading-5 text-dunst">{label}</span>
</button>
)
}
/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */
function SektionsKarte({
id,
titel,
intro,
children,
}: {
id: string
titel: string
intro: string
children: React.ReactNode
}) {
return (
<section id={id} className="scroll-mt-4 rounded-2xl border border-white/10 bg-karte/50 p-5">
<Kicker text={titel} />
<p className="mt-1.5 max-w-xl text-xs leading-5 text-nebel">{intro}</p>
<div className="mt-4 space-y-3">{children}</div>
</section>
)
}
/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */
function HinterlegtPunkt({ wert }: { wert: string }) {
return (
<span className={`ml-2 font-mono text-[10px] ${wert.length > 0 ? 'text-gruen' : 'text-nebel'}`}>
{wert.length > 0 ? '● hinterlegt' : '○ fehlt'}
</span>
)
}
function Feld({ function Feld({
name, name,
wert, wert,
@@ -672,12 +725,64 @@ function PresetWahl({
) )
} }
/** Eine Werkzeug-Zeile: Fundort in Klartext plus der eigene Pfad, der
* die automatische Suche übersteuert (Katalog-Schritt 1). */
function WerkzeugZeile({
name,
pfad,
fehlt,
pflicht,
schluessel,
wert,
aufAendern,
}: {
name: string
pfad: string | null
fehlt: string
pflicht: boolean
schluessel: string
wert: string
aufAendern: (schluessel: string, wert: string) => void
}) {
return (
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
</p>
<Feld
name="Eigener Pfad"
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt (Tippfehler werden als FEHLT sichtbar)"
wert={wert}
aufAendern={(w) => aufAendern(schluessel, w)}
/>
</div>
)
}
function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) { function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
const werte = verbindung.einstellungen const werte = verbindung.einstellungen
const presets = verbindung.werkzeuge?.presets ?? [] const presets = verbindung.werkzeuge?.presets ?? []
const backends = verbindung.werkzeuge?.backends ?? []
const setzen = einstellungSetzen const setzen = einstellungSetzen
const empfehlungFuer = (typ: string): string => const s = verbindung.schluessel
presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], presets) const b = verbindung.beschaffung
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
const [aktiv, setAktiv] = useState('ablage')
const SEKTIONEN: Array<[string, string]> = [
['ablage', 'Ablage'],
['kompression', 'Kompression'],
['metadaten', 'Metadaten'],
['makemkv', 'MakeMKV'],
['medienserver', 'Medienserver'],
['programm', 'Programm'],
['werkzeuge', 'Werkzeuge'],
]
const springe = (id: string): void => {
setAktiv(id)
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const ordnerWaehlen = async (): Promise<void> => { const ordnerWaehlen = async (): Promise<void> => {
const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen') const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen')
@@ -685,138 +790,277 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
} }
return ( return (
<div className="max-w-2xl space-y-7"> <div className="flex gap-8">
<section className="space-y-3"> <nav className="sticky top-4 flex h-fit w-44 flex-none flex-col gap-1">
<Kicker text="ABLAGE" /> {SEKTIONEN.map(([id, name]) => (
<div className="flex items-end gap-2">
<div className="flex-1">
<Feld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} aufAendern={(w) => setzen('ablage', w)} />
</div>
<button <button
onClick={() => void ordnerWaehlen()} key={id}
className="rounded-lg border border-white/15 bg-samt/60 px-3 py-2 text-xs text-dunst hover:text-schnee" onClick={() => springe(id)}
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
aktiv === id
? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell'
: 'border border-transparent text-dunst hover:bg-white/5 hover:text-schnee'
}`}
> >
Durchsuchen {name}
</button> </button>
</div> ))}
</section> </nav>
<section className="space-y-3"> <div className="min-w-0 max-w-2xl flex-1 space-y-5">
<Kicker <SektionsKarte
text={`KOMPRESSION${verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 ? ' — HANDBRAKECLI FEHLT' : ''}`} id="ablage"
/> titel="ABLAGE"
<label className="flex items-center gap-2 text-xs text-dunst"> intro="Ein Ordner genügt — auch ein Netzlaufwerk. Rippy baut darin die Struktur, die Jellyfin, Emby und Kodi ohne Zutun verstehen."
<input >
type="checkbox" <div className="flex items-end gap-2">
checked={werte['transcodeEnabled'] !== 'false'} <div className="flex-1">
onChange={(e) => setzen('transcodeEnabled', e.target.checked ? 'true' : 'false')} <Feld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} aufAendern={(w) => setzen('ablage', w)} />
className="accent-[oklch(0.83_0.11_75)]" </div>
/> <button
Nach dem Rip komprimieren (der verlustfreie Rip bleibt bis zum Erfolg erhalten) onClick={() => void ordnerWaehlen()}
</label> className="rounded-lg border border-white/15 bg-samt/60 px-3 py-2 text-xs text-dunst hover:text-schnee"
<div className="grid gap-3 sm:grid-cols-3"> >
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} aufAendern={setzen} /> Durchsuchen
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} aufAendern={setzen} /> </button>
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} aufAendern={setzen} /> </div>
</div> <div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<Feld <p className="text-dunst">So legt Rippy ab:</p>
name="Allgemeines Preset" <p> Filme\Akira (1988)\ <span className="text-nebel/70"> Film, Poster und Infodatei beieinander</span></p>
hinweis="gilt, wo oben nichts gewählt ist" <p> Musik\ <span className="text-nebel/70"> Audio-CDs als FLAC, benannt nach MusicBrainz</span></p>
wert={werte['transcodePreset'] ?? ''} <p> ISO\ <span className="text-nebel/70"> unerkannte Discs als 1:1-Abbild</span></p>
aufAendern={(w) => setzen('transcodePreset', w)} <p> roh\ <span className="text-nebel/70"> Arbeitsstand während des Rips, räumt sich selbst auf</span></p>
/> </div>
<div className="grid gap-3 sm:grid-cols-2"> </SektionsKarte>
<Feld
name="Tonspuren (Sprachen)"
hinweis="ISO-639-2, z. B. deu,eng — leer = alle"
wert={werte['audioSprachen'] ?? ''}
aufAendern={(w) => setzen('audioSprachen', w)}
/>
<Feld
name="Untertitel (Sprachen)"
hinweis="leer = alle"
wert={werte['untertitelSprachen'] ?? ''}
aufAendern={(w) => setzen('untertitelSprachen', w)}
/>
</div>
</section>
<section className="space-y-3"> <SektionsKarte
<Kicker text="METADATEN" /> id="kompression"
<div className="grid gap-3 sm:grid-cols-2"> titel="KOMPRESSION"
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} /> intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße — der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} /> >
</div> {verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
<Feld <p className="font-mono text-xs text-warn"> HandBrakeCLI fehlt Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
name="MakeMKV-Schlüssel (Dauerlizenz)"
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
geheim
wert={werte['makemkvAppKey'] ?? ''}
aufAendern={(w) => setzen('makemkvAppKey', w)}
/>
</section>
<section className="space-y-3">
<Kicker text="MEDIENSERVER" />
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="text-xs font-semibold text-dunst">System</span>
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
<option value="none">keiner</option>
<option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option>
<option value="kodi">Kodi</option>
<option value="plex">Plex (nur Benennung)</option>
</select>
</label>
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
</div>
</section>
<section className="space-y-3">
<Kicker text="PROGRAMM" />
<label className="flex items-center gap-2 text-xs text-dunst">
<input
type="checkbox"
checked={haupt?.autostart === true}
onChange={(e) => void window.rippy.autostartSetzen(e.target.checked)}
className="accent-[oklch(0.83_0.11_75)]"
/>
Rippy mit Windows starten (HKCU\\Run kein Dienst, keine Adminrechte)
</label>
<Feld
name="Update-Adresse"
hinweis="leer = Gitea des Commanders; NUR https:// wird angenommen (§ 6.8)"
wert={werte['updateUrl'] ?? ''}
aufAendern={(w) => setzen('updateUrl', w)}
/>
{haupt !== null && (
<p className="font-mono text-xs text-nebel">
Selbst-Update: {haupt.update.text}
{haupt.update.letzteErfolgreichePruefung.length > 0 &&
` · letzte erfolgreiche Prüfung: ${haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}`}
</p>
)}
</section>
<section className="space-y-3">
<Kicker text="WERKZEUGE" />
<div className="space-y-1.5 rounded-2xl border border-white/10 bg-karte/50 p-4 font-mono text-xs">
<p className={verbindung.werkzeuge === null ? 'text-nebel' : verbindung.werkzeuge.makemkvcon.length > 0 ? 'text-gruen' : 'text-rot'}>
makemkvcon: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.makemkvcon || 'NICHT GEFUNDEN — MakeMKV installieren'}
</p>
<p className={verbindung.werkzeuge === null ? 'text-nebel' : verbindung.werkzeuge.handbrake.length > 0 ? 'text-gruen' : 'text-warn'}>
HandBrakeCLI: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.handbrake || 'nicht gefunden — Rippen geht, Kompression nicht'}
</p>
<p className={verbindung.werkzeuge === null ? 'text-nebel' : verbindung.werkzeuge.flac.length > 0 ? 'text-gruen' : 'text-warn'}>
flac: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.flac || 'nicht gefunden — nur für Audio-CDs nötig'}
</p>
{verbindung.werkzeuge !== null && verbindung.werkzeuge.backends.length > 0 && (
<p className="text-nebel">Encoder: {verbindung.werkzeuge.backends.join(', ')}</p>
)} )}
</div> <Schalter
</section> an={werte['transcodeEnabled'] !== 'false'}
aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')}
label="Nach dem Rip automatisch komprimieren"
/>
{backends.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] tracking-wider text-nebel">GEMESSENE ENCODER:</span>
{backends.map((backend) => (
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
))}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} aufAendern={setzen} />
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} aufAendern={setzen} />
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} aufAendern={setzen} />
</div>
<p className="text-[11px] leading-4 text-nebel">
automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine
die Grafikkarte, wo es sie gibt. Eine eigene Wahl schlägt die Empfehlung immer.
</p>
<Feld
name="Allgemeines Preset"
hinweis="gilt, wo oben nichts gewählt ist"
wert={werte['transcodePreset'] ?? ''}
aufAendern={(w) => setzen('transcodePreset', w)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
name="Tonspuren (Sprachen)"
hinweis="ISO-639-2, z. B. deu,eng leer = alle"
wert={werte['audioSprachen'] ?? ''}
aufAendern={(w) => setzen('audioSprachen', w)}
/>
<Feld
name="Untertitel (Sprachen)"
hinweis="leer = alle"
wert={werte['untertitelSprachen'] ?? ''}
aufAendern={(w) => setzen('untertitelSprachen', w)}
/>
</div>
</SektionsKarte>
<SektionsKarte
id="metadaten"
titel="METADATEN"
intro="TMDb erkennt Titel, Jahr, Poster und Beschreibung; OMDb hilft bei Ausreißern nach. Beide Schlüssel sind kostenlos und in zwei Minuten geholt."
>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
<p className="mt-1 font-mono text-[10px] text-nebel">
www.themoviedb.org → Einstellungen → API
<HinterlegtPunkt wert={werte['tmdbApiKey'] ?? ''} />
</p>
</div>
<div>
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
<p className="mt-1 font-mono text-[10px] text-nebel">
www.omdbapi.com/apikey.aspx
<HinterlegtPunkt wert={werte['omdbApiKey'] ?? ''} />
</p>
</div>
</div>
<p className="text-[11px] leading-4 text-nebel">
Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …"
an der Laufwerks-Kachel bleibt ohne Suche.
</p>
</SektionsKarte>
<SektionsKarte
id="makemkv"
titel="MAKEMKV"
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird — täglich."
>
{s !== null && (
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3">
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
</span>
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
</p>
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
<p className="mt-1 font-mono text-[10px] text-nebel">
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
{s !== null && s.angenommen === false && (
<button
onClick={() => makemkvUpdate()}
disabled={b !== null && b.laeuft}
className="rounded-lg bg-gradient-to-b from-warn to-[oklch(0.72_0.12_78)] px-3 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.35)] hover:brightness-105 disabled:opacity-40"
>
MakeMKV aktualisieren
</button>
)}
<button
onClick={() => schluesselPruefen()}
disabled={b !== null && b.laeuft}
className="rounded-lg border border-white/15 px-3 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-40"
>
Schlüssel neu prüfen
</button>
</div>
{b !== null && (
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>
{b.text}
</p>
)}
<Feld
name="Eigener Schlüssel (Dauerlizenz)"
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
geheim
wert={werte['makemkvAppKey'] ?? ''}
aufAendern={(w) => setzen('makemkvAppKey', w)}
/>
</SektionsKarte>
<SektionsKarte
id="medienserver"
titel="MEDIENSERVER"
intro="Nach jedem fertigen Rip sagt Rippy deinem Server Bescheid, damit der Film sofort auftaucht — ohne Warten auf den nächsten Bibliotheks-Scan."
>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="text-xs font-semibold text-dunst">System</span>
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
<option value="none">keiner</option>
<option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option>
<option value="kodi">Kodi</option>
<option value="plex">Plex (nur Benennung)</option>
</select>
</label>
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
</div>
<p className="text-[11px] leading-4 text-nebel">
Bei Plex benennt Rippy nur nach dessen Regeln angestoßen wird dort nichts. Die Ordnerstruktur
aus Ablage" passt für alle vier.
</p>
</SektionsKarte>
<SektionsKarte
id="programm"
titel="PROGRAMM"
intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü."
>
<Schalter
an={haupt?.autostart === true}
aufWechsel={(an) => void window.rippy.autostartSetzen(an)}
label="Rippy mit Windows starten (Autostart im Benutzerkonto kein Dienst, keine Adminrechte)"
/>
<Feld
name="Update-Adresse"
hinweis="leer = Standard; NUR https:// wird angenommen"
wert={werte['updateUrl'] ?? ''}
aufAendern={(w) => setzen('updateUrl', w)}
/>
{haupt !== null && (
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>Selbst-Update: {haupt.update.text}</p>
{haupt.update.letzteErfolgreichePruefung.length > 0 && (
<p>letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</p>
)}
<p>
Rippy v{haupt.version}
{verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`}
</p>
<p className="text-nebel/70">Updates warten, solange ein Rip läuft nie mitten im Vorgang.</p>
</div>
)}
</SektionsKarte>
<SektionsKarte
id="werkzeuge"
titel="WERKZEUGE"
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
>
<WerkzeugZeile
name="makemkvcon"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
pflicht={true}
schluessel="werkzeug.makemkv"
wert={werte['werkzeug.makemkv'] ?? ''}
aufAendern={setzen}
/>
<WerkzeugZeile
name="HandBrakeCLI"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
pflicht={false}
schluessel="werkzeug.handbrake"
wert={werte['werkzeug.handbrake'] ?? ''}
aufAendern={setzen}
/>
<WerkzeugZeile
name="flac"
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
fehlt="nicht gefunden — nur für Audio-CDs nötig"
pflicht={false}
schluessel="werkzeug.flac"
wert={werte['werkzeug.flac'] ?? ''}
aufAendern={setzen}
/>
{verbindung.werkzeuge !== null && (
<p className="font-mono text-[10.5px] text-nebel">
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder:{' '}
{backends.length > 0 ? backends.join(', ') : '—'}
</p>
)}
</SektionsKarte>
</div>
</div> </div>
) )
} }
@@ -1037,7 +1281,12 @@ type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek'
export default function App() { export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null) const [haupt, setHaupt] = useState<HauptStatus | null>(null)
const [bereich, setBereich] = useState<Bereich>('uebersicht') // #einstellungen / #bibliothek als Startanker — der Smoke-Beweis
// fotografiert damit auch die anderen Bereiche.
const [bereich, setBereich] = useState<Bereich>(() => {
const anker = window.location.hash.replace('#', '')
return anker === 'einstellungen' || anker === 'bibliothek' ? anker : 'uebersicht'
})
const [verbindung, setVerbindung] = useState<Verbindung>({ const [verbindung, setVerbindung] = useState<Verbindung>({
verbunden: false, verbunden: false,
kern: null, kern: null,
+8 -2
View File
@@ -28,11 +28,17 @@ export function fensterErstellen(): BrowserWindow {
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster // electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen. // (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
const devUrl = process.env['ELECTRON_RENDERER_URL'] const devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) { if (devUrl !== undefined && devUrl.length > 0) {
void fenster.loadURL(devUrl) void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
} else { } else {
void fenster.loadFile(join(__dirname, '../fenster/index.html')) void fenster.loadFile(
join(__dirname, '../fenster/index.html'),
startBereich.length > 0 ? { hash: startBereich } : undefined,
)
} }
return fenster return fenster
} }
+5 -2
View File
@@ -279,8 +279,11 @@ function start(): void {
try { try {
// Der Pong kommt schneller, als der Compositor das erste Bild malt — // Der Pong kommt schneller, als der Compositor das erste Bild malt —
// ein sofortiges capturePage lieferte hier »UnknownVizError« // ein sofortiges capturePage lieferte hier »UnknownVizError«
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist. // (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
await new Promise((fertig) => setTimeout(fertig, 750)) // RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
// späte Kern-Antworten warten.
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
const bild = await fenster.webContents.capturePage() const bild = await fenster.webContents.capturePage()
await writeFile(bildPfad, bild.toPNG()) await writeFile(bildPfad, bild.toPNG())
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`) console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)