diff --git a/beweise/klick/01-uebersicht.png b/beweise/klick/01-uebersicht.png index 4840697..5508bc7 100644 Binary files a/beweise/klick/01-uebersicht.png and b/beweise/klick/01-uebersicht.png differ diff --git a/beweise/klick/02-dialog-vorwahl.png b/beweise/klick/02-dialog-vorwahl.png new file mode 100644 index 0000000..ac5322e Binary files /dev/null and b/beweise/klick/02-dialog-vorwahl.png differ diff --git a/beweise/klick/02-dialog.png b/beweise/klick/02-dialog.png deleted file mode 100644 index 61f101f..0000000 Binary files a/beweise/klick/02-dialog.png and /dev/null differ diff --git a/beweise/klick/03-dialog.png b/beweise/klick/03-dialog.png new file mode 100644 index 0000000..0526241 Binary files /dev/null and b/beweise/klick/03-dialog.png differ diff --git a/beweise/klick/03-fertig.png b/beweise/klick/03-fertig.png deleted file mode 100644 index e34025d..0000000 Binary files a/beweise/klick/03-fertig.png and /dev/null differ diff --git a/beweise/klick/04-fertig.png b/beweise/klick/04-fertig.png new file mode 100644 index 0000000..b5285ef Binary files /dev/null and b/beweise/klick/04-fertig.png differ diff --git a/beweise/klick/04-roh-dateien.png b/beweise/klick/04-roh-dateien.png deleted file mode 100644 index 3875276..0000000 Binary files a/beweise/klick/04-roh-dateien.png and /dev/null differ diff --git a/beweise/klick/05-einstellungen.png b/beweise/klick/05-einstellungen.png deleted file mode 100644 index 8692f22..0000000 Binary files a/beweise/klick/05-einstellungen.png and /dev/null differ diff --git a/beweise/klick/05-roh-dateien.png b/beweise/klick/05-roh-dateien.png new file mode 100644 index 0000000..d2f679e Binary files /dev/null and b/beweise/klick/05-roh-dateien.png differ diff --git a/beweise/klick/06-einstellungen.png b/beweise/klick/06-einstellungen.png new file mode 100644 index 0000000..be3b348 Binary files /dev/null and b/beweise/klick/06-einstellungen.png differ diff --git a/beweise/klick/06-ereignisse.png b/beweise/klick/06-ereignisse.png deleted file mode 100644 index 5278d68..0000000 Binary files a/beweise/klick/06-ereignisse.png and /dev/null differ diff --git a/beweise/klick/07-einrichtung-willkommen.png b/beweise/klick/07-einrichtung-willkommen.png deleted file mode 100644 index d6cf048..0000000 Binary files a/beweise/klick/07-einrichtung-willkommen.png and /dev/null differ diff --git a/beweise/klick/07-hell-modus.png b/beweise/klick/07-hell-modus.png new file mode 100644 index 0000000..60792be Binary files /dev/null and b/beweise/klick/07-hell-modus.png differ diff --git a/beweise/klick/08-einrichtung-qualitaet.png b/beweise/klick/08-einrichtung-qualitaet.png deleted file mode 100644 index 6cfa81e..0000000 Binary files a/beweise/klick/08-einrichtung-qualitaet.png and /dev/null differ diff --git a/beweise/klick/08-ereignisse.png b/beweise/klick/08-ereignisse.png new file mode 100644 index 0000000..8db423c Binary files /dev/null and b/beweise/klick/08-ereignisse.png differ diff --git a/beweise/klick/09-einrichtung-fertig.png b/beweise/klick/09-einrichtung-fertig.png deleted file mode 100644 index c2edb26..0000000 Binary files a/beweise/klick/09-einrichtung-fertig.png and /dev/null differ diff --git a/beweise/klick/09-einrichtung-willkommen.png b/beweise/klick/09-einrichtung-willkommen.png new file mode 100644 index 0000000..06236f1 Binary files /dev/null and b/beweise/klick/09-einrichtung-willkommen.png differ diff --git a/beweise/klick/10-einrichtung-qualitaet.png b/beweise/klick/10-einrichtung-qualitaet.png new file mode 100644 index 0000000..92e06f9 Binary files /dev/null and b/beweise/klick/10-einrichtung-qualitaet.png differ diff --git a/beweise/klick/11-einrichtung-fertig.png b/beweise/klick/11-einrichtung-fertig.png new file mode 100644 index 0000000..fcc5e19 Binary files /dev/null and b/beweise/klick/11-einrichtung-fertig.png differ diff --git a/rippy-windows/BAUEN.md b/rippy-windows/BAUEN.md index 160be10..03472dd 100644 --- a/rippy-windows/BAUEN.md +++ b/rippy-windows/BAUEN.md @@ -34,7 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`. | `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` | | `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema | | `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). | -| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). | +| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). 5.6.0: dazu die Untertitel-Vorwahl (nur bei Extras), „anderer Film …" je Titel, die Restzeit in der Kompressions-Karte (das nachgebaute HandBrake schreibt die ETA-Klammer wie das Binary), die Kachel-Kurzzeile, die Vorgangs-Leiste (TITEL LESEN ✓ → RIPPEN aktiv → KOMPRIMIEREN → ABLEGEN, keine „PHASE x/4"-Texte mehr) und der Hell-Modus (Bild `07-hell-modus.png`). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). | Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er fasst die echten Einstellungen nicht an. diff --git a/rippy-windows/bau/klick-smoke.mjs b/rippy-windows/bau/klick-smoke.mjs index c6fcbc4..1f4db0b 100644 --- a/rippy-windows/bau/klick-smoke.mjs +++ b/rippy-windows/bau/klick-smoke.mjs @@ -143,6 +143,27 @@ await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel await dialog().getByText(/2 von 3 gewählt/).waitFor() }) +await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => { + // Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra — + // erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen. + const rollen = dialog().locator('select') + await rollen.nth(0).selectOption('hauptfilm') + await rollen.nth(1).selectOption('hauptfilm') + if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar') + await rollen.nth(1).selectOption('extra') + await dialog().getByText('BEIM ABSPIELEN AN').waitFor() + const vorwahl = dialog().locator('select').nth(2) + await vorwahl.selectOption('deu') + const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count() + if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`) + await dialog().getByRole('button', { name: 'anderer Film …' }).first().click() + await dialog().getByPlaceholder('Filmtitel suchen …').waitFor() + await dialog().getByRole('button', { name: 'anderer Film …' }).first().click() + await rollen.nth(1).selectOption('hauptfilm') + if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar') +}) +await bild('dialog-vorwahl') + await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => { const rollen = dialog().locator('select') await rollen.nth(0).selectOption('folge') @@ -162,9 +183,18 @@ await schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () => }) await bild('dialog') -await schritt('Rip starten → Phase RIPPT', async () => { +await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => { await dialog().getByRole('button', { name: /Titel rippen/ }).click() await seite.getByText(/RIPPT/).first().waitFor() + if ((await seite.getByText('Automatik gestoppt').count()) !== 0) throw new Error('„Automatik gestoppt" steht noch unter dem laufenden Rip') +}) + +await schritt('5.6.0: Vorgangs-Leiste in der Kachel — TITEL LESEN fertig, RIPPEN aktiv, KOMPRIMIEREN und ABLEGEN offen', async () => { + await seite.getByText('TITEL LESEN', { exact: true }).first().waitFor() + await seite.getByText('RIPPEN', { exact: true }).first().waitFor() + await seite.getByText('KOMPRIMIEREN', { exact: true }).first().waitFor() + await seite.getByText('ABLEGEN', { exact: true }).first().waitFor() + if ((await seite.getByText(/PHASE [0-9]\/4/).count()) !== 0) throw new Error('alte PHASE-x/4-Texte sind noch da') }) await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => { @@ -172,9 +202,13 @@ await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => { await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor() }) -await schritt('Kompressions-Karte läuft und die Ablage endet', async () => { +await schritt('Kompressions-Karte läuft mit Restzeit (ETA aus HandBrakes Zeile), die Kachel zeigt nur eine Kurzzeile', async () => { await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 }) - await seite.getByText(/Komprimiert und abgelegt/).waitFor({ timeout: 60_000 }) + await seite.getByText(/^noch /).first().waitFor({ timeout: 20_000 }) + // Die Leiste in der Karte: zwei Stationen fertig (✓), KOMPRIMIEREN aktiv. + if ((await seite.getByText('KOMPRIMIEREN', { exact: true }).count()) < 1) throw new Error('keine Leiste in der Kompressions-Karte') + if ((await seite.getByText(/KOMPRESSION LÄUFT/).count()) !== 0) throw new Error('Kachel zeigt noch den vollen Kompressions-Block') + await seite.getByText(/Kompression fertig — das Ergebnis steht im Verlauf/).waitFor({ timeout: 60_000 }) }) await bild('fertig') @@ -250,6 +284,17 @@ await schritt('Roh-Dateien löschen → „gelöscht"', async () => { await seite.getByText(/Roh: gelöscht/).first().waitFor() }) +await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => { + await seite.locator('#allgemein').locator('select').first().selectOption('hell') + await seite.locator('html[data-design="hell"]').waitFor() +}) +await bild('hell-modus') + +await schritt('zurück in den Kinosaal (dunkel)', async () => { + await seite.locator('#allgemein').locator('select').first().selectOption('dunkel') + await seite.locator('html[data-design="dunkel"]').waitFor() +}) + await schritt('Ereignisse nennen die Kompression', async () => { await seite.getByRole('button', { name: 'Übersicht' }).click() await seite.getByText(/Kompression „/).first().waitFor() @@ -312,6 +357,6 @@ await schritt('Fertig: der Knopf Los geht’s', async () => { }) await bild('einrichtung-fertig') -console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Erst-Einrichtung: alles geklickt.') +console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.') await app.close().catch(() => {}) process.exit(0) diff --git a/rippy-windows/bau/klick/fake-handbrake.cjs b/rippy-windows/bau/klick/fake-handbrake.cjs index 9ad611e..c7c58a0 100644 --- a/rippy-windows/bau/klick/fake-handbrake.cjs +++ b/rippy-windows/bau/klick/fake-handbrake.cjs @@ -4,7 +4,9 @@ // --help → Encoder-Block „-e, --encoder" bis zur nächsten Option // --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung // --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited." -// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert +// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert; +// ab der zweiten Stufe mit der fps/ETA-Klammer, wie das +// Binary sie formatiert (5.6.0, Restzeit). const fs = require('node:fs') const args = process.argv.slice(2) @@ -74,10 +76,16 @@ out('Scanning title 1 of 1, preview 10, 100.00 %') let schritt = 0 const stufen = [5, 30, 55, 80, 100] const tick = () => { - out(`Encoding: task 1 of 1, ${stufen[schritt].toFixed(2)} %`) + const prozent = stufen[schritt].toFixed(2) + if (schritt === 0) out(`Encoding: task 1 of 1, ${prozent} %`) + else { + // Formatstring aus dem Binary (1.11.2): „%.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)" + const rest = (stufen.length - 1 - schritt) * 300 + out(`Encoding: task 1 of 1, ${prozent} % (48.00 fps, avg 47.50 fps, ETA 00h${String(Math.floor(rest / 60)).padStart(2, '0')}m${String(rest % 60).padStart(2, '0')}s)`) + } schritt += 1 if (schritt < stufen.length) { - setTimeout(tick, 300) + setTimeout(tick, 500) return } fs.copyFileSync(eingabe, ausgabe) diff --git a/rippy-windows/bau/release-notes.md b/rippy-windows/bau/release-notes.md index 94448db..8397259 100644 --- a/rippy-windows/bau/release-notes.md +++ b/rippy-windows/bau/release-notes.md @@ -1,3 +1,40 @@ +Version 5.6.0 + +Mehrere Filme auf einer Disc. +Im Auswahl-Dialog bekommt jeder Titel "anderer Film ..." mit TMDb-Suche +(Double Feature, Trilogie-Disc, Bonus-Film). Jeder Film erhaelt seinen +eigenen Roh-Ordner, Vorgang und Zielordner mit Poster und Infodatei; der +Verlauf zeigt beide. + +Ordner und Dateien tragen den Filmnamen. +Der Roh-Ordner heisst wie der Film ("Akira (1988)", bei Wiederholung mit +Zusatz), die Dateien auch: "Akira (1988).mkv", zwei Fassungen "Akira (1988) +- Fassung 1.mkv", Zugaben "Akira (1988) - Extra 01.mkv" im Unterordner +extras. Folgen behalten ihren Namen, bis die Episoden-Zuordnung sie +benennt; unerkannte Discs behalten den Zeitstempel-Namen. + +Untertitel-Vorwahl je Extra. +Bei Zugaben (z. B. japanisches Bonus-Material) legst du im Dialog fest, +welche Untertitel-Spur beim Abspielen schon an ist - als weiche Spur, +nichts wird eingebrannt, immer optional. Rippys Regel (fremder Ton -> +Wunschsprache) bleibt die Vorgabe. + +Eine Vorgangs-Leiste statt "Phase 1-4 plus Komprimierung". +Jede Disc hat EINEN Weg mit vier Stationen: Titel lesen, Rippen, +Komprimieren, Ablegen (das Rettungs-Abbild schiebt sich bei Bedarf davor). +Die Leiste zeigt immer den ganzen Weg, der Segment-Balken sitzt in der +aktiven Station: in der Laufwerks-Kachel, solange das Laufwerk arbeitet, +danach in der Kompressions-Karte mit HandBrakes eigener Restzeit ("noch +~12 min"), im Verlauf als vier Punkte. Die Kachel zeigt nach dem Rip nur +noch eine Zeile, wo es weitergeht. + +Hell-Modus. +Einstellungen -> Allgemein -> Erscheinungsbild: Dunkel (der Kinosaal), +Hell (Papier und Gold) oder wie Windows. + +Dazu: Die Kopfzeile mit Minimieren, Maximieren und Schliessen bleibt beim +Scrollen stehen. + Version 5.5.0 Uebersicht und Einstellungen neu sortiert. diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index 9fe2848..8c1a32c 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "rippy", - "version": "5.5.0", + "version": "5.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rippy", - "version": "5.5.0", + "version": "5.6.0", "license": "UNLICENSED", "dependencies": { "electron-updater": "^6.8.9", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index 849f2c7..081f049 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -1,7 +1,7 @@ { "name": "rippy", "productName": "Rippy", - "version": "5.5.0", + "version": "5.6.0", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "main": "out/haupt/index.js", "author": "KrBrZ", diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index bf70f79..5a4293c 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -37,7 +37,7 @@ function istHauptStatus(wert: unknown): wert is HauptStatus { /** Eine LED der Fußleiste (5.5.0): grün/gelb/rot/grau plus Klartext — * statt PID und Ping-Millisekunden aus dem Gerüst-Beweis. */ function Led({ ton, text, titel }: { ton: 'gruen' | 'warn' | 'rot' | 'grau'; text: string; titel?: string }) { - const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-white/25' + const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-tinte/25' return ( @@ -65,6 +65,22 @@ export default function App() { return aufVerbindung(setVerbindung) }, []) + // 5.6.0 Hell-Modus: die Einstellung „design" schaltet data-design auf + // (stil.css überschreibt dann die Farb-Tokens); „system" folgt + // Windows. + const design = verbindung.einstellungen['design'] ?? 'dunkel' + useEffect(() => { + const anwenden = (): void => { + const hell = design === 'hell' || (design === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches) + document.documentElement.dataset['design'] = hell ? 'hell' : 'dunkel' + } + anwenden() + if (design !== 'system') return + const mq = window.matchMedia('(prefers-color-scheme: light)') + mq.addEventListener('change', anwenden) + return () => mq.removeEventListener('change', anwenden) + }, [design]) + const db = verbindung.kern?.datenbank ?? null const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase)) @@ -85,7 +101,7 @@ export default function App() { diff --git a/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx b/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx index 951626c..ad116ba 100644 --- a/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx +++ b/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx @@ -5,20 +5,25 @@ // 5.4.0: Sprachen je Titel, Staffel/Folge von Hand, Zielpfad-Vorschau. // 5.5.0: Platz-Prüfung vor dem Rip und der Serien-Merker (Folge von Disc 1 // zählt auf Disc 2 weiter). +// 5.6.0 (Commander): Je Extra WELCHE Untertitel-Spur beim Abspielen +// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je +// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen +// eigenen Roh-Ordner, Vorgang und Zielordner. import { useState } from 'react' -import { zielVorschau } from '../../gemeinsam/ablagepfad' +import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad' import { platzUrteil } from '../../gemeinsam/platz' import type { DiscInfoStand, DiscTitelVorschlag, GeraeteInfo, + MetaVorschlag, SpeicherStand, SprachEintrag, TitelRolle, TitelStand, } from '../../gemeinsam/nachrichten' -import { DialogRahmen, KNOPF_GOLD, dauerText, gb, gbKurz, sprachenVorbelegt } from '../bausteine' -import { ripStarten } from '../kernverbindung' +import { DialogRahmen, IconDisc, KNOPF_GOLD, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine' +import { metadatenSuchen, ripStarten } from '../kernverbindung' /** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels * (ein Knopf IM Label würde die Checkbox mit umschalten). */ @@ -45,7 +50,7 @@ function SprachChips({ type="button" onClick={() => aufWechsel(s.lang)} className={`rounded-md border px-1.5 py-0.5 font-mono text-[10.5px] ${ - an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-white/12 text-nebel hover:text-dunst' + an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-tinte/12 text-nebel hover:text-dunst' }`} title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`} > @@ -59,6 +64,13 @@ function SprachChips({ ) } +/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie + * kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */ +function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string { + if (wunsch.length === 0 || audio.length === 0 || audio[0] === wunsch) return '' + return untertitel.includes(wunsch) ? wunsch : '' +} + export function TitelWahlDialog({ id, typ, @@ -67,6 +79,7 @@ export function TitelWahlDialog({ einstellungen, regel, speicher, + vorschlaege, schliessen, }: { id: string @@ -78,6 +91,8 @@ export function TitelWahlDialog({ * Roh-Material passiert. */ regel: string speicher: SpeicherStand | null + /** Treffer der Metadaten-Suche (für „anderer Film …"). */ + vorschlaege: MetaVorschlag[] schliessen: () => void }) { const [gewaehlt, setGewaehlt] = useState | null>(null) @@ -87,6 +102,12 @@ export function TitelWahlDialog({ // Sprachen je Titel (5.4.0): Was hier nicht drinsteht, ist vorbelegt // (Einstellung, soweit der Titel sie hat — sonst alles, was er hat). const [sprachen, setSprachen] = useState>({}) + // 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine; + // sonst die Sprache. Immer optional, nie eingebrannt. + const [vorwahl, setVorwahl] = useState>({}) + // 5.6.0: Titel, die zu einem anderen Film gehören. + const [filme, setFilme] = useState>({}) + const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null) // Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der // Disc, die erste Folge vom Serien-Merker (5.5.0: die vorige Disc dieser // Serie) oder vom Nutzer („auf Disc 2 sind 3–4"). @@ -115,20 +136,32 @@ export function TitelWahlDialog({ else neu.add(nr) setGewaehlt(neu) } + const wunschSprache = (einstellungen['audioSprachen'] ?? '').split(',')[0]?.trim().toLowerCase() ?? '' const alle = stand?.titel ?? [] const gewaehlteTitel = alle.filter((t) => aktiv.has(t.nr)) const gesamtBytes = gewaehlteTitel.reduce((s, t) => s + t.groesseBytes, 0) const alsSerie = gewaehlteTitel.some((t) => rolleVon(t) === 'folge') - const hauptTitel = gewaehlteTitel.filter((t) => rolleVon(t) !== 'extra').length - const extras = gewaehlteTitel.filter((t) => rolleVon(t) === 'extra').length + const eigeneTitel = gewaehlteTitel.filter((t) => filme[t.nr] === undefined) + const hauptTitel = eigeneTitel.filter((t) => rolleVon(t) !== 'extra').length + const extras = eigeneTitel.filter((t) => rolleVon(t) === 'extra').length + // Andere Filme (5.6.0): je Film die Zahl der Titel, für die Vorschau. + const andereFilme = new Map() + for (const t of gewaehlteTitel) { + const f = filme[t.nr] + if (f === undefined) continue + const e = andereFilme.get(f.tmdbId) + if (e !== undefined) e.titel += 1 + else andereFilme.set(f.tmdbId, { film: f, titel: 1 }) + } const zahlOderNull = (text: string, mindestens: number): number | null => { const n = Number(text.trim()) return text.trim().length > 0 && Number.isInteger(n) && n >= mindestens ? n : null } const staffelZahl = zahlOderNull(staffel, 0) const folgeZahl = zahlOderNull(ersteFolge, 1) + const ablage = einstellungen['ablage'] ?? 'Videos\\Rippy' const vorschau = zielVorschau({ - ablage: einstellungen['ablage'] ?? 'Videos\\Rippy', + ablage, alsSerie, titel: info?.titel ?? '', jahr: info?.jahr ?? null, @@ -141,6 +174,7 @@ export function TitelWahlDialog({ // Platz-Prüfung (5.5.0): gegen den Arbeitsordner — dort landen Roh und Encode. const komprimiert = einstellungen['transcodeEnabled'] !== 'false' const platz = speicher !== null && speicher.arbeit.ok ? platzUrteil(speicher.arbeit.freiBytes, gesamtBytes, komprimiert) : { text: '', hart: false } + const filmTreffer = vorschlaege.filter((v) => v.typ === 'movie') return ( @@ -174,14 +208,14 @@ export function TitelWahlDialog({ new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)), ) } - className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee" + className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee" > Hauptinhalt + Extras - - @@ -190,8 +224,12 @@ export function TitelWahlDialog({ {alle.map((t) => { const an = aktiv.has(t.nr) const wahl = sprachenVon(t) + const rolle = rolleVon(t) + const film = filme[t.nr] + const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache) + const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel' return ( -
+
+ )} +
+ )} + {/* 5.6.0: „anderer Film …" — Double Feature, Trilogie-Disc, + Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */} + {an && rolle !== 'folge' && ( +
+ {film !== undefined ? ( + <> + + → eigener Film: {film.titel} + {film.jahr !== null ? ` (${film.jahr})` : ''} + + + + ) : ( + + )} + {filmSuche?.nr === t.nr && ( +
+
+ setFilmSuche({ nr: t.nr, text: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') metadatenSuchen(id, filmSuche.text) + }} + placeholder="Filmtitel suchen …" + className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-1.5 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none" + /> + +
+ {filmTreffer.length === 0 ? ( +

Noch keine Treffer — Titel eingeben und suchen (braucht den TMDb-Schlüssel).

+ ) : ( +
+ {filmTreffer.map((v) => ( + + ))} +
+ )} +
+ )}
)}
@@ -233,7 +385,7 @@ export function TitelWahlDialog({ {alsSerie && ( -
+

@@ -266,11 +418,25 @@ export function TitelWahlDialog({ )} {/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden - einiges — zwei Zeilen Zielpfad ersparen Überraschungen. */} -

+ einiges — zwei Zeilen Zielpfad ersparen Überraschungen. 5.6.0: + jeder weitere Film bekommt seine Zeile. */} +

LANDET IN

-

{vorschau[0]}

-

{vorschau[1]}

+ {eigeneTitel.length > 0 && ( + <> +

{vorschau[0]}

+

{vorschau[1]}

+ + )} + {[...andereFilme.values()].map(({ film, titel }) => ( +

+ {`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`} + + {' '} + — {titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei + +

+ ))}
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem @@ -297,7 +463,7 @@ export function TitelWahlDialog({ )}
- {schritt < SCHRITTE.length - 1 ? ( diff --git a/rippy-windows/src/fenster/einstellungen/Einstellungen.tsx b/rippy-windows/src/fenster/einstellungen/Einstellungen.tsx index f520d0f..a446e7c 100644 --- a/rippy-windows/src/fenster/einstellungen/Einstellungen.tsx +++ b/rippy-windows/src/fenster/einstellungen/Einstellungen.tsx @@ -49,13 +49,13 @@ export function Einstellungen({ verbindung, haupt, start }: { verbindung: Verbin // 01.09.2026: „alles gequetscht"). return (
-
-
+
{v === null ? (

Belegung wird gemessen …

) : ( @@ -258,7 +258,7 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind {sichtbar.map((e) => { const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen' return ( -
+
{e.titel} @@ -293,7 +293,7 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind setzen('design', e.target.value)} className={FELD_KLASSE}> + + + + +
)}
-
+

Rippy v{haupt?.version ?? '…'} {kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`} @@ -559,7 +567,7 @@ export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; hau ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur https://). */} {haupt !== null && ( -

+

Stand: {haupt.update.text}

@@ -597,7 +605,7 @@ function WerkzeugZeile({ wert: string }) { return ( -
+

0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}> {name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}

diff --git a/rippy-windows/src/fenster/einstellungen/SprachAuswahl.tsx b/rippy-windows/src/fenster/einstellungen/SprachAuswahl.tsx index c36cf9b..6029ffb 100644 --- a/rippy-windows/src/fenster/einstellungen/SprachAuswahl.tsx +++ b/rippy-windows/src/fenster/einstellungen/SprachAuswahl.tsx @@ -30,7 +30,7 @@ export function SprachAuswahl({
{name} {hinweis} -
+
{gewaehlt.length === 0 && alle Sprachen der Disc} {gewaehlt.map((code, i) => ( @@ -53,7 +53,7 @@ export function SprachAuswahl({ onChange={(e) => { if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value]) }} - className="ml-auto rounded-md border border-white/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst" + className="ml-auto rounded-md border border-tinte/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst" > {frei.map((s) => ( diff --git a/rippy-windows/src/fenster/stil.css b/rippy-windows/src/fenster/stil.css index e658cec..c4f1ec9 100644 --- a/rippy-windows/src/fenster/stil.css +++ b/rippy-windows/src/fenster/stil.css @@ -31,6 +31,9 @@ --color-gruen: oklch(0.78 0.13 150); --color-warn: oklch(0.8 0.12 85); --color-rot: oklch(0.68 0.19 25); + + /* Linien und Flächen mit Alpha: im Dunkeln Weiß, im Hellen Tinte (5.6.0). */ + --color-tinte: oklch(1 0 0); } :root { @@ -73,8 +76,8 @@ /* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */ ::-webkit-scrollbar { width: 10px; height: 10px; } -::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } -::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); } +::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--color-tinte) 14%, transparent); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } +::-webkit-scrollbar-thumb:hover { background-color: color-mix(in oklch, var(--color-tinte) 24%, transparent); } ::-webkit-scrollbar-track { background: transparent; } /* Der Poster-Lauf der Erst-Einrichtung (5.5.0): Jede Spalte wandert langsam @@ -90,3 +93,34 @@ animation-iteration-count: infinite; will-change: transform; } + +/* ── Hell-Modus (5.6.0, Commander: „White Mode") ───────────────────────── + Dieselben Tokens, andere Werte: warmes Papier statt Kinosaal, Gold als + dunklerer Akzent, Tinte statt Weiß für Linien und Flächen. Geschaltet + über data-design auf (App.tsx, Einstellung „design": dunkel / + hell / system). */ +:root[data-design="hell"] { + color-scheme: light; + --color-buehne: oklch(0.965 0.008 80); + --color-karte: oklch(0.99 0.004 80); + --color-samt: oklch(0.94 0.01 80); + --color-gold-hell: oklch(0.6 0.12 72); + --color-gold: oklch(0.66 0.13 70); + --color-gold-tief: oklch(0.56 0.13 58); + --color-rec: oklch(0.6 0.19 40); + --color-schnee: oklch(0.2 0.012 60); + --color-dunst: oklch(0.4 0.015 60); + --color-nebel: oklch(0.55 0.015 60); + --color-gruen: oklch(0.52 0.15 150); + --color-warn: oklch(0.58 0.13 80); + --color-rot: oklch(0.55 0.2 25); + --color-tinte: oklch(0.15 0.01 60); +} +:root[data-design="hell"] .buehnenlicht { + background: + radial-gradient(900px 520px at 78% 0%, oklch(0.9 0.06 80 / 0.6), transparent 70%), + radial-gradient(1100px 700px at 0% 100%, oklch(0.9 0.05 40 / 0.5), transparent 65%); +} +:root[data-design="hell"] .filmkorn { + opacity: 0.03; +} diff --git a/rippy-windows/src/fenster/uebersicht/KompressionsKarte.tsx b/rippy-windows/src/fenster/uebersicht/KompressionsKarte.tsx index 8dfea7a..2b2ec60 100644 --- a/rippy-windows/src/fenster/uebersicht/KompressionsKarte.tsx +++ b/rippy-windows/src/fenster/uebersicht/KompressionsKarte.tsx @@ -3,16 +3,18 @@ // noch wartet, mit Abbruch je Auftrag. 5.5.0: Pause — der laufende Film // wird fertig, der nächste wartet. import type { VorgaengeStand } from '../../gemeinsam/nachrichten' -import { SegmentBalken } from '../bausteine' +import { leisteAusKompression } from '../../gemeinsam/vorgangsleiste' +import { restText } from '../bausteine' +import { VorgangsLeiste } from './VorgangsLeiste' import { kompressionAbbrechen, kompressionPause } from '../kernverbindung' export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompression'] }) { if (stand.laeuft === null && stand.wartend.length === 0 && !stand.pausiert) return null const l = stand.laeuft return ( -
+
- + KOMPRESSION · @@ -22,7 +24,7 @@ export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompressio
{l !== null && ( -
+

{l.titel}

- +

> {l.text} @@ -61,8 +63,9 @@ export function KompressionsKarte({ stand }: { stand: VorgaengeStand['kompressio {stand.wartend.length > 0 && (

{stand.wartend.map((w, i) => ( -
- +
+ + Platz {i + 1} · {w.titel} {stand.pausiert ? ' · wartet auf Weiter' : ''} diff --git a/rippy-windows/src/fenster/uebersicht/LaufwerkKachel.tsx b/rippy-windows/src/fenster/uebersicht/LaufwerkKachel.tsx index 8b7b197..f266813 100644 --- a/rippy-windows/src/fenster/uebersicht/LaufwerkKachel.tsx +++ b/rippy-windows/src/fenster/uebersicht/LaufwerkKachel.tsx @@ -24,7 +24,6 @@ import { InfoKachel, LAUFENDE_PHASEN, PHASEN_MONO, - SegmentBalken, TYP_NAMEN, backdropUrl, discHerkunft, @@ -46,6 +45,8 @@ import { ripUeberspringen, type Verbindung, } from '../kernverbindung' +import { leisteAusRip } from '../../gemeinsam/vorgangsleiste' +import { VorgangsLeiste } from './VorgangsLeiste' /** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern * (eigene Wahl → Empfehlung → Default). */ @@ -113,10 +114,10 @@ export function LaufwerkKachel({ ? 'text-warn border-warn/45 bg-warn/10' : bericht.urteil === 'hardware' ? 'text-rot border-rot/45 bg-rot/10' - : 'text-dunst border-white/12 bg-samt/60' + : 'text-dunst border-tinte/12 bg-samt/60' return ( -
+
{backdrop.length > 0 && ( <> @@ -135,7 +136,7 @@ export function LaufwerkKachel({ : discDa ? 'bg-gruen' : geraet.status === 'empty' - ? 'bg-white/25' + ? 'bg-tinte/25' : 'bg-rot' }`} /> @@ -166,7 +167,7 @@ export function LaufwerkKachel({ -
@@ -366,7 +367,7 @@ export function LaufwerkKachel({ )} {rip !== undefined && ( -
+
@@ -380,7 +381,9 @@ export function LaufwerkKachel({ {ripLaeuft && {restzeitText(rip.prozent, ripStartMs)}}
- {ripLaeuft && } + {/* 5.6.0: die Vorgangs-Leiste — der ganze Weg, hier mit den + Details der Laufwerks-Stationen; nach dem Rip nur der Stand. */} + {ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (

Titel {rip.titelNr} von {rip.titelGesamt} @@ -425,35 +428,22 @@ export function LaufwerkKachel({

))} - {/* Nach dem Rip (5.4.0): Wo steht die Kompression? Das - Laufwerk ist schon frei — die Arbeit läuft im Hintergrund. */} + {/* Nach dem Rip (5.6.0, Commander: „Nur in der Kompressions-Karte"): + hier nur noch EINE Zeile, wo es weitergeht — Balken, Prozent, + Restzeit und Ziel stehen in der Kompressions-Karte, danach im + Verlauf. Fehler bleiben sichtbar (R4). */} {rip.phase === 'fertig' && vorgang !== undefined && ( -
- {kompression !== null ? ( - <> -

- KOMPRESSION LÄUFT{kompression.prozent >= 0 ? ` — ${kompression.prozent} %` : ''} -

-
- -
-

- > {kompression.text} -

- - ) : wartePlatz > 0 ? ( -

Kompression wartet — Platz {wartePlatz} in der Warteschlange.

- ) : vorgang.phase === 'fertig' ? ( -

- ✓ {vorgang.komprimiert ? 'Komprimiert und abgelegt' : 'Verlustfrei abgelegt'}: {vorgang.zielOrdner} - {vorgang.fertigBytes > 0 && ` (${gb(vorgang.fertigBytes)})`} -

- ) : vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? ( -

⚠ {vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}

- ) : ( -

Kompression: {vorgang.phase}

- )} -
+

+ {kompression !== null + ? 'Kompression läuft — Stand in der Kompressions-Karte unten.' + : wartePlatz > 0 + ? `Kompression wartet — Platz ${wartePlatz} in der Warteschlange (Karte unten).` + : vorgang.phase === 'fertig' + ? 'Kompression fertig — das Ergebnis steht im Verlauf.' + : vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' + ? `⚠ ${vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}` + : `Kompression: ${vorgang.phase}`} +

)}
{ripLaeuft && rip.prozent >= 0 && ( diff --git a/rippy-windows/src/fenster/uebersicht/SpeicherKarte.tsx b/rippy-windows/src/fenster/uebersicht/SpeicherKarte.tsx index b83914f..dffc566 100644 --- a/rippy-windows/src/fenster/uebersicht/SpeicherKarte.tsx +++ b/rippy-windows/src/fenster/uebersicht/SpeicherKarte.tsx @@ -13,7 +13,7 @@ function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; h const ton = !platz.ok ? 'rot' : platz.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : platz.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gold' const belegt = platz.ok && platz.gesamtBytes > 0 ? 1 - platz.freiBytes / platz.gesamtBytes : 0 return ( -
+
{name} @@ -42,7 +42,7 @@ function Platz({ name, platz, hinweis }: { name: string; platz: SpeicherPlatz; h export function SpeicherKarte({ speicher, statistik }: { speicher: SpeicherStand | null; statistik: Statistik | null }) { return ( -
+
SPEICHER UND BILANZ