Files
rippy/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx
T
HitonabiandClaude Opus 5 e7e33e9021 feat(v5): jede Spur einzeln anhaken — Codec und Kanäle je Tonspur, Untertitel genauso
WAS: Der Auswahl-Dialog zeigt je Titel jede Ton- und Untertitelspur, die
der Info-Lauf gemessen hat („German · DTS-HD MA · Surround 5.1", „German ·
DD · Stereo (Kommentar)", „German · PGS (nur erzwungene)"), vorbelegt wie
bisher (je Wunschsprache die erste Tonspur, alle Untertitel der Wunsch-
sprachen) — angehakt wird einzeln. Die Untertitel-Vorwahl je Extra ist
jetzt eine Spur. Der Parser liest dazu SINFO-Attribute 2 (Anordnung),
22 (Flags) und markiert abgeleitete Spuren (Bit 0x800, an der Kizuna-
Disc gemessen): DTS-Kerne werden nicht angeboten, „nur erzwungene"-
Untertitel schon. Die Ablage scannt die Roh-Datei und findet die
gewählten MakeMKV-Spuren gestuft wieder (Sprache + Codec-Familie +
Kanäle → … → Sprache), HandBrake bekommt `--audio 1,3 --subtitle 2,1
--subtitle-default=1`; Fehlendes wird gemeldet, scheitert der Scan,
gelten die Sprachen der gewählten Spuren als Liste. MakeMKVs Auswahl-
regel lässt Stereo-Doppel jetzt stehen (nur -sel:havecore); Rippys alte
Regel wird in der Registry ersetzt, eine fremde bleibt.
Dazu F31 (Durchsicht): `--aencoder copy` ohne `--audio-copy-mask`
verengt HandBrakeCLI 1.11.2 auf „copy:aac" — AC3/DTS/TrueHD wurden seit
5.0 in AAC umgerechnet. Beide Wege setzen die Maske jetzt ausdrücklich
(gemessen 12.09.2026: AC3 bleibt AC3, Passthru-Zeile nennt alle Codecs).
WARUM: Commander-Entscheid 12.09.2026 („Jede Spur einzeln anhaken") —
bisher ließ sich je Sprache nur „alles oder nichts" wählen, und der
Kommentar-Ton fehlte in der Roh-Datei (havemulti). Gemessen am
mitgelieferten HandBrakeCLI mit ffmpeg-gebauter Quelle
(test/messung.spuren.test.ts, RIPPY_MESSUNG=1): --audio 1,3 → jpn und
deu-Kommentar, --subtitle 2,1 --subtitle-default=1 → eng (default) vor
deu, --subtitle none → keine Spur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:11:27 +02:00

664 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20120 s, deshalb
// beginnt er erst hier und nicht schon beim Einlegen.
// 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.
// 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch ·
// DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel
// genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine
// einzelnen Spuren meldet.
import { useState } from 'react'
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
import { platzUrteil } from '../../gemeinsam/platz'
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
SpeicherStand,
SprachEintrag,
SpurAngabe,
TitelRolle,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung'
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
function SprachChips({
name,
vorhanden,
gewaehlt,
aufWechsel,
}: {
name: string
vorhanden: readonly SprachEintrag[]
gewaehlt: readonly string[]
aufWechsel: (lang: string) => void
}) {
if (vorhanden.length === 0) return null
return (
<span className="inline-flex flex-wrap items-center gap-1">
<span className="mr-0.5 font-mono text-[10px] tracking-wider text-nebel">{name}</span>
{vorhanden.map((s) => {
const an = gewaehlt.includes(s.lang)
return (
<button
key={s.lang}
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-tinte/12 text-nebel hover:text-dunst'
}`}
title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`}
>
{an ? '✓ ' : ''}
{s.lang}
{s.spuren > 1 ? `×${s.spuren}` : ''}
</button>
)
})}
</span>
)
}
/** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln
* anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb
* des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */
function SpurZeilen({
name,
art,
vorhanden,
gewaehlt,
aufWechsel,
}: {
name: string
art: 'audio' | 'untertitel'
vorhanden: readonly SpurAngabe[]
gewaehlt: readonly number[]
aufWechsel: (nr: number) => void
}) {
const zeilen = vorhanden.filter((s) => angeboten(s, art))
if (zeilen.length === 0) return null
return (
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
{zeilen.map((s) => {
const an = gewaehlt.includes(s.nr)
return (
<label
key={s.nr}
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
>
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
{spurLabel(s)}
</label>
)
})}
</div>
)
}
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
* kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte
* Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur
* der Wunschsprache — sonst nichts. */
function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined {
if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined
return untertitel.find((s) => s.lang.toLowerCase() === wunsch)
}
/** 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,
stand,
info,
einstellungen,
regel,
speicher,
vorschlaege,
schliessen,
}: {
id: string
typ: GeraeteInfo['typ']
stand: TitelStand | undefined
info: DiscInfoStand | undefined
einstellungen: Record<string, string>
/** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem
* 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)
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
// 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.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht
// drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle
// Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die
// Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine.
const [spuren, setSpuren] = useState<Record<number, { audio: number[]; untertitel: number[] }>>({})
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | 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").
const merker = info?.serienMerker ?? null
const merkerGilt = merker !== null && (info?.discNr === null || info === undefined || (info.discNr ?? 1) > 1)
const [staffel, setStaffel] = useState<string>(
info !== undefined && info.staffel !== null ? String(info.staffel) : merker !== null ? String(merker.staffel) : '',
)
const [ersteFolge, setErsteFolge] = useState(merkerGilt && merker !== null ? String(merker.naechsteFolge) : '')
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
const sprachenVon = (t: DiscTitelVorschlag): { audio: string[]; untertitel: string[] } =>
sprachen[t.nr] ?? {
audio: sprachenVorbelegt(t.sprachen.audio, einstellungen['audioSprachen'] ?? ''),
untertitel: sprachenVorbelegt(t.sprachen.untertitel, einstellungen['untertitelSprachen'] ?? ''),
}
const sprachWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', lang: string): void => {
const jetzt = sprachenVon(t)
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
}
const wunschListe = (schluessel: string): string[] =>
(einstellungen[schluessel] ?? '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0)
/** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */
const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0)
const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } =>
spuren[t.nr] ?? {
audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'),
untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'),
}
const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => {
const jetzt = spurenVon(t)
const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b)
setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } })
}
/** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */
const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => {
const nrn = spurenVon(t)
return {
audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)),
untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)),
}
}
const umschalten = (nr: number): void => {
const neu = new Set(aktiv)
if (neu.has(nr)) neu.delete(nr)
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 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,
alsSerie,
titel: info?.titel ?? '',
jahr: info?.jahr ?? null,
staffel: staffelZahl,
ersteFolge: folgeZahl,
hauptTitel,
extras,
vorgangsName: `${typ}-<Zeitstempel>`,
})
// 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">
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
{stand === undefined || stand.laeuft ? (
<div className="mt-4">
<p className="font-mono text-xs text-nebel">Rippy liest die Titel der Disc meist dauert das ein bis zwei Minuten. Solange dreht das Laufwerk.</p>
{/* 5.7.0 (Kandidat 2): Ab der zweiten Minute sagt der Kern, wie lange
es schon läuft und wo die Grenze liegt. */}
{stand !== undefined && stand.einordnung.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{stand.einordnung}</p>}
</div>
) : stand.fehler.length > 0 ? (
<p className="mt-4 font-mono text-xs text-rot"> {stand.fehler}</p>
) : (
<>
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
{stand.unsicher ? '⚠ ' : ''}
{stand.einordnung}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<button
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
>
Rippys Vorschlag
</button>
<button
onClick={() =>
setGewaehlt(
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
// Sammeltitel noch Logos/Warnungen.
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
)
}
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-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-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
Nichts
</button>
</div>
<div className="mt-3 space-y-1">
{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'
const spurNrn = spurenVon(t)
const spurWahl = gewaehlteSpuren(t)
const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache)
const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel'
return (
<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>
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
{an ? (
<select
value={rolle}
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
onClick={(e) => e.stopPropagation()}
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>
<option value="extra">Extra</option>
<option value="folge">Folge</option>
</select>
) : (
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
)}
</label>
{/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die
der Info-Lauf an DIESEM Titel gemessen hat — einzeln
anhaken, was in die Datei soll. */}
{an && spurenDa(t) && (
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
<SpurZeilen
name="UNTERTITEL"
art="untertitel"
vorhanden={t.spuren.untertitel}
gewaehlt={spurNrn.untertitel}
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
/>
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt HandBrake nimmt die erste Spur</span>
)}
{/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional — eine
weiche Spur, die der Spieler von selbst einblendet;
eingebrannt wird nie. Hier als SPUR. */}
{rolle === 'extra' && spurWahl.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={vorwahlSpurWert}
onChange={(e) => {
const w = e.target.value
const neu = { ...vorwahlSpur }
if (w === 'regel') delete neu[t.nr]
else neu[t.nr] = w === 'keine' ? null : Number(w)
setVorwahlSpur(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">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
<option value="keine">keine</option>
{spurWahl.untertitel.map((u) => (
<option key={u.nr} value={String(u.nr)}>
{spurLabel(u)}
</option>
))}
</select>
</label>
)}
</div>
)}
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
Titel gemessen hat — anhaken, was in die Datei soll.
Der Commander-Fall: japanischer Ton, deutsche Untertitel.
Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */}
{an && !spurenDa(t) && (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-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>
)
})}
{alle.length === 0 && <p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>}
</div>
{alsSerie && (
<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
type="number"
min={0}
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-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
/>
</label>
<label className="block">
<span className="font-mono text-[10px] tracking-wider text-nebel">AB FOLGE</span>
<input
type="number"
min={1}
value={ersteFolge}
onChange={(e) => setErsteFolge(e.target.value)}
placeholder="automatisch"
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">
{merkerGilt && merker !== null
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${datumKurz(merker.wann).slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
</p>
</div>
)}
{/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden
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>
{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
vollen Laufwerk scheitern. */}
{speicher !== null && speicher.arbeit.ok && (
<p className={`mt-2 font-mono text-[11px] ${platz.hart ? 'text-rot' : platz.text.length > 0 ? 'text-warn' : 'text-nebel'}`}>
{platz.text.length > 0
? `${platz.hart ? '✗' : '⚠'} ${platz.text}`
: `Platz: ${gbKurz(speicher.arbeit.freiBytes)} frei im Arbeitsordner — reicht für ${gb(gesamtBytes)} verlustfrei${komprimiert ? ' plus Kompression' : ''}.`}
</p>
)}
<p className="mt-3 font-mono text-[11px] text-nebel">
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei
{regel.length > 0 ? ` · ${regel}` : ''}
</p>
<p className="mt-1 text-[11px] text-nebel">
{alsSerie
? 'Folgen landen unter Serien/… /Season NN. Ohne Vorgabe setzt Rippy die Episoden-Nummern nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}{' '}
Das Laufwerk ist frei, sobald die Roh-Dateien liegen die Kompression läuft danach im Hintergrund.
</p>
</>
)}
<div className="mt-5 flex justify-between gap-2">
<button
onClick={() => {
// Läuft der Titel-Lauf noch, beendet „Abbrechen" ihn auch im Kern —
// sonst dreht das Laufwerk weiter, obwohl der Dialog zu ist.
if (stand !== undefined && stand.laeuft) titelAbbrechen(id)
schliessen()
}}
className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
>
Abbrechen
</button>
<button
onClick={() => {
ripStarten(
id,
gewaehlteTitel.map((t) => {
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
// 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
// mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
// scheitert; und die alten Hinweise bleiben stimmig).
if (spurenDa(t)) {
const spurWahl = gewaehlteSpuren(t)
return {
nr: t.nr,
rolle,
dauerS: t.dauerS,
groesseBytes: t.groesseBytes,
audioSprachen: sprachenDerSpuren(spurWahl.audio),
untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
audioSpuren: spurWahl.audio,
untertitelSpuren: spurWahl.untertitel,
...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
}
}
return {
nr: t.nr,
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 },
)
schliessen()
}}
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft || platz.hart}
className={KNOPF_GOLD}
title={platz.hart ? platz.text : ''}
>
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
</button>
</div>
</DialogRahmen>
)
}