feat(v5): 5.6.0 — Vorgangs-Leiste, mehrere Filme je Disc, Filmnamen für Ordner und Dateien, Untertitel-Vorwahl je Extra, Restzeit, Hell-Modus

Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:

* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
  aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
  der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
  Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
  RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
  filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
  und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
  ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
  <html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
  machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
  vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
  aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.

Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-03 19:17:37 +02:00
co-authored by Claude Fable 5.1
parent 76ed3b337a
commit f5dab5d1c1
59 changed files with 1284 additions and 182 deletions
@@ -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<Set<number> | 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<Record<number, { audio: string[]; untertitel: string[] }>>({})
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
// sonst die Sprache. Immer optional, nie eingebrannt.
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
// 5.6.0: Titel, die zu einem anderen Film gehören.
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
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 34").
@@ -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<number, { film: MetaVorschlag; titel: number }>()
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 (
<DialogRahmen schliessen={schliessen} breite="max-w-3xl">
@@ -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
</button>
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Alles
</button>
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Nichts
</button>
</div>
@@ -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 (
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-white/8 bg-samt/40'}`}>
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
<label className="flex cursor-pointer items-center gap-3 p-2">
<input type="checkbox" checked={an} onChange={() => umschalten(t.nr)} className="accent-[oklch(0.83_0.11_75)]" />
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
@@ -200,10 +238,10 @@ export function TitelWahlDialog({
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
{an ? (
<select
value={rolleVon(t)}
value={rolle}
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
onClick={(e) => e.stopPropagation()}
className="flex-none rounded-lg border border-white/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
className="flex-none rounded-lg border border-tinte/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
title="Bestimmt, WOHIN der Titel abgelegt wird"
>
<option value="hauptfilm">Hauptfilm</option>
@@ -218,12 +256,126 @@ export function TitelWahlDialog({
Titel gemessen hat — anhaken, was in die Datei soll.
Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
{an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-white/8 px-2 py-1.5 pl-9">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
{wahl.audio.length === 0 && t.sprachen.audio.length > 0 && (
<span className="font-mono text-[10.5px] text-warn">kein Ton gewählt HandBrake nimmt die erste Spur</span>
)}
{/* 5.6.0: Vorwahl NUR bei Extras, IMMER optional — eine
weiche Spur, die der Spieler von selbst einblendet;
eingebrannt wird nie. */}
{rolle === 'extra' && wahl.untertitel.length > 0 && (
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
<select
value={vorwahlWert}
onChange={(e) => {
const w = e.target.value
const neu = { ...vorwahl }
if (w === 'regel') delete neu[t.nr]
else neu[t.nr] = w === 'keine' ? null : w
setVorwahl(neu)
}}
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
>
<option value="regel">{regelWahl.length > 0 ? `Rippys Regel (${regelWahl})` : 'Rippys Regel (keine)'}</option>
<option value="keine">keine</option>
{wahl.untertitel.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</label>
)}
</div>
)}
{/* 5.6.0: „anderer Film …" — Double Feature, Trilogie-Disc,
Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */}
{an && rolle !== 'folge' && (
<div className="flex flex-wrap items-center gap-2 border-t border-tinte/8 px-2 py-1.5 pl-9">
{film !== undefined ? (
<>
<span className="inline-flex items-center gap-1.5 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
eigener Film: {film.titel}
{film.jahr !== null ? ` (${film.jahr})` : ''}
<button
type="button"
onClick={() => {
const neu = { ...filme }
delete neu[t.nr]
setFilme(neu)
}}
className="ml-1 text-nebel hover:text-rot"
title="wieder zur Disc-Zuordnung"
>
×
</button>
</span>
</>
) : (
<button
type="button"
onClick={() => setFilmSuche(filmSuche?.nr === t.nr ? null : { nr: t.nr, text: '' })}
className="rounded-md border border-tinte/15 px-2 py-0.5 text-[11px] text-nebel hover:text-schnee"
title="Dieser Titel ist ein anderer Film (Double Feature, Bonus-Film): eigener Ordner mit Poster und Infodatei"
>
anderer Film
</button>
)}
{filmSuche?.nr === t.nr && (
<div className="w-full rounded-lg border border-tinte/10 bg-buehne/70 p-2">
<div className="flex gap-2">
<input
value={filmSuche.text}
autoFocus
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => metadatenSuchen(id, filmSuche.text)}
className="rounded-lg border border-gold/50 bg-gold/10 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/20"
>
Suchen
</button>
</div>
{filmTreffer.length === 0 ? (
<p className="mt-2 font-mono text-[10.5px] text-nebel">Noch keine Treffer Titel eingeben und suchen (braucht den TMDb-Schlüssel).</p>
) : (
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
{filmTreffer.map((v) => (
<button
key={v.tmdbId}
type="button"
onClick={() => {
setFilme({ ...filme, [t.nr]: v })
setFilmSuche(null)
}}
className="flex w-full items-center gap-2 rounded-lg border border-tinte/10 bg-karte/60 p-1.5 text-left hover:border-gold/50"
>
{v.posterPfad.length > 0 ? (
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-10 w-7 rounded object-cover" />
) : (
<span className="flex h-10 w-7 items-center justify-center rounded bg-samt">
<IconDisc groesse={14} farbe="oklch(0.55 0.015 80)" />
</span>
)}
<span className="text-xs text-schnee">
{v.titel}
{v.jahr !== null ? ` (${v.jahr})` : ''}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
@@ -233,7 +385,7 @@ export function TitelWahlDialog({
</div>
{alsSerie && (
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-white/8 bg-samt/40 p-3">
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-tinte/8 bg-samt/40 p-3">
<label className="block">
<span className="font-mono text-[10px] tracking-wider text-nebel">STAFFEL</span>
<input
@@ -242,7 +394,7 @@ export function TitelWahlDialog({
value={staffel}
onChange={(e) => setStaffel(e.target.value)}
placeholder={info !== undefined && info.staffel !== null ? String(info.staffel) : '1'}
className="mt-1 block w-20 rounded-lg border border-white/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
className="mt-1 block w-20 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<label className="block">
@@ -253,7 +405,7 @@ export function TitelWahlDialog({
value={ersteFolge}
onChange={(e) => setErsteFolge(e.target.value)}
placeholder="automatisch"
className="mt-1 block w-28 rounded-lg border border-white/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
className="mt-1 block w-28 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
@@ -266,11 +418,25 @@ export function TitelWahlDialog({
)}
{/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden
einiges — zwei Zeilen Zielpfad ersparen Überraschungen. */}
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
einiges — zwei Zeilen Zielpfad ersparen Überraschungen. 5.6.0:
jeder weitere Film bekommt seine Zeile. */}
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
<p className="text-[10px] tracking-wider text-nebel">LANDET IN</p>
<p className="break-all text-gold-hell">{vorschau[0]}</p>
<p className="text-dunst">{vorschau[1]}</p>
{eigeneTitel.length > 0 && (
<>
<p className="break-all text-gold-hell">{vorschau[0]}</p>
<p className="text-dunst">{vorschau[1]}</p>
</>
)}
{[...andereFilme.values()].map(({ film, titel }) => (
<p key={film.tmdbId} className="break-all text-gold-hell">
{`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`}
<span className="text-dunst">
{' '}
{titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei
</span>
</p>
))}
</div>
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem
@@ -297,7 +463,7 @@ export function TitelWahlDialog({
)}
<div className="mt-5 flex justify-between gap-2">
<button onClick={schliessen} className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee">
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee">
Abbrechen
</button>
<button
@@ -306,13 +472,16 @@ export function TitelWahlDialog({
id,
gewaehlteTitel.map((t) => {
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
return {
nr: t.nr,
rolle: rolleVon(t),
rolle,
dauerS: t.dauerS,
groesseBytes: t.groesseBytes,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
...(rolle === 'extra' && t.nr in vorwahl ? { untertitelVorwahl: vorwahl[t.nr] } : {}),
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
}
}),
{ staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null },