fixed live preview

This commit is contained in:
Linrador 2026-03-31 14:03:19 +02:00
parent 548919cde2
commit 6337220de9
22 changed files with 3963 additions and 2457 deletions

View File

@ -473,16 +473,20 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
fetchedAtNow := cbApplySnapshot(rooms)
if cbModelStore != nil {
// ✅ bekannten Store sofort auf aktuellen Snapshot ziehen
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
// Statusfelder pflegen (online/offline, room_status, last_online_at, ...)
syncChaturbateRoomStateIntoModelStore(
cbModelStore,
append([]ChaturbateRoom(nil), rooms...),
copiedRooms,
fetchedAtNow,
)
// optional / best effort
if len(rooms) > 0 {
go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
// Vollständigen Snapshot pflegen, aber offline NICHT überschreiben
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
if len(copiedRooms) > 0 {
go cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
}
}
@ -547,14 +551,16 @@ func startChaturbateOnlinePoller(store *ModelStore) {
// ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren
if cbModelStore != nil {
go syncChaturbateRoomStateIntoModelStore(
cbModelStore,
append([]ChaturbateRoom(nil), rooms...),
fetchedAtNow,
)
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
if len(rooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
go func(roomsCopy []ChaturbateRoom, ts time.Time) {
syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts)
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts)
}(copiedRooms, fetchedAtNow)
if len(copiedRooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
}
}
@ -686,17 +692,12 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us
return "", err
}
master, err := ParseStream(body) // -> hls_source
master, err := ParseStream(body) // hls_source
if err != nil {
return "", err
}
pl, err := FetchPlaylist(ctx, hc, master, cookie) // -> beste Variant
if err != nil {
return "", err
}
return strings.TrimSpace(pl.PlaylistURL), nil
return strings.TrimSpace(master), nil
}
// refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs.

View File

@ -202,17 +202,6 @@ func servePreviewHLSFileWithBase(w http.ResponseWriter, r *http.Request, id, fil
return
}
// activation: hover or play=1
active := isHover(r) || strings.TrimSpace(r.URL.Query().Get("play")) == "1"
if !active {
if isIndex {
serveLiveNotReady(w, r)
return
}
http.Error(w, "preview not active", http.StatusNotFound)
return
}
if !ok || job == nil {
if isIndex {
serveLiveNotReady(w, r)
@ -362,16 +351,10 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
commonIn = append(commonIn, "-i", m3u8URL)
hqArgs := append(commonIn,
"-vf", "scale=480:-2",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p",
"-profile:v", "main",
"-level", "3.1",
"-threads", "4",
"-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
"-map", "0:v:0",
"-map", "0:a:0?",
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
"-c:v", "copy",
"-c:a", "copy",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "6",
@ -485,7 +468,7 @@ func rewriteM3U8WithBase(raw []byte, id string, basePath string) []byte {
}
name := path.Base(u)
out.WriteString(base + url.QueryEscape(name) + "&play=1")
out.WriteString(base + url.QueryEscape(name))
out.WriteByte('\n')
}
if err := sc.Err(); err != nil {
@ -518,7 +501,7 @@ func rewriteAttrURIWithBase(line, base string, basePath string) string {
}
name := path.Base(valTrim)
repl := base + url.QueryEscape(name) + "&play=1"
repl := base + url.QueryEscape(name)
return line[:start] + repl + line[end:]
}
@ -591,13 +574,6 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
return
}
// activation: hover or play=1 (wie bei HLS)
active := isHover(r) || strings.TrimSpace(r.URL.Query().Get("play")) == "1"
if !active {
http.Error(w, "preview not active", http.StatusNotFound)
return
}
jobsMu.Lock()
job, ok := jobs[id]
state := ""
@ -701,20 +677,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
args = append(args,
"-map", "0:v:0",
"-map", "0:a:0?",
"-vf", "scale=480:-2",
"-c:v", "libx264",
"-preset", "veryfast",
"-tune", "zerolatency",
"-pix_fmt", "yuv420p",
"-profile:v", "main",
"-level", "3.1",
"-g", "48",
"-keyint_min", "48",
"-sc_threshold", "0",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-ar", "48000",
"-c:v", "copy",
"-c:a", "copy",
)
// Output: fMP4 fragmented to stdout (single HTTP response)

View File

@ -1191,8 +1191,8 @@ func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom,
}
defer func() { _ = tx.Rollback() }()
// q1 mit cb_online_last_error
stmt1, err1 := tx.Prepare(`
// ONLINE: voller Snapshot wird gespeichert
stmtOnline1, errOnline1 := tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
@ -1213,10 +1213,9 @@ WHERE lower(trim(host)) = lower(trim($8))
AND lower(trim(model_key)) = lower(trim($9));
`)
// fallback ohne cb_online_last_error
var stmt2 *sql.Stmt
if err1 != nil {
stmt2, err = tx.Prepare(`
var stmtOnline2 *sql.Stmt
if errOnline1 != nil {
stmtOnline2, err = tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
@ -1238,13 +1237,43 @@ WHERE lower(trim(host)) = lower(trim($7))
if err != nil {
return err
}
defer stmt2.Close()
defer stmtOnline2.Close()
} else {
defer stmt1.Close()
defer stmtOnline1.Close()
}
// OFFLINE: nur Status updaten, Snapshot NICHT überschreiben
stmtOffline1, errOffline1 := tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
cb_online_last_error = $3,
updated_at = $4
WHERE lower(trim(host)) = lower(trim($5))
AND lower(trim(model_key)) = lower(trim($6));
`)
var stmtOffline2 *sql.Stmt
if errOffline1 != nil {
stmtOffline2, err = tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
updated_at = $3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`)
if err != nil {
return err
}
defer stmtOffline2.Close()
} else {
defer stmtOffline1.Close()
}
now := time.Now().UTC()
fetchedAtStr := fetchedAt.Format(time.RFC3339Nano)
for _, key := range knownKeys {
key = strings.TrimSpace(key)
@ -1252,18 +1281,10 @@ WHERE lower(trim(host)) = lower(trim($7))
continue
}
var (
online bool
snap *ChaturbateOnlineSnapshot
jsonStr string
imgURL string
)
if rm, ok := roomsByUser[strings.ToLower(key)]; ok {
online = true
imgURL = strings.TrimSpace(selectBestRoomImageURL(rm))
imgURL := strings.TrimSpace(selectBestRoomImageURL(rm))
snap = &ChaturbateOnlineSnapshot{
snap := &ChaturbateOnlineSnapshot{
Username: rm.Username,
DisplayName: rm.DisplayName,
CurrentShow: strings.TrimSpace(rm.CurrentShow),
@ -1284,29 +1305,52 @@ WHERE lower(trim(host)) = lower(trim($7))
ChatRoomURLRS: rm.ChatRoomURLRS,
Tags: rm.Tags,
}
} else {
online = false
snap = &ChaturbateOnlineSnapshot{
Username: key,
CurrentShow: "offline",
}
}
if snap != nil {
jsonStr := ""
if b, err := json.Marshal(snap); err == nil {
jsonStr = strings.TrimSpace(string(b))
}
if stmtOnline1 != nil {
if _, err := stmtOnline1.Exec(
true,
fetchedAt,
nullableStringArg(jsonStr),
fetchedAt,
"",
imgURL,
now,
host,
key,
); err != nil {
return err
}
} else {
if _, err := stmtOnline2.Exec(
true,
fetchedAt,
nullableStringArg(jsonStr),
fetchedAt,
imgURL,
now,
host,
key,
); err != nil {
return err
}
}
continue
}
if stmt1 != nil {
if _, err := stmt1.Exec(
online,
fetchedAt,
nullableStringArg(jsonStr),
// OFFLINE:
// KEIN cb_online_json Update
// KEIN cb_online_fetched_at Update
if stmtOffline1 != nil {
if _, err := stmtOffline1.Exec(
false,
fetchedAt,
"",
imgURL,
now,
host,
key,
@ -1314,12 +1358,9 @@ WHERE lower(trim(host)) = lower(trim($7))
return err
}
} else {
if _, err := stmt2.Exec(
online,
if _, err := stmtOffline2.Exec(
false,
fetchedAt,
nullableStringArg(jsonStr),
fetchedAt,
imgURL,
now,
host,
key,
@ -1327,8 +1368,6 @@ WHERE lower(trim(host)) = lower(trim($7))
return err
}
}
_ = fetchedAtStr // falls du später Logging willst
}
return tx.Commit()

View File

@ -84,7 +84,7 @@ func RecordStream(
}
jobsMu.Lock()
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewM3U8 = strings.TrimSpace(hlsURL)
job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent
if previewDir != "" {

View File

@ -1,8 +1,8 @@
{
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
"ffmpegPath": "",
"autoAddToDownloadList": true,
"autoStartAddedDownloads": true,
@ -10,8 +10,8 @@
"maxConcurrentDownloads": 40,
"useChaturbateApi": true,
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 50,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 100,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "all",

View File

@ -321,9 +321,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
if in.MaxConcurrentDownloads < 1 {
in.MaxConcurrentDownloads = 1
}
if in.MaxConcurrentDownloads > 100 {
in.MaxConcurrentDownloads = 100
}
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
recAbs, err := resolvePathRelativeToApp(in.RecordDir)

View File

@ -1,17 +1,17 @@
{
"name": "frontend",
"version": "0.0.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"version": "1.0.0",
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@tailwindcss/vite": "^4.1.18",
"hls.js": "^1.6.15",
"flag-icons": "^7.5.0",
"prop-types": "^15.8.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@ -2819,6 +2819,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -2933,12 +2939,6 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View File

@ -13,7 +13,7 @@
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@tailwindcss/vite": "^4.1.18",
"hls.js": "^1.6.15",
"flag-icons": "^7.5.0",
"prop-types": "^15.8.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View File

@ -30,6 +30,7 @@ import { useNotify } from './components/ui/notify'
import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage'
import VideoSplitModal from './components/ui/VideoSplitModal'
import TextInput from './components/ui/TextInput'
const COOKIE_STORAGE_KEY = 'record_cookies'
@ -498,10 +499,6 @@ export default function App() {
const doneCountInFlightRef = useRef(false)
const doneCountLastAtRef = useRef(0)
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null)
const finishedReloadLastDispatchAtRef = useRef(0)
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
@ -563,14 +560,16 @@ export default function App() {
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
const selectSourceUrl = useCallback(() => {
const el = sourceUrlInputRef.current
if (!el) return
// Fokus sicherstellen, dann alles markieren
el.focus()
// rAF, damit der Fokus sicher "sitzt" (und für Mobile/Safari stabiler)
requestAnimationFrame(() => el.select())
}, [])
const INCLUDE_KEEP_KEY = 'finishedDownloads_includeKeep_v2'
const [includeKeep, setIncludeKeep] = useState<boolean>(() => {
try {
const raw = window.localStorage.getItem(INCLUDE_KEEP_KEY)
return raw === '1' || raw === 'true' || raw === 'yes'
} catch {
return false
}
})
const checkAuth = useCallback(async () => {
try {
@ -684,7 +683,8 @@ export default function App() {
}
}, [])
const DONE_PAGE_SIZE = 8
const BASE_DONE_PAGE_SIZE = 6
const [donePageSize, setDonePageSize] = useState<number>(BASE_DONE_PAGE_SIZE)
type DoneSortMode =
| 'completed_desc'
@ -714,6 +714,11 @@ export default function App() {
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
const safeCount = Number.isFinite(count) && count > 0 ? count : 0
return Math.max(1, Math.ceil(safeCount / pageSize))
}
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
if (pageToFetch < 1) return
if (donePrefetchInFlightRef.current) return
@ -728,7 +733,9 @@ export default function App() {
donePrefetchInFlightRef.current = true
try {
const res = await fetch(
`/api/record/done?page=${pageToFetch}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
@ -744,7 +751,7 @@ export default function App() {
} finally {
donePrefetchInFlightRef.current = false
}
}, [doneSort])
}, [doneSort, includeKeep])
const loadDoneCount = useCallback(async () => {
const now = Date.now()
@ -757,7 +764,10 @@ export default function App() {
doneCountLastAtRef.current = now
try {
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
const res = await fetch(
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
@ -771,7 +781,7 @@ export default function App() {
} finally {
doneCountInFlightRef.current = false
}
}, [])
}, [includeKeep])
const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
try {
@ -783,12 +793,14 @@ export default function App() {
setLastHeaderUpdateAtMs(Date.now())
donePrefetchRef.current = null
void prefetchDonePage(pageToLoad + 1)
return
}
const res = await fetch(
`/api/record/done?page=${pageToLoad}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(sortToLoad)}`,
`/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
@ -804,63 +816,24 @@ export default function App() {
setDoneJobs(items)
setLastHeaderUpdateAtMs(Date.now())
void prefetchDonePage(pageToLoad + 1)
const countRaw = Number(data?.count ?? data?.totalCount ?? doneCount)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
if (count !== doneCount) {
setDoneCount(count)
}
} catch {
// ignore
}
}, [donePage, doneSort, prefetchDonePage])
const requestFinishedReload = useCallback((reason = 'unknown') => {
const now = Date.now()
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
// Wenn wir nicht im Finished-Tab sind, keine Reload-Events feuern.
// (Count kann separat aktualisiert werden.)
if (selectedTabRef.current !== 'finished') return
// Bereits geplant -> nur koaleszieren
if (finishedReloadTimerRef.current != null) {
return
}
// Harte Dedupe-Schranke
const sinceLast = now - finishedReloadLastDispatchAtRef.current
if (sinceLast < cooldownMs) {
finishedReloadTimerRef.current = window.setTimeout(() => {
finishedReloadTimerRef.current = null
finishedReloadLastDispatchAtRef.current = Date.now()
window.dispatchEvent(
new CustomEvent('finished-downloads:reload', {
detail: { source: `App.requestFinishedReload(cooldown-tail)`, reason },
})
)
}, cooldownMs - sinceLast)
return
}
// Leichtes Coalescing für Burst im selben Moment
finishedReloadTimerRef.current = window.setTimeout(() => {
finishedReloadTimerRef.current = null
finishedReloadLastDispatchAtRef.current = Date.now()
window.dispatchEvent(
new CustomEvent('finished-downloads:reload', {
detail: { source: 'App.requestFinishedReload', reason },
})
)
}, 150)
}, [])
}, [donePage, doneSort, prefetchDonePage, includeKeep, doneCount])
useEffect(() => {
return () => {
if (finishedReloadTimerRef.current != null) {
window.clearTimeout(finishedReloadTimerRef.current)
finishedReloadTimerRef.current = null
}
try {
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
} catch {
// ignore
}
}, [])
}, [includeKeep])
const loadJobs = useCallback(async () => {
try {
@ -2347,14 +2320,11 @@ export default function App() {
useEffect(() => {
if (selectedTab !== 'finished') return
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
void loadDoneCount()
requestFinishedReload('selectedTab effect')
const onVis = () => {
if (!document.hidden) {
void loadDoneCount()
requestFinishedReload('selectedTab visibilitychange')
}
}
document.addEventListener('visibilitychange', onVis)
@ -2362,7 +2332,7 @@ export default function App() {
return () => {
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, loadDoneCount, requestFinishedReload])
}, [selectedTab, loadDoneCount])
useEffect(() => {
if (!authed) return
@ -2386,10 +2356,6 @@ export default function App() {
void loadDoneCount()
void loadJobs()
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
requestFinishedReload('sse fallback poll')
}
}, document.hidden ? 60000 : 5000)
}
@ -2469,11 +2435,6 @@ export default function App() {
if (state === 'done' || state === 'error' || state === 'missing') {
bumpAssets()
}
// ✅ Optional: Finished-Ansicht sichtbar? Dann Liste leicht anstoßen
if (selectedTabRef.current === 'finished') {
requestFinishedReload(`finishedPostwork:${state}`)
}
} catch {}
}
@ -2528,10 +2489,6 @@ export default function App() {
void loadJobs()
void loadDoneCount()
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
requestFinishedReload('visibilitychange')
}
}
document.addEventListener('visibilitychange', onVis)
@ -2559,7 +2516,7 @@ export default function App() {
eventSourceRef.current = null
modelEventNamesRef.current = new Set()
}
}, [authed, loadJobs, loadDoneCount, requestFinishedReload])
}, [authed, loadJobs, loadDoneCount])
useEffect(() => {
const desired = new Set<string>()
@ -2830,7 +2787,7 @@ export default function App() {
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
const need = DONE_PAGE_SIZE - filtered.length
const need = donePageSize - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
@ -2843,7 +2800,7 @@ export default function App() {
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
while (next.length < donePageSize && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
@ -2862,11 +2819,14 @@ export default function App() {
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1)
// ✅ Prefetch wieder nachfüllen aber nur wenn es noch eine nächste Seite gibt
const nextDoneCount = Math.max(0, doneCount - 1)
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount)
if (donePage < totalPagesAfterDelete) {
void prefetchDonePage(donePage + 1)
}
// ✅ wichtig
requestFinishedReload('delete success')
void loadDoneCount()
}, 320)
},
@ -2881,7 +2841,7 @@ export default function App() {
return
}
},
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage]
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage, doneCount]
)
const handleKeepJob = useCallback(
@ -2900,7 +2860,7 @@ export default function App() {
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
const need = DONE_PAGE_SIZE - filtered.length
const need = donePageSize - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
@ -2913,7 +2873,7 @@ export default function App() {
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
while (next.length < donePageSize && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
@ -2932,11 +2892,14 @@ export default function App() {
// ✅ Count runter
setDoneCount((c) => Math.max(0, c - 1))
// ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1)
// ✅ Prefetch wieder nachfüllen aber nur wenn es noch eine nächste Seite gibt
const nextDoneCount = Math.max(0, doneCount - 1)
const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount)
if (donePage < totalPagesAfterKeep) {
void prefetchDonePage(donePage + 1)
}
// ✅ Finished-Liste aktiv neu laden
requestFinishedReload('keep success')
void loadDoneCount()
}, 320)
},
@ -2954,8 +2917,8 @@ export default function App() {
donePage,
doneSort,
prefetchDonePage,
requestFinishedReload,
loadDoneCount,
doneCount,
]
)
@ -3469,25 +3432,14 @@ export default function App() {
<div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
<div className="relative">
<label className="sr-only">Source URL</label>
<input
<TextInput
ref={sourceUrlInputRef}
value={sourceUrl}
size='sm'
onChange={(e) => setSourceUrl(e.target.value)}
onMouseDown={(e) => {
// nur Linksklick
if (e.button !== 0) return
// wenn schon fokussiert: Browser soll Caret nicht irgendwohin setzen
// und wir markieren gleich alles
e.preventDefault()
selectSourceUrl()
}}
onFocus={() => {
// z.B. Tab-Navigation ins Feld
selectSourceUrl()
}}
selectAllOnFocus
selectAllOnMouseDown
placeholder="https://…"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
</div>
@ -3578,7 +3530,8 @@ export default function App() {
doneJobs={doneJobs}
doneTotal={doneCount}
page={donePage}
pageSize={DONE_PAGE_SIZE}
pageSize={donePageSize}
onPageSizeChange={setDonePageSize}
onPageChange={setDonePage}
onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJobWithUndo}
@ -3600,6 +3553,12 @@ export default function App() {
setDonePage(1)
}}
loadMode="paged"
includeKeep={includeKeep}
onIncludeKeepChange={(checked) => {
setIncludeKeep(checked)
setDonePage(1)
donePrefetchRef.current = null
}}
/>
) : null}

View File

@ -178,6 +178,17 @@ function effectiveRoomStatusOfJob(
return raw
}
function previewAlignEndAtOfJob(job: RecordJob): string | null {
const postworkState = getEffectivePostworkState(job)
// Während Nachbearbeitung Preview nicht "abschalten"
if (postworkState === 'running' || postworkState === 'queued') {
return null
}
return job.endedAt ?? null
}
const roomStatusTone = (status: string): string => {
switch (status) {
case 'Public':
@ -951,7 +962,7 @@ export default function Downloads({
growingByJobId
)}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEndAt={previewAlignEndAtOfJob(j)}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
@ -1388,77 +1399,155 @@ export default function Downloads({
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
<div className="flex items-center justify-between gap-2 p-3">
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalCount}
</span>
</div>
<div className="p-3">
{/* Mobile */}
<div className="sm:hidden">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalCount}
</span>
</div>
<div className="shrink-0 flex items-center gap-2 min-w-0">
{concurrentLimitHintVisible ? (
<div
className={[
'flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 whitespace-nowrap',
concurrentLimitReached
? 'bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-100 dark:ring-amber-400/20'
: 'bg-blue-50 text-blue-900 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-100 dark:ring-blue-400/20',
].join(' ')}
{concurrentLimitHintVisible ? (
<div
className={[
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1',
'max-w-[55%] truncate',
concurrentLimitReached
? 'bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-100 dark:ring-amber-400/20'
: 'bg-blue-50 text-blue-900 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-100 dark:ring-blue-400/20',
].join(' ')}
title={
concurrentLimitReached
? `Downloads: ${activeDownloadCount}/${maxConcurrentDownloads} · Limit erreicht`
: `Downloads: ${activeDownloadCount}/${maxConcurrentDownloads}`
}
>
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
</div>
) : null}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="w-full justify-center"
title={
concurrentLimitReached
? 'Maximale Anzahl gleichzeitiger Downloads erreicht'
: 'Begrenzung für gleichzeitige Downloads ist aktiv'
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
{concurrentLimitReached ? ' · Limit erreicht' : ''}
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="w-full justify-center"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div>
{/* Desktop */}
<div className="hidden sm:flex sm:items-center sm:justify-between sm:gap-2">
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
) : null}
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalCount}
</span>
</div>
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<div className="min-w-0 flex items-center gap-2">
{concurrentLimitHintVisible ? (
<div
className={[
'flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 whitespace-nowrap',
concurrentLimitReached
? 'bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-100 dark:ring-amber-400/20'
: 'bg-blue-50 text-blue-900 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-100 dark:ring-blue-400/20',
].join(' ')}
title={
concurrentLimitReached
? 'Maximale Anzahl gleichzeitiger Downloads erreicht'
: 'Begrenzung für gleichzeitige Downloads ist aktiv'
}
>
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
{concurrentLimitReached ? ' · Limit erreicht' : ''}
</div>
) : null}
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="hidden sm:inline-flex"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
title={
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div>
</div>
</div>

View File

@ -184,6 +184,31 @@ function effectiveRoomStatusOfJob(
return raw
}
function previewRoomStatusOfJob(
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>,
growingByJobId?: Record<string, boolean>
): string {
const postworkState = getEffectivePostworkState(job)
if (postworkState === 'running' || postworkState === 'queued') {
return 'Public'
}
return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId)
}
function previewAlignEndAtOfJob(job: RecordJob): string | null {
const postworkState = getEffectivePostworkState(job)
if (postworkState === 'running' || postworkState === 'queued') {
return null
}
return job.endedAt ?? null
}
const roomStatusTone = (status: string): string => {
switch (status) {
case 'Public':
@ -682,9 +707,14 @@ export default function DownloadsCardRow({
<ModelPreview
jobId={j.id}
blur={blurPreviews}
roomStatus={roomStatus}
roomStatus={previewRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEndAt={previewAlignEndAtOfJob(j)}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,521 @@
// frontend\src\components\ui\FinishedDownloadsGalleryCard.tsx
'use client'
import * as React from 'react'
import type {
RecordJob,
GalleryModelFlags,
FinishedPostworkSummary,
} from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
import Checkbox from './Checkbox'
import {
buildSpriteFrameStyle,
parseJobMeta,
firstNonEmptyString,
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
return value > 24 * 60 * 60 ? value / 1000 : value
}
type Props = {
job: RecordJob
checked: boolean
hasAnySelection: boolean
isPreviewActive: boolean
forcePreviewMuted?: boolean
blurPreviews?: boolean
postworkByFile: Record<string, FinishedPostworkSummary>
getPostworkSummaryForOutput: (
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
durations: Record<string, number>
teaserState: FinishedDownloadsTeaserState
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
formatBytes: (bytes?: number | null) => string
lower: (s: string) => string
modelsByKey: Record<string, GalleryModelFlags>
activeTagSet: Set<string>
deletingKeys: Set<string>
keepingKeys: Set<string>
removingKeys: Set<string>
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
onToggleSelected: (job: RecordJob) => void
onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void
onOpenPlayer: (job: RecordJob) => void
handleDuration: (job: RecordJob, seconds: number) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
onToggleHot: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
jobForDetails: (job: RecordJob) => RecordJob
contentPaddingClass: string
footerMinHeightClass: string
titleClass: string
sublineClass: string
}
function FinishedDownloadsGalleryCardInner({
job: j,
checked,
hasAnySelection,
isPreviewActive,
forcePreviewMuted,
blurPreviews,
postworkByFile,
getPostworkSummaryForOutput,
renderPostworkBadge,
durations,
teaserState,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
lower,
modelsByKey,
activeTagSet,
deletingKeys,
keepingKeys,
removingKeys,
registerTeaserHost,
onToggleSelected,
onHoverPreviewKeyChange,
onToggleTagFilter,
onOpenPlayer,
handleDuration,
handleScrubberClickIndex,
deleteVideo,
keepVideo,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
jobForDetails,
contentPaddingClass,
footerMinHeightClass,
titleClass,
sublineClass,
}: Props) {
const k = keyFor(j)
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
setScrubIndexByKey((prev) => {
if (index === undefined) {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
}
if (prev[key] === index) return prev
return { ...prev, [key]: index }
})
}, [])
const clearScrubIndex = React.useCallback((key: string) => {
setScrubIndexForKey(key, undefined)
}, [setScrubIndexForKey])
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const tags = React.useMemo(() => {
const s = String(flags?.tags ?? '').trim()
if (!s) return [] as string[]
const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const kk = p.toLowerCase()
if (seen.has(kk)) continue
seen.add(kk)
out.push(p)
}
return out
}, [flags?.tags])
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
const showPostworkBadge = !!postworkBadge
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const meta = React.useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta])
const resObj = React.useMemo(() => {
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
if (
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
typeof h === 'number' && Number.isFinite(h) && h > 0
) {
return { w, h }
}
return null
}, [meta])
const resLabel = formatResolution(resObj)
const isRemoving = removingKeys.has(k)
const isDeleting = deletingKeys.has(k)
const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isKeeping || isRemoving
const modelImageSrc = firstNonEmptyString(
flags?.image,
flags?.imageUrl,
(j as any)?.meta?.modelImage,
(j as any)?.meta?.modelImageUrl
)
const sprite = React.useMemo(
() =>
readPreviewSpriteInfo((j as any)?.meta, {
fallbackPath: flags?.previewScrubberPath,
fallbackCount: flags?.previewScrubberCount,
}),
[(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount]
)
const spriteUrl = sprite.url
const hasScrubberUi = sprite.hasScrubberUi
const hasSpriteScrubber = sprite.hasSpriteScrubber
const scrubberCount = sprite.count
const scrubberStepSeconds = sprite.stepSeconds
const hasScrubber = hasScrubberUi
const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
const spriteFrameStyle = React.useMemo(
() =>
buildSpriteFrameStyle({
spriteUrl,
spriteCols: sprite.cols,
spriteRows: sprite.rows,
spriteCount: sprite.count,
activeIndex: activeScrubIndex,
}),
[spriteUrl, sprite.cols, sprite.rows, sprite.count, activeScrubIndex]
)
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
const previewDurationSeconds =
durations[k] ??
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
normalizeDurationSeconds(meta?.file?.durationSeconds)
return (
<div className="relative">
<div
role="button"
tabIndex={0}
data-finished-hover-key={k}
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
onClick={(e) => {
if (hasAnySelection) {
e.preventDefault()
e.stopPropagation()
onToggleSelected(j)
return
}
onOpenPlayer(j)
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => {
onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<div
className={[
'relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-[transform,opacity,box-shadow,background-color] duration-180 ease-out',
'will-change-[transform,opacity]',
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
busy && !isRemoving && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100',
].filter(Boolean).join(' ')}
>
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHost(k)}
onMouseEnter={() => setHoveredThumbKey(k)}
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={previewDurationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: hideTeaserUnderOverlay,
})}
animatedMode="teaser"
animatedTrigger="always"
muted={previewMuted}
popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'}
forceActive={isPreviewActive}
teaserPreloadEnabled
/>
</div>
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div className="h-full w-full" style={spriteFrameStyle} />
</div>
) : null}
{showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
<img
src={modelImageSrc}
alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
draggable={false}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
<div className="text-[10px] font-semibold tracking-wide text-white/95">
MODEL PREVIEW
</div>
</div>
</div>
) : null}
{hasScrubber && hoveredThumbKey === k ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
onIndexClick={(index) => {
setScrubIndexForKey(k, index)
handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
</div>
</div>
<div
className={[
'relative flex flex-col rounded-b-lg border-t border-black/5 bg-white dark:border-white/10 dark:bg-gray-900',
footerMinHeightClass,
contentPaddingClass,
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onOpenPlayer(j)
}}
>
<div className="min-w-0">
<div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0">
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
{model}
</div>
{showPostworkBadge && renderPostworkBadge ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)}
</div>
) : null}
</div>
<div className="mt-0.5 flex min-h-[18px] items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span
className={['min-w-0 truncate text-gray-500 dark:text-gray-400', sublineClass].join(' ')}
title={stripHotPrefix(file) || '—'}
>
{stripHotPrefix(file) || '—'}
</span>
</div>
</div>
<div
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div>
</div>
</div>
)
}
const FinishedDownloadsGalleryCard = React.memo(FinishedDownloadsGalleryCardInner)
export default FinishedDownloadsGalleryCard

View File

@ -7,32 +7,18 @@ import type {
GalleryModelFlags,
FinishedPostworkSummary,
} from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
shouldObserveTeasers,
} from './teaserPlayback'
import Checkbox from './Checkbox'
import {
buildSpriteFrameStyle,
parseJobMeta,
firstNonEmptyString,
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard'
type Props = {
rows: RecordJob[]
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
onColumnCountChange?: (count: number) => void
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
@ -43,6 +29,7 @@ type Props = {
) => FinishedPostworkSummary | undefined
teaserState: FinishedDownloadsTeaserState
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
cardScale?: number
handleDuration: (job: RecordJob, seconds: number) => void
@ -83,10 +70,15 @@ type Props = {
forcePreviewMuted?: boolean
}
function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
// ms -> s Heuristik wie in FinishedVideoPreview
return value > 24 * 60 * 60 ? value / 1000 : value
function snapTo(value: number, step: number, base = 0) {
if (!Number.isFinite(value)) return base
if (!Number.isFinite(step) || step <= 0) return value
const snapped = Math.round((value - base) / step) * step + base
return Number(snapped.toFixed(4))
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
export default function FinishedDownloadsGalleryView({
@ -98,9 +90,10 @@ export default function FinishedDownloadsGalleryView({
getPostworkSummaryForOutput,
teaserState,
renderPostworkBadge,
cardScale,
onColumnCountChange,
selectedKeys,
onToggleSelected,
handleDuration,
handleScrubberClickIndex,
jobForDetails,
@ -110,13 +103,10 @@ export default function FinishedDownloadsGalleryView({
runtimeOf,
sizeBytesOf,
formatBytes,
deletingKeys,
keepingKeys,
removingKeys,
registerTeaserHost,
onHoverPreviewKeyChange,
onOpenPlayer,
deleteVideo,
@ -133,11 +123,8 @@ export default function FinishedDownloadsGalleryView({
onAddToDownloads,
forcePreviewMuted,
}: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const observeTeasers = shouldObserveTeasers(teaserState.mode)
const hasAnySelection = selectedKeys.size > 0
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (!observeTeasers) {
@ -149,444 +136,137 @@ export default function FinishedDownloadsGalleryView({
[registerTeaserHost, observeTeasers]
)
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const effectiveScaleRaw =
typeof cardScale === 'number'
? cardScale
: 1
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
return out
}
const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35)
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const meta = parseJobMeta((j as any)?.meta)
const layoutScale = React.useMemo(
() => snapTo(effectiveScale, 0.05, 0.8),
[effectiveScale]
)
const w =
meta?.media?.video?.width ??
meta?.file?.video?.width
const containerRef = React.useRef<HTMLDivElement | null>(null)
const [containerWidth, setContainerWidth] = React.useState(0)
const h =
meta?.media?.video?.height ??
meta?.file?.video?.height
const gapPx = 12
if (
typeof w === 'number' &&
Number.isFinite(w) &&
w > 0 &&
typeof h === 'number' &&
Number.isFinite(h) &&
h > 0
) {
return { w, h }
const preferredCardWidth = React.useMemo(() => {
const base = 240
return Math.round(base * layoutScale)
}, [layoutScale])
const columnCount = React.useMemo(() => {
if (!containerWidth || containerWidth <= 0) {
return 1
}
return null
const columnsThatFit = Math.floor(
(containerWidth + gapPx) / (preferredCardWidth + gapPx)
)
return Math.max(1, columnsThatFit)
}, [containerWidth, preferredCardWidth])
React.useEffect(() => {
onColumnCountChange?.(columnCount)
}, [columnCount, onColumnCountChange])
const cardMinWidth = React.useMemo(() => {
return preferredCardWidth
}, [preferredCardWidth])
const gridStyle = React.useMemo<React.CSSProperties>(() => {
return {
['--gallery-card-min' as any]: `${cardMinWidth}px`,
}
}, [cardMinWidth])
const contentPaddingClass = 'px-3.5 py-3'
const footerMinHeightClass = 'min-h-[118px]'
const titleClass = 'text-sm'
const sublineClass = 'text-xs'
React.useLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const update = () => {
setContainerWidth(el.clientWidth || 0)
}
update()
const ro = new ResizeObserver(() => update())
ro.observe(el)
return () => {
ro.disconnect()
}
}, [])
// ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet)
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
// ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte)
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
setScrubIndexByKey((prev) => {
if (index === undefined) {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
}
if (prev[key] === index) return prev
return { ...prev, [key]: index }
})
}, [])
const clearScrubIndex = React.useCallback((key: string) => {
setScrubIndexForKey(key, undefined)
}, [setScrubIndexForKey])
return (
<div className="relative">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<div className="relative w-full" ref={containerRef}>
<div
className="grid w-full gap-3"
style={{
...gridStyle,
gridAutoFlow: 'row',
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
}}
>
{rows.map((j) => {
const k = keyFor(j)
const checked = selectedKeys.has(k)
const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
const showPostworkBadge = !!postworkBadge
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const isRemoving = removingKeys.has(k)
const isDeleting = deletingKeys.has(k)
const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isKeeping || isRemoving
// ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
const modelImageSrc = firstNonEmptyString(
flags?.image,
flags?.imageUrl,
(j as any)?.meta?.modelImage,
(j as any)?.meta?.modelImageUrl
)
// meta robust lesen (Objekt oder JSON-String)
const meta = parseJobMeta((j as any)?.meta)
// ------------------------------------------------------------
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
// ------------------------------------------------------------
const sprite = readPreviewSpriteInfo((j as any)?.meta, {
fallbackPath: flags?.previewScrubberPath,
fallbackCount: flags?.previewScrubberCount,
})
const spriteUrl = sprite.url
const hasScrubberUi = sprite.hasScrubberUi
const hasSpriteScrubber = sprite.hasSpriteScrubber
const scrubberCount = sprite.count
const scrubberStepSeconds = sprite.stepSeconds
const hasScrubber = hasScrubberUi
const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
// Sprite-Overlay-Frame (kein Request pro Move)
const spriteFrameStyle = buildSpriteFrameStyle({
spriteUrl,
spriteCols: sprite.cols,
spriteRows: sprite.rows,
spriteCount: sprite.count,
activeIndex: activeScrubIndex,
})
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
const hideTeaserUnderOverlay =
showModelPreviewInThumb || showScrubberSpriteInThumb
const previewDurationSeconds =
durations[k] ??
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
normalizeDurationSeconds(meta?.file?.durationSeconds)
return (
<div key={k} className="relative">
<div
role="button"
tabIndex={0}
data-finished-hover-key={k}
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
onClick={(e) => {
if (hasAnySelection) {
e.preventDefault()
e.stopPropagation()
onToggleSelected(j)
return
}
onOpenPlayer(j)
}}
onMouseEnter={() => {
onHoverPreviewKeyChange?.(k)
}}
onMouseLeave={() => {
onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<div
className={[
'relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
'will-change-[transform,opacity]',
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
busy && !isRemoving && 'pointer-events-none opacity-70',
isDeleting &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving
? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
: 'opacity-100 translate-y-0 scale-100',
]
.filter(Boolean)
.join(' ')}
>
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
{/* Thumb */}
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => {
setHoveredThumbKey(k)
}}
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={previewDurationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: hideTeaserUnderOverlay,
})}
animatedMode="teaser"
animatedTrigger="always"
muted={previewMuted}
popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'}
forceActive={isPreviewActive}
teaserPreloadEnabled
/>
</div>
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
{showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div
className="h-full w-full"
style={spriteFrameStyle}
/>
</div>
) : null}
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
{showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
<img
src={modelImageSrc}
alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
draggable={false}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
<div className="text-[10px] font-semibold tracking-wide text-white/95">
MODEL PREVIEW
</div>
</div>
</div>
) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{hasScrubber && hoveredThumbKey === k ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
onIndexClick={(index) => {
setScrubIndexForKey(k, index)
handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
{/* Meta-Overlay im Video: unten rechts */}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
</div>
</div>
{/* Footer / Meta */}
<div
className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onOpenPlayer(j)
}}
>
{/* Model oben + Badge direkt dahinter */}
<div className="min-w-0">
<div className="mt-0.5 flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
{showPostworkBadge && renderPostworkBadge ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)}
</div>
) : null}
</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span
className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
title={stripHotPrefix(file) || '—'}
>
{stripHotPrefix(file) || '—'}
</span>
</div>
</div>
{/* Actions (wie CardView: im Footer statt im Video) */}
<div
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div>
</div>
</div>
<FinishedDownloadsGalleryCard
key={k}
job={j}
checked={selectedKeys.has(k)}
hasAnySelection={selectedKeys.size > 0}
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
forcePreviewMuted={forcePreviewMuted}
blurPreviews={blurPreviews}
postworkByFile={postworkByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
renderPostworkBadge={renderPostworkBadge}
durations={durations}
teaserState={teaserState}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
registerTeaserHost={registerTeaserHostIfNeeded}
onToggleSelected={onToggleSelected}
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onToggleTagFilter={onToggleTagFilter}
onOpenPlayer={onOpenPlayer}
handleDuration={handleDuration}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={onToggleHot}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
jobForDetails={jobForDetails}
contentPaddingClass={contentPaddingClass}
footerMinHeightClass={footerMinHeightClass}
titleClass={titleClass}
sublineClass={sublineClass}
/>
)
})}
</div>

View File

@ -5,6 +5,7 @@
import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import Button from './Button'
type ModalLayout = 'single' | 'split'
type ModalScroll = 'body' | 'right' | 'none'
@ -12,7 +13,7 @@ type ModalScroll = 'body' | 'right' | 'none'
type ModalProps = {
open: boolean
onClose: () => void
title?: string
title?: ReactNode
titleRight?: ReactNode
children?: ReactNode
footer?: ReactNode
@ -201,20 +202,17 @@ export default function Modal({
<div className="shrink-0 flex items-center gap-2">
{titleRight ? <div className="hidden sm:block">{titleRight}</div> : null}
<button
<Button
type="button"
variant="secondary"
size="sm"
onClick={onClose}
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
)}
aria-label="Schließen"
title="Schließen"
className="shrink-0 px-2 min-w-0"
>
<XMarkIcon className="size-5" />
</button>
</Button>
</div>
</div>
@ -272,7 +270,7 @@ export default function Modal({
{mobileCollapsedImageSrc ? (
<img
src={mobileCollapsedImageSrc}
alt={mobileCollapsedImageAlt || title || ''}
alt={mobileCollapsedImageAlt || (typeof title === 'string' ? title : '')}
className={cn(
'shrink-0 rounded-lg object-cover ring-1 ring-black/5 dark:ring-white/10',
mobileCollapsed ? 'size-8' : 'size-10'
@ -299,20 +297,17 @@ export default function Modal({
<div className="shrink-0 flex items-center gap-2">
{titleRight ? <div>{titleRight}</div> : null}
<button
<Button
type="button"
variant="secondary"
size="sm"
onClick={onClose}
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
)}
aria-label="Schließen"
title="Schließen"
className="shrink-0 px-2 min-w-0"
>
<XMarkIcon className="size-5" />
</button>
</Button>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,171 @@
// frontend\src\components\ui\ModelGenderIcon.tsx
'use client'
import * as React from 'react'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
type GenderKind = 'female' | 'male' | 'trans' | 'couple' | null
function normalizeGender(
gender?: string | null,
sex?: string | null,
subgender?: string | null
): GenderKind {
const raw = `${gender || ''} ${sex || ''} ${subgender || ''}`.trim().toLowerCase()
if (!raw) return null
if (raw.includes('couple') || raw.includes('pair')) {
return 'couple'
}
if (
raw.includes('trans') ||
raw.includes('transgender') ||
raw.includes('ts') ||
raw.includes('shemale')
) {
return 'trans'
}
if (
raw.includes('female') ||
raw.includes('girl') ||
raw.includes('woman')
) {
return 'female'
}
if (
raw.includes('male') ||
raw.includes('boy') ||
raw.includes('man')
) {
return 'male'
}
return null
}
type IconProps = {
className?: string
}
function BaseIcon({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={cn('size-3.5', className)}
>
{children}
</svg>
)
}
function FemaleIcon({ className }: IconProps) {
return (
<BaseIcon className={className}>
<circle cx="12" cy="8" r="4.25" />
<path d="M12 12.25V20" />
<path d="M9 17H15" />
</BaseIcon>
)
}
function MaleIcon({ className }: IconProps) {
return (
<BaseIcon className={className}>
<circle cx="9" cy="15" r="4.25" />
<path d="M12 12L19 5" />
<path d="M14.75 5H19V9.25" />
</BaseIcon>
)
}
function TransIcon({ className }: IconProps) {
return (
<BaseIcon className={className}>
<circle cx="10.5" cy="10.5" r="3.75" />
<path d="M13.25 7.75L18.5 2.5" />
<path d="M15.75 2.5H18.5V5.25" />
<path d="M10.5 14.25V21" />
<path d="M7.75 18.25H13.25" />
<path d="M6.5 10.5H2.75" />
<path d="M4.5 8.75L2.75 10.5L4.5 12.25" />
</BaseIcon>
)
}
function CoupleIcon({ className }: IconProps) {
return (
<BaseIcon className={className}>
<circle cx="8" cy="9" r="3" />
<circle cx="16" cy="9" r="3" />
<path d="M3.75 18.5C4.5 15.75 6 14.5 8 14.5C10 14.5 11.5 15.75 12.25 18.5" />
<path d="M11.75 18.5C12.5 15.75 14 14.5 16 14.5C18 14.5 19.5 15.75 20.25 18.5" />
</BaseIcon>
)
}
type Props = {
gender?: string | null
sex?: string | null
subgender?: string | null
variant?: 'hero' | 'default'
className?: string
iconClassName?: string
}
export default function ModelGenderIcon({
gender,
sex,
subgender,
variant = 'default',
className,
iconClassName,
}: Props) {
const kind = normalizeGender(gender, sex, subgender)
if (!kind) return null
const map: Record<Exclude<GenderKind, null>, { label: string; Icon: React.ComponentType<IconProps> }> = {
female: { label: 'Female', Icon: FemaleIcon },
male: { label: 'Male', Icon: MaleIcon },
trans: { label: 'Trans', Icon: TransIcon },
couple: { label: 'Couple', Icon: CoupleIcon },
}
const { label, Icon } = map[kind]
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full ring-1 ring-inset',
variant === 'hero'
? 'size-5 bg-black/30 text-white ring-white/20 backdrop-blur'
: 'size-5 bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-white dark:ring-white/15',
className
)}
title={label}
aria-label={label}
>
<Icon className={iconClassName} />
</span>
)
}

View File

@ -290,7 +290,7 @@ export default function ModelPreview({
setPopupVolume(nextVolume)
setPopupMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom relative z-0"
className="w-full h-full object-contain object-center relative z-0"
/>
{showLiveBadge ? (
@ -303,7 +303,7 @@ export default function ModelPreview({
<div className="absolute right-2 bottom-2 z-[60]">
<button
type="button"
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-black/65 px-2.5 py-1.5 text-white shadow-sm ring-1 ring-white/10 hover:bg-black/75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-white/90 px-2.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-black/10 hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500/70 dark:bg-black/55 dark:text-white dark:ring-white/10 dark:hover:bg-black/70 dark:focus-visible:outline-white/70"
title={popupMuted ? 'Ton an' : 'Stumm'}
aria-label={popupMuted ? 'Ton an' : 'Stumm'}
onClick={(e) => {

View File

@ -308,9 +308,7 @@ export default function Player({
}))
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
const isRunning = job.status === 'running'
const [hlsReady, setHlsReady] = React.useState(false)
const isLive = isRunning && hlsReady
const isLive = job.status === 'running'
const [liveMuted, setLiveMuted] = React.useState(startMuted)
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
@ -321,12 +319,12 @@ export default function Player({
const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
const previewId = React.useMemo(
() => (isRunning ? job.id : finishedStem || job.id),
[isRunning, job.id, finishedStem]
() => (isLive ? job.id : finishedStem || job.id),
[isLive, job.id, finishedStem]
)
React.useEffect(() => {
if (isRunning) return
if (isLive) return
if (fullDurationSec > 0) return
const fileName = baseName(job.output?.trim() || '')
@ -365,7 +363,7 @@ export default function Player({
alive = false
ctrl.abort()
}
}, [isRunning, fullDurationSec, job.output])
}, [isLive, fullDurationSec, job.output])
const isHotFile = fileRaw.startsWith('HOT ')
const model = React.useMemo(() => {
@ -455,56 +453,6 @@ export default function Player({
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const hlsIndexUrl = React.useMemo(() => {
const u = `/api/preview?id=${encodeURIComponent(previewId)}&play=1`
return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
}, [previewId, isRunning])
React.useEffect(() => {
if (!isRunning) {
setHlsReady(false)
return
}
let alive = true
const ctrl = new AbortController()
setHlsReady(false)
const poll = async () => {
for (let i = 0; i < 120 && alive && !ctrl.signal.aborted; i++) {
try {
const res = await apiFetch(hlsIndexUrl, {
method: 'GET',
cache: 'no-store',
signal: ctrl.signal,
headers: { 'cache-control': 'no-cache' },
})
if (res.ok) {
const text = await res.text()
// ✅ muss wirklich wie eine m3u8 aussehen und mindestens 1 Segment enthalten
const hasM3u = text.includes('#EXTM3U')
const hasSegment =
/#EXTINF:/i.test(text) || /\.ts(\?|$)/i.test(text) || /\.m4s(\?|$)/i.test(text)
if (hasM3u && hasSegment) {
if (alive) setHlsReady(true)
return
}
}
} catch {}
await new Promise((r) => setTimeout(r, 500))
}
}
poll()
return () => {
alive = false
ctrl.abort()
}
}, [isRunning, hlsIndexUrl])
const buildVideoSrc = React.useCallback(
(params: { file?: string; id?: string }) => {
const qp = new URLSearchParams()
@ -517,7 +465,7 @@ export default function Player({
const media = React.useMemo(() => {
// ✅ Live wird NICHT mehr über Video.js gespielt
if (isRunning) return { src: '', type: '' }
if (isLive) return { src: '', type: '' }
// ✅ Warten bis meta.json existiert + Infos geladen
if (!metaReady) return { src: '', type: '' }
@ -532,7 +480,7 @@ export default function Player({
}
return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' }
}, [isRunning, metaReady, job.output, job.id, buildVideoSrc])
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
const containerRef = React.useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null)
@ -612,7 +560,7 @@ export default function Player({
const appliedStartSeekRef = React.useRef<string>('')
React.useEffect(() => {
if (isRunning) {
if (isLive) {
setMetaReady(true)
return
}
@ -679,7 +627,7 @@ export default function Player({
alive = false
ctrl.abort()
}
}, [isRunning, playbackKey, job.output])
}, [isLive, playbackKey, job.output])
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [, setVvTick] = React.useState(0)
@ -790,7 +738,7 @@ export default function Player({
React.useEffect(() => {
const p: any = playerRef.current
if (!p || p.isDisposed?.()) return
if (isRunning) return // live nutzt Video.js nicht
if (isLive) return // live nutzt Video.js nicht
installAbsoluteTimelineShim(p)
@ -821,13 +769,13 @@ export default function Player({
delete p.__serverSeekAbs
} catch {}
}
}, [job.output, isRunning])
}, [job.output, isLive])
React.useLayoutEffect(() => {
if (!mounted) return
if (!containerRef.current) return
if (playerRef.current) return
if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
if (isLive) return // ✅ neu: für Live keinen Video.js mounten
if (!metaReady) return
const videoEl = document.createElement('video')
@ -896,7 +844,7 @@ export default function Player({
}
}
}
}, [mounted, startMuted, isRunning, metaReady, videoH, updateIntrinsicDims])
}, [mounted, startMuted, isLive, metaReady, videoH, updateIntrinsicDims])
React.useEffect(() => {
const p = playerRef.current
@ -944,7 +892,7 @@ export default function Player({
React.useEffect(() => {
if (!mounted) return
if (!isRunning && !metaReady) {
if (!isLive && !metaReady) {
releaseMedia()
return
}
@ -1025,11 +973,11 @@ export default function Player({
})
tryPlay()
}, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
}, [mounted, isLive, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
React.useEffect(() => {
if (!mounted) return
if (isRunning) return // Live spielt nicht über Video.js
if (isLive) return // Live spielt nicht über Video.js
if (!metaReady) return
if (!media.src) return
@ -1100,7 +1048,7 @@ export default function Player({
}
}, [
mounted,
isRunning,
isLive,
metaReady,
media.src,
playbackKey,
@ -1108,23 +1056,6 @@ export default function Player({
seekPlayerToAbsolute,
])
React.useEffect(() => {
if (!mounted) return
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
const onErr = () => {
if (job.status === 'running') setHlsReady(false)
}
p.on('error', onErr)
return () => {
try {
p.off('error', onErr)
} catch {}
}
}, [mounted, job.status])
React.useEffect(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
@ -1226,7 +1157,7 @@ export default function Player({
if (typeof window === 'undefined') return r
const ratio = getVideoAspectRatio()
const BAR_H = isRunning ? 0 : 30 // gewünschter fixer Platz unter dem Video für die Controlbar
const BAR_H = isLive ? 0 : 30 // gewünschter fixer Platz unter dem Video für die Controlbar
const { w: vw, h: vh } = getViewport()
const maxW = vw - MARGIN * 2
@ -1261,7 +1192,7 @@ export default function Player({
return { x, y, w: Math.round(w), h }
},
[getVideoAspectRatio, isRunning]
[getVideoAspectRatio, isLive]
)
const loadRect = React.useCallback(() => {
@ -1642,11 +1573,11 @@ export default function Player({
const phaseRaw = String((job as any).phase ?? '')
const phase = phaseRaw.toLowerCase()
const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving'
const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending
const stopDisabled = !onStopJob || !isLive || isStoppingLike || stopPending
const footerRight = (
<div className="flex items-center gap-1 min-w-0">
{isRunning ? (
{isLive ? (
<>
<Button
variant="primary"
@ -1747,7 +1678,7 @@ export default function Player({
const fullSize = expanded || miniDesktop
const metaBottom = isRunning
const metaBottom = isLive
? `calc(4px + env(safe-area-inset-bottom))`
: `calc(${controlBarH + 2}px + env(safe-area-inset-bottom))`
@ -1772,7 +1703,7 @@ export default function Player({
className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
>
{isRunning ? (
{isLive ? (
<div className="absolute inset-0 bg-black">
<LiveVideo
src={liveHlsSrc}
@ -1782,7 +1713,7 @@ export default function Player({
setLiveVolume(nextVolume)
setLiveMuted(nextMuted)
}}
className="w-full h-full object-contain object-bottom"
className="w-full h-full object-cover object-center"
/>
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
@ -1899,7 +1830,7 @@ export default function Player({
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{resolutionLabel}</span>
) : null}
{!isRunning ? (
{!isLive ? (
<>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
{runtimeLabel}
@ -1945,7 +1876,7 @@ export default function Player({
<div className="pointer-events-auto">
<div className="flex items-center justify-center gap-2 flex-wrap">
{isRunning ? (
{isLive ? (
<Button
variant="primary"
color="red"
@ -2020,7 +1951,7 @@ export default function Player({
}
: undefined
}
order={isRunning ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
order={isLive ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
className="flex items-center justify-start gap-1"
/>
</div>
@ -2120,7 +2051,7 @@ export default function Player({
{resolutionLabel !== '—' ? (
<span className="rounded bg-black/45 px-1.5 py-0.5">{resolutionLabel}</span>
) : null}
{!isRunning && runtimeLabel !== '—' ? (
{!isLive && runtimeLabel !== '—' ? (
<span className="rounded bg-black/45 px-1.5 py-0.5">{runtimeLabel}</span>
) : null}
</div>

View File

@ -0,0 +1,460 @@
// frontend\src\components\ui\Slider.tsx
'use client'
import * as React from 'react'
type Props = {
id?: string
value: number
onChange: (value: number) => void
onChangeEnd?: (value: number) => void
min?: number
max?: number
step?: number
disabled?: boolean
label?: string
hint?: string
valueLabel?: string
valueFormatter?: (value: number) => string
showValueLabel?: boolean
showMinMaxLabels?: boolean
minLabel?: string
maxLabel?: string
showReset?: boolean
resetValue?: number
resetLabel?: string
compact?: boolean
softStep?: boolean
className?: string
inputClassName?: string
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function countDecimals(value: number) {
if (!Number.isFinite(value)) return 0
const s = String(value)
const i = s.indexOf('.')
return i >= 0 ? s.length - i - 1 : 0
}
function roundToStep(value: number, step: number, min: number) {
if (!Number.isFinite(step) || step <= 0) return value
const precision = Math.max(countDecimals(step), countDecimals(min))
const rounded = Math.round((value - min) / step) * step + min
return Number(rounded.toFixed(precision))
}
export default function Slider({
id,
value,
onChange,
onChangeEnd,
min = 0,
max = 100,
step = 1,
disabled = false,
label,
hint,
valueLabel,
valueFormatter,
showValueLabel = false,
showMinMaxLabels = false,
minLabel,
maxLabel,
showReset = false,
resetValue,
resetLabel = 'Reset',
compact = false,
softStep = true,
className = '',
inputClassName = '',
}: Props) {
const reactId = React.useId()
const inputId = id ?? `slider-${reactId}`
const safeValue = Number.isFinite(value) ? value : min
const clamped = clamp(safeValue, min, max)
const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0
const emitCommit = () => {
onChangeEnd?.(clamped)
}
const resolvedValueLabel =
valueLabel ??
(valueFormatter ? valueFormatter(clamped) : String(clamped))
const resolvedMinLabel =
minLabel ?? (valueFormatter ? valueFormatter(min) : String(min))
const resolvedMaxLabel =
maxLabel ?? (valueFormatter ? valueFormatter(max) : String(max))
const canReset =
showReset &&
typeof resetValue === 'number' &&
Number.isFinite(resetValue)
const trackH = compact ? 4 : 8
const thumbSize = compact ? 14 : 16
const inputH = compact ? 14 : 16
const thumbOffset = -((thumbSize - trackH) / 2)
const handleChange = (next: number) => {
const raw = clamp(next, min, max)
const finalValue = softStep ? raw : roundToStep(raw, step, min)
onChange(finalValue)
}
return (
<div
className={[
compact
? 'inline-flex min-w-0 items-center gap-3 rounded-md border border-gray-200/70 bg-white/60 px-3 h-9 shadow-sm dark:border-white/10 dark:bg-white/5'
: 'rounded-xl border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
className,
].join(' ')}
>
{compact ? (
<>
{label ? (
<label
htmlFor={inputId}
className="shrink-0 text-sm font-medium leading-none text-gray-900 dark:text-gray-100"
>
{label}
</label>
) : null}
<div className="relative min-w-0 flex-1">
<div className="pointer-events-none absolute inset-y-0 left-0 right-0">
<div
className="absolute left-0 right-0 rounded-full bg-gray-200/90 dark:bg-white/10"
style={{
height: `${trackH}px`,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
<div
className="absolute left-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{
width: `${percent}%`,
height: `${trackH}px`,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
</div>
<input
id={inputId}
type="range"
min={min}
max={max}
step={softStep ? 'any' : step}
value={clamped}
disabled={disabled}
aria-label={label ?? 'Slider'}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={clamped}
className={[
`
relative z-10 block w-full cursor-pointer appearance-none bg-transparent
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none touch-pan-x
[&::-webkit-slider-runnable-track]:bg-transparent
[&::-webkit-slider-runnable-track]:appearance-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-white/80
[&::-webkit-slider-thumb]:bg-indigo-500
[&::-webkit-slider-thumb]:shadow-sm
dark:[&::-webkit-slider-thumb]:border-gray-950
dark:[&::-webkit-slider-thumb]:bg-indigo-400
[&::-moz-range-track]:bg-transparent
[&::-moz-range-track]:border-0
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-white/80
[&::-moz-range-thumb]:bg-indigo-500
[&::-moz-range-thumb]:shadow-sm
dark:[&::-moz-range-thumb]:border-gray-950
dark:[&::-moz-range-thumb]:bg-indigo-400
`,
inputClassName,
].join(' ')}
style={
{
height: `${inputH}px`,
WebkitAppearance: 'none',
appearance: 'none',
['--slider-track-h' as any]: `${trackH}px`,
['--slider-thumb-size' as any]: `${thumbSize}px`,
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
} as React.CSSProperties
}
onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={emitCommit}
onMouseUp={emitCommit}
onTouchEnd={emitCommit}
onKeyUp={(e) => {
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Home' ||
e.key === 'End' ||
e.key === 'PageUp' ||
e.key === 'PageDown'
) {
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
}
}}
/>
<style>{`
input[type='range']::-webkit-slider-runnable-track {
height: var(--slider-track-h);
}
input[type='range']::-webkit-slider-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
margin-top: var(--slider-thumb-offset);
-webkit-appearance: none;
}
input[type='range']::-moz-range-track {
height: var(--slider-track-h);
}
input[type='range']::-moz-range-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
appearance: none;
}
`}</style>
</div>
{showValueLabel ? (
<div className="shrink-0 text-xs font-medium tabular-nums text-gray-500 dark:text-gray-400">
{resolvedValueLabel}
</div>
) : null}
{canReset ? (
<button
type="button"
disabled={disabled}
onClick={() => handleChange(resetValue!)}
className="
inline-flex h-7 shrink-0 items-center rounded-md px-2 text-xs font-medium
text-gray-700 ring-1 ring-gray-200 transition-colors
hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50
dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10
"
>
{resetLabel}
</button>
) : null}
</>
) : (
<>
{(label || hint || showValueLabel || canReset) ? (
<div className="mb-2 flex items-start justify-between gap-3">
{(label || hint) ? (
<div className="min-w-0">
{label ? (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{label}
</label>
) : null}
{hint ? (
<div className="text-xs text-gray-600 dark:text-gray-300">
{hint}
</div>
) : null}
</div>
) : null}
{(showValueLabel || canReset) ? (
<div className="flex shrink-0 items-center gap-2">
{showValueLabel ? (
<div className="rounded-md bg-gray-100 px-2 py-1 text-xs font-medium tabular-nums text-gray-500 dark:bg-white/10 dark:text-gray-400">
{resolvedValueLabel}
</div>
) : null}
{canReset ? (
<button
type="button"
disabled={disabled}
onClick={() => handleChange(resetValue!)}
className="
inline-flex h-7 items-center rounded-md px-2 text-xs font-medium
text-gray-700 ring-1 ring-gray-200 transition-colors
hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50
dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10
"
>
{resetLabel}
</button>
) : null}
</div>
) : null}
</div>
) : null}
<div className="relative min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 right-0">
<div
className="absolute left-0 right-0 rounded-full bg-gray-200 dark:bg-white/10"
style={{
height: `${trackH}px`,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
<div
className="absolute left-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{
width: `${percent}%`,
height: `${trackH}px`,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
</div>
<input
id={inputId}
type="range"
min={min}
max={max}
step={softStep ? 'any' : step}
value={clamped}
disabled={disabled}
aria-label={label ?? 'Slider'}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={clamped}
className={[
`
relative z-10 block w-full cursor-pointer appearance-none bg-transparent
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none touch-pan-x
[&::-webkit-slider-runnable-track]:bg-transparent
[&::-webkit-slider-runnable-track]:appearance-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-white/80
[&::-webkit-slider-thumb]:bg-indigo-500
[&::-webkit-slider-thumb]:shadow-sm
dark:[&::-webkit-slider-thumb]:border-gray-950
dark:[&::-webkit-slider-thumb]:bg-indigo-400
[&::-moz-range-track]:bg-transparent
[&::-moz-range-track]:border-0
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-white/80
[&::-moz-range-thumb]:bg-indigo-500
[&::-moz-range-thumb]:shadow-sm
dark:[&::-moz-range-thumb]:border-gray-950
dark:[&::-moz-range-thumb]:bg-indigo-400
`,
inputClassName,
].join(' ')}
style={
{
height: `${thumbSize}px`,
WebkitAppearance: 'none',
appearance: 'none',
['--slider-track-h' as any]: `${trackH}px`,
['--slider-thumb-size' as any]: `${thumbSize}px`,
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
} as React.CSSProperties
}
onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={emitCommit}
onMouseUp={emitCommit}
onTouchEnd={emitCommit}
onKeyUp={(e) => {
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Home' ||
e.key === 'End' ||
e.key === 'PageUp' ||
e.key === 'PageDown'
) {
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
}
}}
/>
<style>{`
input[type='range']::-webkit-slider-runnable-track {
height: var(--slider-track-h);
}
input[type='range']::-webkit-slider-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
margin-top: var(--slider-thumb-offset);
-webkit-appearance: none;
}
input[type='range']::-moz-range-track {
height: var(--slider-track-h);
}
input[type='range']::-moz-range-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
appearance: none;
}
`}</style>
</div>
{showMinMaxLabels ? (
<div className="mt-1 flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400">
<span>{resolvedMinLabel}</span>
<span>{resolvedMaxLabel}</span>
</div>
) : null}
</>
)}
</div>
)
}

View File

@ -15,6 +15,8 @@ type Props = {
maxWidthClassName?: string
gapPx?: number
className?: string
expandMode?: 'cover' | 'dropdown'
dropdownMaxHeightClassName?: string
}
export default function TagOverflowRow({
@ -27,6 +29,8 @@ export default function TagOverflowRow({
maxWidthClassName,
gapPx = 6,
className,
expandMode = 'cover',
dropdownMaxHeightClassName = 'max-h-[40vh]',
}: Props) {
const [open, setOpen] = React.useState(false)
@ -141,6 +145,25 @@ export default function TagOverflowRow({
})
}, [])
React.useLayoutEffect(() => {
if (!open || expandMode !== 'cover') return
recalcOverlay()
const host = hostRef.current
const parent = host?.offsetParent as HTMLElement | null
if (!parent) return
const ro = new ResizeObserver(() => {
recalcOverlay()
})
ro.observe(parent)
if (host) ro.observe(host)
return () => ro.disconnect()
}, [open, expandMode, recalcOverlay])
React.useLayoutEffect(() => {
if (!open) return
@ -181,11 +204,15 @@ export default function TagOverflowRow({
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
const restAll = sortedTags.length - visibleTags.length
const keepInlineRowVisible = expandMode === 'dropdown'
const showInlineRow = !open || keepInlineRowVisible
return (
<div ref={hostRef} className="relative h-full min-h-[1.75rem]">
{/* collapsed row (in footer) */}
{!open ? (
{showInlineRow ? (
<div
ref={rowWrapRef}
className={['min-h-[1.75rem] h-full flex items-start gap-1.5 overflow-visible', className].filter(Boolean).join(' ')}
@ -215,22 +242,20 @@ export default function TagOverflowRow({
<button
type="button"
className={[
// TagBadge-like sizing + shape
'inline-flex min-h-[1.375rem] shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-medium leading-none',
// TagBadge-like focus behavior
'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
// neutral colors (damit es sich als “Control” abhebt)
'bg-gray-100 text-gray-700 hover:bg-gray-200/70',
'dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20',
open
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200/70 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200/70 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpen(true)
setOpen((prev) => !prev)
}}
title="Alle Tags anzeigen"
title={open ? 'Tag-Liste schließen' : 'Alle Tags anzeigen'}
aria-haspopup="dialog"
aria-expanded={false}
aria-expanded={open}
>
+{restAll}
</button>
@ -240,56 +265,107 @@ export default function TagOverflowRow({
{/* overlay that covers the whole footer host */}
{open ? (
<div
style={overlayStyle}
className={[
'absolute z-30',
'border border-gray-200 bg-white shadow-sm',
'dark:border-white/10 dark:bg-gray-950',
'pointer-events-auto',
'flex flex-col',
].join(' ')}
onClick={stop}
onMouseDown={stop}
onPointerDown={stop}
role="dialog"
aria-label="Tags"
>
<button
type="button"
className="
absolute right-2 top-2 z-10
rounded-md bg-gray-100/80 px-2 py-1 text-xs font-semibold text-gray-700
hover:bg-gray-200/80
dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20
"
onClick={() => setOpen(false)}
aria-label="Schließen"
title="Schließen"
expandMode === 'cover' ? (
<div
style={overlayStyle}
className={[
'absolute z-30',
'border border-gray-200 bg-white shadow-sm',
'dark:border-white/10 dark:bg-gray-950',
'pointer-events-auto',
'flex flex-col',
].join(' ')}
onClick={stop}
onMouseDown={stop}
onPointerDown={stop}
role="dialog"
aria-label="Tags"
>
</button>
<button
type="button"
className="
absolute right-2 top-2 z-10
rounded-md bg-gray-100/80 px-2 py-1 text-xs font-semibold text-gray-700
hover:bg-gray-200/80
dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20
"
onClick={() => setOpen(false)}
aria-label="Schließen"
title="Schließen"
>
</button>
<div className="min-h-0 flex-1 overflow-auto px-3 pb-3 pr-2 pt-3">
<div className="mb-2 pr-10">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Alle Tags
<div className="min-h-0 flex-1 overflow-auto px-3 pb-3 pr-2 pt-3">
<div className="mb-2 pr-10">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Alle Tags
</div>
</div>
<div className="flex flex-wrap items-start gap-1.5 overflow-visible p-0.5">
{sortedTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))}
</div>
</div>
</div>
) : (
<div
className={[
'absolute left-0 right-0 top-full mt-2 z-40',
'overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
'dark:border-white/10 dark:bg-gray-950',
'pointer-events-auto',
].join(' ')}
onClick={stop}
onMouseDown={stop}
onPointerDown={stop}
role="dialog"
aria-label="Tags"
>
<button
type="button"
className="
absolute right-2 top-2 z-10
rounded-md bg-gray-100/80 px-2 py-1 text-xs font-semibold text-gray-700
hover:bg-gray-200/80
dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20
"
onClick={() => setOpen(false)}
aria-label="Schließen"
title="Schließen"
>
</button>
<div className="flex flex-wrap items-start gap-1.5 overflow-visible p-0.5">
{sortedTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))}
<div className={['overflow-auto px-3 pb-3 pr-2 pt-3', dropdownMaxHeightClassName].join(' ')}>
<div className="mb-2 pr-10">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Alle Tags
</div>
</div>
<div className="flex flex-wrap items-start gap-1.5 overflow-visible p-0.5">
{sortedTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))}
</div>
</div>
</div>
</div>
)
) : null}
{/* hidden measure area */}

View File

@ -1,4 +1,5 @@
@import "tailwindcss";
@import 'flag-icons/css/flag-icons.min.css';
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;