This commit is contained in:
Rother 2026-01-02 13:13:03 +01:00
parent ca237ef2da
commit ab3b55bcf8
19 changed files with 1970 additions and 619 deletions

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -227,15 +227,15 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
})
mux.HandleFunc("/api/models/meta", func(w http.ResponseWriter, r *http.Request) {
modelsWriteJSON(w, http.StatusOK, store.Meta())
})
modelsWriteJSON(w, http.StatusOK, store.Meta())
})
mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) {
host := strings.TrimSpace(r.URL.Query().Get("host"))
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
})
mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) {
host := strings.TrimSpace(r.URL.Query().Get("host"))
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
})
mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) {
modelsWriteJSON(w, http.StatusOK, store.List())
})
@ -264,6 +264,37 @@ mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request)
modelsWriteJSON(w, http.StatusOK, m)
})
// ✅ NEU: Ensure-Endpoint (für QuickActions aus FinishedDownloads)
// Erst versucht er ein bestehendes Model via modelKey zu finden, sonst legt er ein "manual" Model an.
mux.HandleFunc("/api/models/ensure", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req struct {
ModelKey string `json:"modelKey"`
}
if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
key := strings.TrimSpace(req.ModelKey)
if key == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"})
return
}
m, err := store.EnsureByModelKey(key)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, m)
})
mux.HandleFunc("/api/models/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})

View File

@ -79,6 +79,68 @@ type ModelStore struct {
mu sync.Mutex
}
// EnsureByModelKey:
// - liefert ein bestehendes Model (best match) wenn vorhanden
// - sonst legt es ein "manual" Model ohne URL an (Input=modelKey, IsURL=false)
// Dadurch funktionieren QuickActions (Like/Favorite) auch bei fertigen Videos,
// bei denen keine SourceURL mehr vorhanden ist.
func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, err
}
key := strings.TrimSpace(modelKey)
if key == "" {
return StoredModel{}, errors.New("modelKey fehlt")
}
// Erst schauen ob es das Model schon gibt (egal welcher Host)
var existingID string
err := s.db.QueryRow(`
SELECT id
FROM models
WHERE lower(model_key) = lower(?)
ORDER BY favorite DESC, updated_at DESC
LIMIT 1;
`, key).Scan(&existingID)
if err == nil && existingID != "" {
return s.getByID(existingID)
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, err
}
// Neu anlegen als "manual" (is_url = 0), input = modelKey (NOT NULL)
now := time.Now().UTC().Format(time.RFC3339Nano)
id := canonicalID("", key)
s.mu.Lock()
defer s.mu.Unlock()
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
model_key=excluded.model_key,
updated_at=excluded.updated_at;
`,
id, key, int64(0), "", "", key,
"", "",
int64(0), int64(0), int64(0), int64(0), nil,
now, now,
)
if err != nil {
return StoredModel{}, err
}
return s.getByID(id)
}
// Backwards compatible:
// - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db"
// und die JSON-Datei wird als Legacy-Quelle für die 1x Migration genutzt.
@ -379,7 +441,7 @@ func (s *ModelStore) List() []StoredModel {
rows, err := s.db.Query(`
SELECT
id,input,is_url,host,path,model_key,
tags,last_stream,
tags, COALESCE(last_stream,''),
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models
@ -524,27 +586,28 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
defer s.mu.Unlock()
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
input=excluded.input,
is_url=excluded.is_url,
host=excluded.host,
path=excluded.path,
model_key=excluded.model_key,
updated_at=excluded.updated_at;
`,
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
input=excluded.input,
is_url=excluded.is_url,
host=excluded.host,
path=excluded.path,
model_key=excluded.model_key,
updated_at=excluded.updated_at;
`,
id,
u.String(),
int64(1),
host,
p.Path,
modelKey,
int64(0), int64(0), int64(0), int64(0), nil, // Flags nur bei neuem Insert (Update fasst sie nicht an)
"", "", // ✅ tags, last_stream
int64(0), int64(0), int64(0), int64(0), nil,
now,
now,
)
@ -592,7 +655,15 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
if patch.Keep != nil {
keep = boolToInt(*patch.Keep)
}
// ✅ Business-Rule (robust, auch wenn Frontend es mal nicht mitsendet):
// - Liked=true => Favorite=false
// - Favorite=true => Liked wird gelöscht (NULL)
if patch.Liked != nil && *patch.Liked {
favorite = int64(0)
}
if patch.Favorite != nil && *patch.Favorite {
liked = sql.NullInt64{Valid: false}
}
if patch.ClearLiked {
liked = sql.NullInt64{Valid: false}
} else if patch.Liked != nil {
@ -721,7 +792,7 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
err := s.db.QueryRow(`
SELECT
input,is_url,host,path,model_key,
tags, lastStream,
tags, COALESCE(last_stream,''),
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models

View File

@ -7,9 +7,10 @@ import Tabs, { type TabItem } from './components/ui/Tabs'
import RecorderSettings from './components/ui/RecorderSettings'
import FinishedDownloads from './components/ui/FinishedDownloads'
import Player from './components/ui/Player'
import type { RecordJob, ParsedModel } from './types'
import type { RecordJob } from './types'
import RunningDownloads from './components/ui/RunningDownloads'
import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar'
const COOKIE_STORAGE_KEY = 'record_cookies'
@ -110,8 +111,6 @@ export default function App() {
const DONE_PAGE_SIZE = 8
const [sourceUrl, setSourceUrl] = useState('')
const [, setParsed] = useState<ParsedModel | null>(null)
const [, setParseError] = useState<string | null>(null)
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [donePage, setDonePage] = useState(1)
@ -120,7 +119,7 @@ export default function App() {
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
const [, setError] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [cookieModalOpen, setCookieModalOpen] = useState(false)
const [cookies, setCookies] = useState<Record<string, string>>({})
@ -129,6 +128,9 @@ export default function App() {
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false)
const [assetNonce, setAssetNonce] = useState(0)
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
@ -317,55 +319,73 @@ export default function App() {
}, [doneCount, donePage])
useEffect(() => {
if (sourceUrl.trim() === '') {
setParsed(null)
setParseError(null)
return
let cancelled = false
let es: EventSource | null = null
let fallbackTimer: number | null = null
let inFlight = false
const applyList = (list: any) => {
const arr = Array.isArray(list) ? (list as RecordJob[]) : []
if (!cancelled) {
setJobs(arr)
jobsRef.current = arr
}
}
const t = setTimeout(async () => {
try {
const p = await apiJSON<ParsedModel>('/api/models/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: sourceUrl.trim() }),
})
setParsed(p)
setParseError(null)
} catch (e: any) {
setParsed(null)
setParseError(e?.message ?? String(e))
}
}, 300)
return () => clearTimeout(t)
}, [sourceUrl])
useEffect(() => {
let cancelled = false
const loadJobs = async () => {
const loadOnce = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<RecordJob[]>('/api/record/list')
if (!cancelled) {
setJobs(Array.isArray(list) ? list : [])
}
applyList(list)
} catch {
if (!cancelled) {
// optional: bei Fehler nicht alles leeren, sondern Zustand behalten
// setJobs([])
}
// ignore
} finally {
inFlight = false
}
}
// direkt einmal laden
loadJobs()
// dann jede Sekunde
const t = setInterval(loadJobs, 1000)
const startFallbackPolling = () => {
if (fallbackTimer) return
fallbackTimer = window.setInterval(loadOnce, document.hidden ? 15000 : 5000)
}
// initial einmal laden
void loadOnce()
// SSE verbinden
es = new EventSource('/api/record/stream')
const onJobs = (ev: MessageEvent) => {
try {
applyList(JSON.parse(ev.data))
} catch {
// ignore
}
}
es.addEventListener('jobs', onJobs as any)
es.onerror = () => {
// wenn SSE nicht geht (Proxy/Nginx/Browser): fallback polling
startFallbackPolling()
}
const onVis = () => {
// wenn wieder sichtbar/fokus: einmal nachziehen
if (!document.hidden) void loadOnce()
}
document.addEventListener('visibilitychange', onVis)
window.addEventListener('focus', onVis)
return () => {
cancelled = true
clearInterval(t)
if (fallbackTimer) window.clearInterval(fallbackTimer)
document.removeEventListener('visibilitychange', onVis)
window.removeEventListener('focus', onVis)
es?.removeEventListener('jobs', onJobs as any)
es?.close()
es = null
}
}, [])
@ -414,6 +434,40 @@ export default function App() {
}
}, [selectedTab, donePage])
// ✅ Sofort-Refresh für Finished-Liste + Count (z.B. nach Delete/Keep),
// damit die Seite direkt wieder mit PAGE_SIZE Items gefüllt wird und
// die Page-Nummern/Counts stimmen.
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
try {
// 1) Meta (Count)
const meta = await apiJSON<{ count?: number }>(
'/api/record/done/meta',
{ cache: 'no-store' as any }
)
const countRaw = typeof meta?.count === 'number' ? meta.count : 0
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setDoneCount(count)
// 2) Page clampen
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target)
// 3) Liste für (ggf. geclampte) Seite laden
const list = await apiJSON<RecordJob[]>(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}`,
{ cache: 'no-store' as any }
)
setDoneJobs(Array.isArray(list) ? list : [])
} catch {
// ignore
}
},
[donePage]
)
function isChaturbate(url: string): boolean {
try {
@ -491,11 +545,29 @@ export default function App() {
}
}, []) // arbeitet über refs, daher keine deps nötig
async function resolveModelForJob(job: RecordJob): Promise<StoredModel | null> {
async function resolveModelForJob(
job: RecordJob,
opts?: { ensure?: boolean }
): Promise<StoredModel | null> {
const wantEnsure = Boolean(opts?.ensure)
const upsertCache = (m: StoredModel) => {
const now = Date.now()
const cur = modelsCacheRef.current
if (!cur) {
modelsCacheRef.current = { ts: now, list: [m] }
return
}
cur.ts = now
const idx = cur.list.findIndex((x) => x.id === m.id)
if (idx >= 0) cur.list[idx] = m
else cur.list.unshift(m)
}
const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
const url = extractFirstHttpUrl(urlFromJob)
// 1) Wenn URL da ist: parse + upsert => liefert ID + flags
// 1) Wenn URL da ist: parse + upsert
if (url) {
const parsed = await apiJSON<any>('/api/models/parse', {
method: 'POST',
@ -509,13 +581,15 @@ export default function App() {
body: JSON.stringify(parsed),
})
upsertCache(saved)
return saved
}
// 2) Fallback: aus Dateiname modelKey ableiten und im Store suchen
// 2) Fallback: modelKey aus Dateiname
const key = modelKeyFromFilename(job.output || '')
if (!key) return null
// Cache laden/auffrischen (nur fürs schnelle Match)
const now = Date.now()
const cached = modelsCacheRef.current
if (!cached || now - cached.ts > 30_000) {
@ -526,12 +600,25 @@ export default function App() {
const list = modelsCacheRef.current?.list ?? []
const needle = key.toLowerCase()
// wenn mehrere: nimm Favorite zuerst, dann irgendeins
const hits = list.filter(m => (m.modelKey || '').toLowerCase() === needle)
if (hits.length === 0) return null
return hits.sort((a, b) => Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)))[0]
const hits = list.filter((m) => (m.modelKey || '').toLowerCase() === needle)
if (hits.length > 0) {
return hits.sort((a, b) => Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)))[0]
}
// ✅ Wenn QuickAction: Model bei Bedarf anlegen
if (!wantEnsure) return null
const ensured = await apiJSON<StoredModel>('/api/models/ensure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modelKey: key }),
})
upsertCache(ensured)
return ensured
}
useEffect(() => {
let cancelled = false
if (!playerJob) {
@ -593,6 +680,44 @@ export default function App() {
}
}, [])
const handleKeepJob = useCallback(async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
// 1) gleiche Animation wie Delete (fade-out + Nachrücken)
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', {
detail: { file, phase: 'start' as const },
})
)
try {
await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', {
detail: { file, phase: 'success' as const },
})
)
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
// Wenn Finished-Tab gerade NICHT offen ist, Counts/Liste trotzdem direkt updaten:
if (selectedTab !== 'finished') void refreshDoneNow()
} catch (e) {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', {
detail: { file, phase: 'error' as const },
})
)
throw e
}
}, [selectedTab, refreshDoneNow])
const handleToggleHot = useCallback(async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
@ -623,7 +748,7 @@ export default function App() {
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) m = await resolveModelForJob(job, { ensure: true })
if (!m) return
const next = !Boolean(m.favorite)
@ -646,7 +771,7 @@ export default function App() {
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job)
if (!m) m = await resolveModelForJob(job, { ensure: true })
if (!m) return
const curLiked = m.liked === true
@ -732,10 +857,10 @@ export default function App() {
async function stopJob(id: string) {
try {
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, {
method: 'POST',
})
} catch {}
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
} catch (e: any) {
setError(e?.message ?? String(e))
}
}
return (
@ -767,12 +892,36 @@ export default function App() {
className="mt-1 block w-full rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
/>
{error ? (
<div className="mt-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 break-words">{error}</div>
<button
type="button"
className="shrink-0 rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
onClick={() => setError(null)}
aria-label="Fehlermeldung schließen"
title="Schließen"
>
</button>
</div>
</div>
) : null}
{isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) && (
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
Für Chaturbate werden die Cookies <code>cf_clearance</code> und{' '}
<code>sessionId</code> benötigt.
</div>
)}
{busy ? (
<div className="mt-3">
<ProgressBar label="Starte Download…" indeterminate />
</div>
) : null}
</Card>
<Tabs
@ -807,13 +956,14 @@ export default function App() {
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
blurPreviews={Boolean(recSettings.blurPreviews)}
assetNonce={assetNonce}
onRefreshDone={refreshDoneNow}
/>
)}
{selectedTab === 'models' && <ModelsTab />}
{selectedTab === 'settings' && <RecorderSettings />}
{selectedTab === 'settings' && <RecorderSettings onAssetsGenerated={bumpAssets} />}
<CookieModal
open={cookieModalOpen}
@ -845,6 +995,7 @@ export default function App() {
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true}
onKeep={handleKeepJob}
onDelete={handleDeleteJob}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}

View File

@ -6,8 +6,6 @@ import Table, { type Column, type SortState } from './Table'
import Card from './Card'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
import { buildDownloadContextMenu } from './DownloadContextMenu'
import Button from './Button'
import ButtonGroup from './ButtonGroup'
import {
@ -16,7 +14,6 @@ import {
Squares2X2Icon,
TrashIcon,
FireIcon,
EllipsisVerticalIcon,
BookmarkSquareIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
@ -31,6 +28,7 @@ import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
import FinishedDownloadsTableView from './FinishedDownloadsTableView'
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
import Pagination from './Pagination'
import { applyInlineVideoPolicy } from './videoPolicy'
type Props = {
jobs: RecordJob[]
@ -45,6 +43,8 @@ type Props = {
page: number
pageSize: number
onPageChange: (page: number) => void
onRefreshDone?: (preferPage?: number) => void | Promise<void>
assetNonce?: number
}
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
@ -157,9 +157,10 @@ export default function FinishedDownloads({
doneTotal,
page,
pageSize,
onPageChange
onPageChange,
onRefreshDone,
assetNonce,
}: Props) {
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
@ -168,6 +169,21 @@ export default function FinishedDownloads({
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
// 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
// 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken"
const [overrideDoneJobs, setOverrideDoneJobs] = React.useState<RecordJob[] | null>(null)
const [overrideDoneTotal, setOverrideDoneTotal] = React.useState<number | null>(null)
const [refillTick, setRefillTick] = React.useState(0)
const refillTimerRef = React.useRef<number | null>(null)
const queueRefill = useCallback(() => {
if (refillTimerRef.current) window.clearTimeout(refillTimerRef.current)
// kurz debouncen, damit bei mehreren Aktionen nicht zig Fetches laufen
refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80)
}, [])
const [sort, setSort] = React.useState<SortState>(null)
type ViewMode = 'table' | 'cards' | 'gallery'
@ -208,6 +224,56 @@ export default function FinishedDownloads({
// ⭐ Models-Flags (Fav/Like) aus Backend-Store
const [modelsByKey, setModelsByKey] = React.useState<Record<string, StoredModelFlags>>({})
// ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt
useEffect(() => {
if (refillTick === 0) return
let alive = true
;(async () => {
try {
const [metaRes, listRes] = await Promise.all([
fetch('/api/record/done/meta', { cache: 'no-store' as any }),
fetch(`/api/record/done?page=${page}&pageSize=${pageSize}`, { cache: 'no-store' as any }),
])
if (!alive) return
if (metaRes.ok) {
const meta = await metaRes.json()
const count = Number(meta?.count ?? 0)
if (Number.isFinite(count) && count >= 0) {
setOverrideDoneTotal(count)
const totalPages = Math.max(1, Math.ceil(count / pageSize))
if (page > totalPages) {
// Seite ist nach Delete/Keep "weg" -> auf letzte gültige Seite springen
onPageChange(totalPages)
setOverrideDoneJobs(null)
return
}
}
}
if (listRes.ok) {
const list = await listRes.json()
setOverrideDoneJobs(Array.isArray(list) ? list : [])
}
} catch {
// optional: console.debug(...)
}
})()
return () => {
alive = false
}
}, [refillTick, page, pageSize, onPageChange])
useEffect(() => {
// Wenn Parent neu geladen hat, brauchen wir Overrides nicht mehr
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}, [doneJobs, doneTotal])
const refreshModelsByKey = useCallback(async () => {
try {
const res = await fetch('/api/models/list', { cache: 'no-store' as any })
@ -275,9 +341,8 @@ export default function FinishedDownloads({
const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) return false
v.muted = true
v.playsInline = true
v.setAttribute('playsinline', 'true')
// ✅ zentral
applyInlineVideoPolicy(v, { muted: true })
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
@ -293,16 +358,6 @@ export default function FinishedDownloads({
onOpenPlayer(job)
}, [onOpenPlayer])
const openCtx = (job: RecordJob, e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setCtx({ x: e.clientX, y: e.clientY, job })
}
const openCtxAt = (job: RecordJob, x: number, y: number) => {
setCtx({ x, y, job })
}
const markDeleting = useCallback((key: string, value: boolean) => {
setDeletingKeys((prev) => {
const next = new Set(prev)
@ -343,16 +398,25 @@ export default function FinishedDownloads({
})
}, [])
const animateRemove = useCallback((key: string) => {
// 1) rot + fade-out starten
markRemoving(key, true)
const animateRemove = useCallback(
(key: string) => {
// 1) rot + fade-out starten
markRemoving(key, true)
// 2) nach der Animation wirklich ausblenden
window.setTimeout(() => {
markDeleted(key)
markRemoving(key, false)
}, 320)
}, [markDeleted, markRemoving])
// 2) nach der Animation wirklich ausblenden + Seite auffüllen
window.setTimeout(() => {
markDeleted(key)
markRemoving(key, false)
// ✅ wichtig: Seite sofort neu laden -> Item rückt nach
queueRefill()
// optional: Parent sync (kann bleiben, muss aber nicht)
void onRefreshDone?.(page)
}, 320)
},
[markDeleted, markRemoving, queueRefill, onRefreshDone, page]
)
const releasePlayingFile = useCallback(
async (file: string, opts?: { close?: boolean }) => {
@ -384,8 +448,8 @@ export default function FinishedDownloads({
if (onDeleteJob) {
await onDeleteJob(job)
// ✅ optional: sofort aus der Liste animieren (fühlt sich besser an)
animateRemove(key)
// ✅ nach erfolgreichem Delete die Page nachziehen
queueRefill()
return true
}
@ -406,7 +470,7 @@ export default function FinishedDownloads({
markDeleting(key, false)
}
},
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove]
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, queueRefill]
)
const keepVideo = useCallback(
@ -442,41 +506,53 @@ export default function FinishedDownloads({
[keepingKeys, deletingKeys, markKeeping, releasePlayingFile, animateRemove]
)
const items = React.useMemo<ContextMenuItem[]>(() => {
if (!ctx) return []
const j = ctx.job
const model = modelNameFromOutput(j.output)
const toggleHotVideo = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) {
window.alert('Kein Dateiname gefunden kann nicht HOT togglen.')
return
}
return buildDownloadContextMenu({
job: j,
modelName: model,
state: {
watching: false,
liked: null,
favorite: false,
hot: false,
keep: false,
},
actions: {
onPlay: onOpenPlayer,
try {
await releasePlayingFile(file, { close: true })
onToggleWatch: (job) => console.log('toggle watch', job.id),
onSetLike: (job, liked) => console.log('set like', job.id, liked),
onToggleFavorite: (job) => console.log('toggle favorite', job.id),
onMoreFromModel: (modelName) => console.log('more from', modelName),
// ✅ Wenn du extern einen Handler hast, kannst du den nutzen
// (Wenn du KEINEN hast: läuft der Fallback unten)
if (onToggleHot) {
await onToggleHot(job)
return
}
onRevealInExplorer: (job) => console.log('reveal in explorer', job.output),
onAddToDownloadList: (job) => console.log('add to download list', job.id),
onToggleHot: (job) => console.log('toggle hot', job.id),
// Fallback: Backend direkt
const res = await fetch(`/api/record/toggle-hot?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
onToggleKeep: (job) => console.log('toggle keep', job.id),
onDelete: (job) => {
setCtx(null)
void deleteVideo(job)
},
},
})
}, [ctx, deleteVideo, onOpenPlayer])
const data = (await res.json().catch(() => null)) as any
const oldFile = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file
const newFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : ''
if (newFile) {
// Optimistisch umbenennen (nicht aufs nächste Polling warten)
setRenamedFiles((prev) => ({ ...prev, [oldFile]: newFile }))
// Dauer-Key mitziehen (optional)
setDurations((prev) => {
const v = prev[oldFile]
if (typeof v !== 'number') return prev
const { [oldFile]: _omit, ...rest } = prev
return { ...rest, [newFile]: v }
})
}
} catch (e: any) {
window.alert(`HOT umbenennen fehlgeschlagen: ${String(e?.message || e)}`)
}
},
[baseName, releasePlayingFile, onToggleHot]
)
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
const start = Date.parse(String(job.startedAt || ''))
@ -486,17 +562,37 @@ export default function FinishedDownloads({
return (typeof sec === 'number' && sec > 0) ? sec : Number.POSITIVE_INFINITY
}, [])
const applyRenamedOutput = useCallback(
(job: RecordJob): RecordJob => {
const out = norm(job.output || '')
const file = baseName(out)
const override = renamedFiles[file]
if (!override) return job
const idx = out.lastIndexOf('/')
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
return { ...job, output: dir + override }
},
[renamedFiles, baseName]
)
const doneJobsPage = overrideDoneJobs ?? doneJobs
const doneTotalPage = overrideDoneTotal ?? doneTotal
const rows = useMemo(() => {
const map = new Map<string, RecordJob>()
// Basis: Files aus dem Done-Ordner
for (const j of doneJobs) map.set(keyFor(j), j)
for (const j of doneJobsPage) {
const jj = applyRenamedOutput(j)
map.set(keyFor(jj), jj)
}
// Jobs aus /list drübermergen (z.B. frisch fertiggewordene)
// Jobs aus /list drübermergen
for (const j of jobs) {
const k = keyFor(j)
if (map.has(k)) map.set(k, { ...map.get(k)!, ...j })
const jj = applyRenamedOutput(j)
const k = keyFor(jj)
if (map.has(k)) map.set(k, { ...map.get(k)!, ...jj })
}
const list = Array.from(map.values()).filter((j) => {
@ -506,7 +602,7 @@ export default function FinishedDownloads({
list.sort((a, b) => norm(b.endedAt || '').localeCompare(norm(a.endedAt || '')))
return list
}, [jobs, doneJobs, deletedKeys])
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])
const endedAtMs = (j: RecordJob) => (j.endedAt ? new Date(j.endedAt).getTime() : 0)
@ -592,16 +688,11 @@ export default function FinishedDownloads({
// ✅ wenn Cards-View: Swipe schon beim Start raus (ohne Aktion, weil App die API schon macht)
if (view === 'cards') {
swipeRefs.current.get(key)?.swipeLeft({ runAction: false })
}
} else if (detail.phase === 'success') {
markDeleting(key, false)
if (view === 'cards') {
// ✅ nach Swipe-Animation wirklich aus der Liste entfernen
window.setTimeout(() => markDeleted(key), 320)
window.setTimeout(() => {
markDeleted(key)
void onRefreshDone?.(page) // ✅ HIER dazu
}, 320)
} else {
// table/gallery: wie bisher ausblenden
animateRemove(key)
}
} else if (detail.phase === 'error') {
@ -611,12 +702,16 @@ export default function FinishedDownloads({
if (view === 'cards') {
swipeRefs.current.get(key)?.reset()
}
} else if (detail.phase === 'success') {
markDeleting(key, false)
queueRefill()
void onRefreshDone?.(page)
}
}
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, markDeleted, view])
}, [animateRemove, markDeleting, markDeleted, view, onRefreshDone, page, queueRefill])
const viewRows = view === 'table' ? rows : sortedNonTableRows
@ -705,7 +800,6 @@ export default function FinishedDownloads({
{
key: 'preview',
header: 'Vorschau',
srOnlyHeader: true,
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
@ -714,11 +808,6 @@ export default function FinishedDownloads({
className="py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
openCtx(j, e)
}}
>
<FinishedVideoPreview
job={j}
@ -731,6 +820,7 @@ export default function FinishedDownloads({
animated={true}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
/>
</div>
)
@ -755,19 +845,17 @@ export default function FinishedDownloads({
return (
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="truncate font-medium text-gray-900 dark:text-white" title={model}>
{model}
</div>
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate" title={file}>
{file || '—'}
</span>
{isHot ? (
<span className="shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
<div className="truncate text-xs text-gray-500 dark:text-gray-400" title={file}>
{file || '—'}
</div>
</div>
)
},
@ -848,15 +936,88 @@ export default function FinishedDownloads({
srOnlyHeader: true,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k) || keepingKeys.has(k)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const iconBtn =
'inline-flex items-center justify-center rounded-md p-1.5 ' +
'hover:bg-gray-100/70 dark:hover:bg-white/5 ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500'
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500 ' +
'disabled:opacity-50 disabled:cursor-not-allowed'
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const modelKey = lower(modelNameFromOutput(j.output))
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
return (
<div className="flex items-center justify-end gap-1">
{/* Favorite */}
<button
type="button"
className={iconBtn}
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
disabled={busy || !onToggleFavorite}
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
await onToggleFavorite?.(j)
await refreshModelsByKey()
}}
>
{isFav ? (
<StarSolidIcon className="size-5 text-amber-600 dark:text-amber-300" />
) : (
<StarOutlineIcon className="size-5 text-gray-500 dark:text-gray-300" />
)}
</button>
{/* Like */}
<button
type="button"
className={iconBtn}
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
disabled={busy || !onToggleLike}
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
await onToggleLike?.(j)
await refreshModelsByKey()
}}
>
{isLiked ? (
<HeartSolidIcon className="size-5 text-rose-600 dark:text-rose-300" />
) : (
<HeartOutlineIcon className="size-5 text-gray-500 dark:text-gray-300" />
)}
</button>
{/* HOT */}
<button
type="button"
className={iconBtn}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void toggleHotVideo(j)
}}
>
<FireIcon
className={[
'size-5',
isHot ? 'text-amber-600 dark:text-amber-300' : 'text-gray-500 dark:text-gray-300',
].join(' ')}
/>
</button>
{/* Keep */}
<button
type="button"
@ -864,6 +1025,7 @@ export default function FinishedDownloads({
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
@ -880,6 +1042,7 @@ export default function FinishedDownloads({
title="Löschen"
aria-label="Löschen"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
@ -888,22 +1051,6 @@ export default function FinishedDownloads({
>
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
</button>
{/* More */}
<button
type="button"
className={iconBtn}
title="Mehr Aktionen"
aria-label="Mehr Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
<EllipsisVerticalIcon className="size-5 text-gray-500 dark:text-gray-300" />
</button>
</div>
)
},
@ -920,7 +1067,7 @@ export default function FinishedDownloads({
}
}, [isSmall])
if (rows.length === 0) {
if (rows.length === 0 && doneTotalPage === 0) {
return (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">
@ -1027,13 +1174,12 @@ export default function FinishedDownloads({
handleDuration={handleDuration}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
openCtx={openCtx}
openCtxAt={openCtxAt}
releasePlayingFile={releasePlayingFile}
modelsByKey={modelsByKey}
onToggleHot={onToggleHot}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
assetNonce={assetNonce}
/>
)}
@ -1045,7 +1191,6 @@ export default function FinishedDownloads({
sort={sort}
onSortChange={setSort}
onRowClick={onOpenPlayer}
onRowContextMenu={(job, e) => openCtx(job, e)}
rowClassName={(j) => {
const k = keyFor(j)
return [
@ -1079,25 +1224,21 @@ export default function FinishedDownloads({
deletedKeys={deletedKeys}
registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer}
openCtx={openCtx}
openCtxAt={openCtxAt}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={toggleHotVideo}
lower={lower}
modelsByKey={modelsByKey}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
assetNonce={assetNonce}
/>
)}
<ContextMenu
open={!!ctx}
x={ctx?.x ?? 0}
y={ctx?.y ?? 0}
items={items}
onClose={() => setCtx(null)}
/>
<Pagination
page={page}
pageSize={pageSize}
totalItems={doneTotal}
totalItems={doneTotalPage}
onPageChange={(p) => {
// 1) Inline-Playback + aktiven Teaser sofort stoppen
flushSync(() => {

View File

@ -9,7 +9,6 @@ import { flushSync } from 'react-dom'
import {
TrashIcon,
FireIcon,
EllipsisVerticalIcon,
BookmarkSquareIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
@ -60,9 +59,6 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
openCtx: (job: RecordJob, e: React.MouseEvent) => void
openCtxAt: (job: RecordJob, x: number, y: number) => void
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null }>
@ -108,9 +104,6 @@ export default function FinishedDownloadsCardsView({
deleteVideo,
keepVideo,
openCtx,
openCtxAt,
releasePlayingFile,
modelsByKey,
@ -130,6 +123,20 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ')
const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const statusCls =
j.status === 'failed'
? 'bg-red-500/35'
: j.status === 'finished'
? 'bg-emerald-500/35'
: j.status === 'stopped'
? 'bg-amber-500/35'
: 'bg-black/40'
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
@ -154,7 +161,6 @@ export default function FinishedDownloadsCardsView({
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
onContextMenu={(e) => openCtx(j, e)}
>
<Card noBodyPadding className="overflow-hidden">
{/* Preview */}
@ -171,7 +177,7 @@ export default function FinishedDownloadsCardsView({
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k]}
onDuration={handleDuration}
className="w-full h-full"
@ -195,25 +201,23 @@ export default function FinishedDownloadsCardsView({
].join(' ')}
/>
{/* Overlay bottom */}
{/* Bottom overlay meta (Status links, Dauer+Größe rechts) */}
<div
className={[
'pointer-events-none absolute inset-x-3 bottom-3 flex items-end justify-between gap-3',
'pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white',
'transition-opacity duration-150',
inlineActive ? 'opacity-0' : 'opacity-100',
].join(' ')}
>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{model}</div>
<div className="truncate text-[11px] text-white/80">{stripHotPrefix(fileRaw) || '—'}</div>
</div>
<div className="flex items-center justify-between gap-2 text-[11px] opacity-90">
<span className={cn('rounded px-1.5 py-0.5 font-semibold', statusCls)}>
{j.status}
</span>
<div className="shrink-0 flex items-center gap-2">
{fileRaw.startsWith('HOT ') ? (
<span className="rounded-md bg-amber-500/25 px-2 py-1 text-[11px] font-semibold text-white">
HOT
</span>
) : null}
<div className="flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
</div>
</div>
</div>
@ -240,71 +244,8 @@ export default function FinishedDownloadsCardsView({
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
const isHot = fileRaw.startsWith('HOT ')
const modelKey = modelNameFromOutput(j.output)
const flags = modelsByKey[lower(modelKey)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
return (
<>
{!isSmall && (
<>
{/* Keep */}
<button
type="button"
className={iconBtn}
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-300" />
</button>
{/* Delete */}
<button
type="button"
className={iconBtn}
title="Löschen"
aria-label="Löschen"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-300" />
</button>
</>
)}
{/* HOT */}
<button
type="button"
className={iconBtn}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
disabled={busy || !onToggleHot}
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
// wichtig gegen File-Lock beim Rename:
await releasePlayingFile(fileRaw, { close: true })
await new Promise((r) => setTimeout(r, 150))
await onToggleHot?.(j)
}}
>
<FireIcon className={cn('size-5', isHot ? 'text-amber-300' : 'text-white/90')} />
</button>
{/* Favorite */}
<button
@ -345,44 +286,92 @@ export default function FinishedDownloadsCardsView({
return <Icon className={cn('size-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
})()}
</button>
{/* Menu */}
{/* HOT */}
<button
type="button"
className={iconBtn}
title="Aktionen"
aria-label="Aktionen"
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
disabled={busy || !onToggleHot}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
// wichtig gegen File-Lock beim Rename:
await releasePlayingFile(fileRaw, { close: true })
await new Promise((r) => setTimeout(r, 150))
await onToggleHot?.(j)
}}
>
<EllipsisVerticalIcon className="size-5" />
<FireIcon className={cn('size-5', isHot ? 'text-amber-300' : 'text-white/90')} />
</button>
{!isSmall && (
<>
{/* Keep */}
<button
type="button"
className={iconBtn}
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-300" />
</button>
{/* Delete */}
<button
type="button"
className={iconBtn}
title="Löschen"
aria-label="Löschen"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-300" />
</button>
</>
)}
</>
)
})()}
</div>
</div>
{/* Meta */}
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
<div className="min-w-0 truncate">
Dauer: <span className="font-medium">{dur}</span>
<span className="mx-2 opacity-60"></span>
Größe: <span className="font-medium">{size}</span>
{/* Footer / Meta */}
<div className="px-4 py-3">{/* Model + Datei im Footer */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{j.output ? (
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400" title={j.output}>
{j.output}
</div>
) : null}
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{stripHotPrefix(fileRaw) || '—'}</span>
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
</div>
</Card>
</div>

View File

@ -3,7 +3,17 @@
import * as React from 'react'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
import {
TrashIcon,
BookmarkSquareIcon,
FireIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
} from '@heroicons/react/24/outline'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
} from '@heroicons/react/24/solid'
type Props = {
rows: RecordJob[]
@ -27,10 +37,16 @@ type Props = {
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
onOpenPlayer: (job: RecordJob) => void
openCtx: (job: RecordJob, e: React.MouseEvent) => void
openCtxAt: (job: RecordJob, x: number, y: number) => void
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
onToggleHot: (job: RecordJob) => void | Promise<void>
lower: (s: string) => string
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null }>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
}
export default function FinishedDownloadsGalleryView({
@ -55,19 +71,35 @@ export default function FinishedDownloadsGalleryView({
registerTeaserHost,
onOpenPlayer,
openCtx,
openCtxAt,
deleteVideo,
keepVideo,
onToggleHot,
lower,
modelsByKey,
onToggleFavorite,
onToggleLike,
}: Props) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((j) => {
const k = keyFor(j)
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const file = baseName(j.output || '')
const isHot = file.startsWith('HOT ')
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const statusCls =
j.status === 'failed'
? 'bg-red-500/35'
: j.status === 'finished'
? 'bg-emerald-500/35'
: j.status === 'stopped'
? 'bg-amber-500/35'
: 'bg-black/40'
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k)
@ -94,21 +126,15 @@ export default function FinishedDownloadsGalleryView({
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
onContextMenu={(e) => openCtx(j, e)}
>
{/* Thumb */}
<div
className="group relative aspect-video bg-black/5 dark:bg-white/5"
ref={registerTeaserHost(k)}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
openCtx(j, e)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k]}
onDuration={handleDuration}
variant="fill"
@ -131,7 +157,7 @@ export default function FinishedDownloadsGalleryView({
"
/>
{/* Bottom text */}
{/* Bottom overlay meta (Status links, Dauer+Größe rechts) */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
@ -139,85 +165,150 @@ export default function FinishedDownloadsGalleryView({
group-hover:opacity-0 group-focus-within:opacity-0
"
>
<div className="truncate text-xs font-semibold">{model}</div>
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] opacity-90">
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
<div className="flex items-center justify-between gap-2 text-[11px] opacity-90">
<span className={`rounded px-1.5 py-0.5 font-semibold ${statusCls}`}>
{j.status}
</span>
<div className="shrink-0 flex items-center gap-1.5">
<div className="flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
</div>
</div>
</div>
{/* Quick keep */}
<button
type="button"
{/* Quick actions (top-right, wie Cards) */}
<div
className={[
'absolute right-12 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
'bg-black/55 text-white hover:bg-black/70',
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
'absolute right-2 top-2 z-10 flex items-center gap-1.5',
'opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 transition-opacity',
].join(' ')}
aria-label="Behalten"
title="Behalten (nach keep verschieben)"
disabled={busy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
</button>
{(() => {
const iconBtn =
'pointer-events-auto inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
'disabled:opacity-50 disabled:cursor-not-allowed'
{/* Quick delete */}
<button
type="button"
className={[
'absolute right-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
'bg-black/55 text-white hover:bg-black/70',
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
].join(' ')}
aria-label="Video löschen"
title="Video löschen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
</button>
return (
<>
{/* Favorite */}
{onToggleFavorite ? (
<button
type="button"
className={iconBtn}
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void onToggleFavorite(j)
}}
>
{isFav ? (
<StarSolidIcon className="size-5 text-amber-300" />
) : (
<StarOutlineIcon className="size-5 text-white/90" />
)}
</button>
) : null}
{/* More / Context */}
<button
type="button"
className={[
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
'bg-black/55 text-white hover:bg-black/70',
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
].join(' ')}
aria-label="Aktionen"
title="Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
</button>
{/* Like */}
{onToggleLike ? (
<button
type="button"
className={iconBtn}
aria-label={isLiked ? 'Like entfernen' : 'Like setzen'}
title={isLiked ? 'Like entfernen' : 'Like setzen'}
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void onToggleLike(j)
}}
>
{isLiked ? (
<HeartSolidIcon className="size-5 text-rose-300" />
) : (
<HeartOutlineIcon className="size-5 text-white/90" />
)}
</button>
) : null}
<button
type="button"
className={iconBtn}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void onToggleHot(j)
}}
>
<FireIcon className={['size-5', isHot ? 'text-amber-300' : 'text-white/90'].join(' ')} />
</button>
<button
type="button"
className={iconBtn}
aria-label="Behalten"
title="Behalten (nach keep verschieben)"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-300" />
</button>
<button
type="button"
className={iconBtn}
aria-label="Video löschen"
title="Video löschen"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-300" />
</button>
</>
)
})()}
</div>
</div>
{/* status line */}
<div className="px-3 py-2">
<div className="flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-300">
<span className="truncate">
Status: <span className="font-medium">{j.status}</span>
</span>
{baseName(j.output || '').startsWith('HOT ') ? (
<span className="shrink-0 rounded bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
{/* Footer / Meta (wie CardView) */}
<div className="px-4 py-3">
{/* Model + Datei im Footer */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}

View File

@ -11,7 +11,6 @@ type Props = {
sort: SortState
onSortChange: (s: SortState) => void
onRowClick: (job: RecordJob) => void
onRowContextMenu: (job: RecordJob, e: React.MouseEvent) => void
rowClassName?: (job: RecordJob) => string
}
@ -22,7 +21,6 @@ export default function FinishedDownloadsTableView({
sort,
onSortChange,
onRowClick,
onRowContextMenu,
rowClassName,
}: Props) {
return (
@ -37,7 +35,6 @@ export default function FinishedDownloadsTableView({
sort={sort}
onSortChange={onSortChange}
onRowClick={onRowClick}
onRowContextMenu={onRowContextMenu}
rowClassName={rowClassName}
/>
)

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED } from './videoPolicy'
type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
@ -50,6 +51,14 @@ export type FinishedVideoPreviewProps = {
inlineControls?: boolean
/** Inline-Playback: loopen? */
inlineLoop?: boolean
assetNonce?: number
/** alle Inline/Teaser/Clips muted? (Default: true) */
muted?: boolean
/** Popover-Video muted? (Default: true) */
popoverMuted?: boolean
}
export default function FinishedVideoPreview({
@ -79,10 +88,21 @@ export default function FinishedVideoPreview({
inlineNonce = 0,
inlineControls = false,
inlineLoop = true,
assetNonce = 0,
muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED,
}: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
const commonVideoProps = {
muted,
playsInline: true,
preload: 'metadata' as const,
}
const [thumbOk, setThumbOk] = useState(true)
const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false)
@ -104,12 +124,14 @@ export default function FinishedVideoPreview({
? 'hover'
: 'never'
// ✅ id = Dateiname ohne Endung (genau wie du willst)
const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const previewId = useMemo(() => {
const file = getFileName(job.output || '')
if (!file) return ''
const dot = file.lastIndexOf('.')
return dot > 0 ? file.slice(0, dot) : file
}, [file])
const base = file.replace(/\.[^.]+$/, '') // ext weg
return stripHot(base).trim()
}, [job.output, getFileName])
// Vollvideo (für Inline-Playback + Duration-Metadaten)
const videoSrc = useMemo(
@ -120,11 +142,6 @@ export default function FinishedVideoPreview({
// ✅ Teaser-Video (vorgerendert)
const isActive = active !== undefined ? Boolean(active) : true
const teaserSrc = useMemo(
() => (previewId ? `/api/generated/teaser?id=${encodeURIComponent(previewId)}` : ''),
[previewId]
)
const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
@ -207,14 +224,18 @@ export default function FinishedVideoPreview({
return Math.min(dur - 0.05, Math.max(0.05, t))
}, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples])
const v = assetNonce ?? 0
const thumbSrc = useMemo(() => {
if (!previewId) return ''
// static thumb (oder frames: mit t=...)
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
thumbTimeSec.toFixed(2)
)}&v=${encodeURIComponent(String(localTick))}`
}, [previewId, thumbTimeSec, localTick])
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}-${localTick}`
}, [previewId, thumbTimeSec, localTick, v])
const teaserSrc = useMemo(() => {
if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&v=${v}`
}, [previewId, v])
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
@ -228,6 +249,11 @@ export default function FinishedVideoPreview({
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
useEffect(() => {
setThumbOk(true)
setVideoOk(true)
}, [previewId, assetNonce])
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' &&
@ -347,6 +373,7 @@ export default function FinishedVideoPreview({
{/* 1) Inline Full Video (mit Controls) */}
{showingInlineVideo ? (
<video
{...commonVideoProps}
key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc}
className={[
@ -354,9 +381,6 @@ export default function FinishedVideoPreview({
blurCls,
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
].filter(Boolean).join(' ')}
muted
playsInline
preload="metadata"
autoPlay
controls={inlineControls}
loop={inlineLoop}
@ -432,7 +456,7 @@ export default function FinishedVideoPreview({
<video
src={videoSrc}
className={['w-full h-full bg-black', blurCls].filter(Boolean).join(' ')}
muted
muted={popoverMuted}
playsInline
preload="metadata"
controls

View File

@ -0,0 +1,139 @@
'use client'
import { useRef, useState, useEffect, useCallback } from 'react'
import Button from './Button'
import ProgressBar from './ProgressBar'
type TaskState = {
running: boolean
total: number
done: number
generatedThumbs: number
generatedPreviews: number
skipped: number
startedAt?: string
finishedAt?: string
error?: string
}
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
cache: 'no-store' as any,
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers || {}),
},
})
let data: any = null
try {
data = await res.json()
} catch {
// ignore
}
if (!res.ok) {
const msg = (data && (data.error || data.message)) || res.statusText
throw new Error(msg)
}
return data as T
}
type Props = {
onFinished?: () => void
}
export default function GenerateAssetsTask({ onFinished }: Props) {
const [state, setState] = useState<TaskState | null>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
const loadStatus = useCallback(async () => {
try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets')
setState(st)
} catch (e: any) {
setError(e?.message ?? String(e))
}
}, [])
const prevRunningRef = useRef(false)
useEffect(() => {
const prev = prevRunningRef.current
const cur = Boolean(state?.running)
prevRunningRef.current = cur
// Task ist gerade fertig geworden
if (prev && !cur) {
onFinished?.()
}
}, [state?.running, onFinished])
useEffect(() => {
loadStatus()
}, [loadStatus])
useEffect(() => {
if (!state?.running) return
const t = window.setInterval(loadStatus, 2000)
return () => window.clearInterval(t)
}, [state?.running, loadStatus])
async function start() {
setError(null)
setStarting(true)
try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' })
setState(st)
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setStarting(false)
}
}
const running = !!state?.running
const total = state?.total ?? 0
const done = state?.done ?? 0
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
<div className="rounded-md border border-gray-200 p-3 dark:border-white/10">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">Fehlende Assets generieren</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-white/70">
Erzeugt pro fertiger Datei unter <span className="font-mono">/generated/&lt;id&gt;/</span> die Dateien{' '}
<span className="font-mono">thumbs.jpg</span> und <span className="font-mono">preview.mp4</span>.
</div>
</div>
<Button variant="primary" onClick={start} disabled={starting || running}>
{running ? 'Läuft…' : 'Generieren'}
</Button>
</div>
{error ? <div className="mt-2 text-xs text-red-600 dark:text-red-400">{error}</div> : null}
{state?.error ? <div className="mt-2 text-xs text-amber-600 dark:text-amber-400">{state.error}</div> : null}
{state ? (
<div className="mt-3 space-y-2">
<ProgressBar
value={pct}
showPercent
rightLabel={total ? `${done}/${total} Dateien` : '—'}
/>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-700 dark:text-white/70">
<span>
Thumbs: {state.generatedThumbs} Previews: {state.generatedPreviews} Übersprungen: {state.skipped}
</span>
</div>
</div>
) : null}
</div>
)
}

View File

@ -2,14 +2,15 @@
import { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy'
export default function LiveHlsVideo({
src,
muted,
muted = DEFAULT_INLINE_MUTED,
className,
}: {
src: string
muted: boolean
muted?: boolean
className?: string
}) {
const ref = useRef<HTMLVideoElement>(null)
@ -19,13 +20,13 @@ export default function LiveHlsVideo({
let cancelled = false
let hls: Hls | null = null
// NOTE: Narrowing ("if (!video) return") wirkt NICHT in async-Callbacks.
// Deshalb reichen wir das Element explizit in start() rein.
const videoEl = ref.current
if (!videoEl) return
setBroken(false)
videoEl.muted = muted
// ✅ zentral
applyInlineVideoPolicy(videoEl, { muted })
async function waitForManifest() {
const started = Date.now()
@ -97,9 +98,7 @@ export default function LiveHlsVideo({
className={className}
playsInline
autoPlay
// wichtig: Mini bleibt muted über Prop, Popover nicht
muted={muted}
// click hilft, falls Autoplay mit Sound geblockt
onClick={() => {
const v = ref.current
if (v) {

View File

@ -8,21 +8,39 @@ import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = {
jobId: string
// Optional: wird von außen hochgezählt (z.B. alle 5s). Wenn nicht gesetzt,
// tickt die Komponente selbst (weniger Re-Renders im Parent).
thumbTick?: number
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
autoTickMs?: number
blur?: boolean
// ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt)
alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null
alignEveryMs?: number
}
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000, blur = false }: Props) {
export default function ModelPreview({
jobId,
thumbTick,
autoTickMs = 10_000,
blur = false,
alignStartAt,
alignEndAt = null,
alignEveryMs,
}: Props) {
const blurCls = blur ? 'blur-md' : ''
const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
const ms = Date.parse(String(v ?? ''))
return Number.isFinite(ms) ? ms : NaN
}
useEffect(() => {
// Wenn Parent tickt, kein lokales Ticken
if (typeof thumbTick === 'number') return
@ -30,12 +48,43 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000, blu
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar
if (!inView || document.hidden) return
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
if (!Number.isFinite(period) || period <= 0) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const endMs = alignEndAt ? toMs(alignEndAt) : NaN
// 1) ✅ Aligned: tick genau auf Vielfachen von period seit startMs
if (Number.isFinite(startMs)) {
let t: number | undefined
const schedule = () => {
const now = Date.now()
if (Number.isFinite(endMs) && now >= endMs) return
const elapsed = Math.max(0, now - startMs)
const rem = elapsed % period
const wait = rem === 0 ? period : period - rem
t = window.setTimeout(() => {
setLocalTick((x) => x + 1)
schedule()
}, wait)
}
schedule()
return () => {
if (t) window.clearTimeout(t)
}
}
// 2) Fallback: normales Interval (nicht aligned)
const id = window.setInterval(() => {
setLocalTick((t) => t + 1)
}, autoTickMs)
setLocalTick((x) => x + 1)
}, period)
return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView])
}, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs])
useEffect(() => {
const el = rootRef.current

View File

@ -16,11 +16,13 @@ import {
XMarkIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
BookmarkSquareIcon,
} from '@heroicons/react/24/outline'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
} from '@heroicons/react/24/solid'
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
@ -88,10 +90,13 @@ export type PlayerProps = {
isLiked?: boolean
// actions
onKeep?: (job: RecordJob) => void | Promise<void>
onDelete?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
startMuted?: boolean
}
export default function Player({
@ -103,10 +108,12 @@ export default function Player({
isHot = false,
isFavorite = false,
isLiked = false,
onKeep,
onDelete,
onToggleHot,
onToggleFavorite,
onToggleLike,
startMuted = DEFAULT_PLAYER_START_MUTED,
}: PlayerProps) {
const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id])
@ -212,7 +219,7 @@ export default function Player({
const p = videojs(videoEl, {
autoplay: true,
muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern
muted: startMuted,
controls: true,
preload: 'metadata',
playsinline: true,
@ -261,8 +268,7 @@ export default function Player({
const t = p.currentTime() || 0
// Autoplay-Policy: vor play() immer muted setzen
p.muted(true)
p.muted(startMuted)
// Source setzen
p.src({ src: media.src, type: media.type })
@ -287,7 +293,7 @@ export default function Player({
// zusätzlich sofort versuchen (hilft je nach Browser/Cache)
tryPlay()
}, [mounted, media.src, media.type])
}, [mounted, media.src, media.type, startMuted])
// ✅ Resize triggern bei expand/collapse
@ -441,6 +447,12 @@ export default function Player({
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
'disabled:opacity-50 disabled:cursor-not-allowed'
const overlayBtnSuccess =
'inline-flex items-center justify-center rounded-md bg-emerald-600/35 p-2 text-white ' +
'backdrop-blur hover:bg-emerald-600/55 transition ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
'disabled:opacity-50 disabled:cursor-not-allowed'
const footerRight = (
<div className="flex items-center gap-1">
<button
@ -499,6 +511,23 @@ export default function Player({
})()}
</button>
<button
type="button"
className={overlayBtnSuccess}
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
onClick={async (e) => {
e.stopPropagation()
releaseMedia()
onClose()
await new Promise((r) => setTimeout(r, 150))
await onKeep?.(job)
}}
disabled={!onKeep}
>
<BookmarkSquareIcon className="h-5 w-5 text-emerald-200" />
</button>
<button
type="button"
className={overlayBtnDanger}

View File

@ -0,0 +1,93 @@
'use client'
import * as React from 'react'
type ProgressBarProps = {
label?: React.ReactNode
value?: number | null // 0..100
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
showPercent?: boolean // zeigt rechts “xx%” (nur determinate)
rightLabel?: React.ReactNode // optionaler Text links unten (z.B. 3/10)
steps?: string[] // optional: Step-Labels (wie in deinem Beispiel)
currentStep?: number // 0-basiert, z.B. 1 = Step 2 aktiv
size?: 'sm' | 'md'
className?: string
}
export default function ProgressBar({
label,
value = 0,
indeterminate = false,
showPercent = false,
rightLabel,
steps,
currentStep,
size = 'md',
className,
}: ProgressBarProps) {
const clamped = Math.max(0, Math.min(100, Number(value) || 0))
const h = size === 'sm' ? 'h-1.5' : 'h-2'
const hasSteps = Array.isArray(steps) && steps.length > 0
const stepCount = hasSteps ? steps!.length : 0
const stepAlign = (i: number) => {
if (i === 0) return 'text-left'
if (i === stepCount - 1) return 'text-right'
return 'text-center'
}
const isActiveStep = (i: number) => {
if (typeof currentStep !== 'number' || !Number.isFinite(currentStep)) return false
return i <= currentStep
}
return (
<div className={className}>
{label ? (
<p className="text-sm font-medium text-gray-900 dark:text-white">
{label}
</p>
) : null}
<div aria-hidden="true" className={label ? 'mt-2' : ''}>
<div className="overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
{indeterminate ? (
<div className={`${h} w-full rounded-full bg-indigo-600/70 dark:bg-indigo-500/70 animate-pulse`} />
) : (
<div
className={`${h} rounded-full bg-indigo-600 dark:bg-indigo-500 transition-[width] duration-200`}
style={{ width: `${clamped}%` }}
/>
)}
</div>
{(rightLabel || (showPercent && !indeterminate)) ? (
<div className="mt-2 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>{rightLabel ?? ''}</span>
{showPercent && !indeterminate ? <span>{Math.round(clamped)}%</span> : <span />}
</div>
) : null}
{hasSteps ? (
<div
className="mt-3 hidden text-sm font-medium text-gray-600 sm:grid dark:text-gray-400"
style={{ gridTemplateColumns: `repeat(${stepCount}, minmax(0, 1fr))` }}
>
{steps!.map((s, i) => (
<div
key={`${i}-${s}`}
className={[
stepAlign(i),
isActiveStep(i) ? 'text-indigo-600 dark:text-indigo-400' : '',
].join(' ')}
>
{s}
</div>
))}
</div>
) : null}
</div>
</div>
)
}

View File

@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
import Button from './Button'
import Card from './Card'
import LabeledSwitch from './LabeledSwitch'
import GenerateAssetsTask from './GenerateAssetsTask'
type RecorderSettings = {
recordDir: string
@ -34,7 +35,11 @@ const DEFAULTS: RecorderSettings = {
blurPreviews: false,
}
export default function RecorderSettings() {
type Props = {
onAssetsGenerated?: () => void
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
@ -270,6 +275,12 @@ export default function RecorderSettings() {
/>
</div>
</div>
{/* Tasks */}
<div className="mt-2 border-t border-gray-200 pt-4 dark:border-white/10">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
<GenerateAssetsTask onFinished={onAssetsGenerated} />
</div>
</div>
</Card>
)

View File

@ -1,13 +1,14 @@
// RunningDownloads.tsx
'use client'
import { useMemo } from 'react'
import { useMemo, useState, useCallback, useEffect } from 'react'
import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
import ModelPreview from './ModelPreview'
import WaitingModelsTable, { type WaitingModelRow } from './WaitingModelsTable'
import type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar'
type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown
@ -37,27 +38,42 @@ const phaseLabel = (p?: string) => {
}
function StatusCell({ job }: { job: RecordJob }) {
const label = phaseLabel((job as any).phase)
const phaseRaw = String((job as any).phase ?? '')
const phase = phaseRaw.toLowerCase()
const progress = Number((job as any).progress ?? 0)
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100 && !!label
// ✅ Phase-Text komplett ausblenden (auch remuxing/moving/finalizing)
const hideLabel =
phase === 'stopping' || phase === 'remuxing' || phase === 'moving' || phase === 'finalizing'
const label = hideLabel ? '' : phaseLabel(phaseRaw)
// ✅ ProgressBar soll unabhängig vom Label erscheinen
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
return (
<div className="min-w-0">
<div className="truncate">
<span className="font-medium">{job.status}</span>
{label ? <span className="text-gray-600 dark:text-gray-300"> {label}</span> : null}
{label ? (
<span className="text-gray-600 dark:text-gray-300"> {label}</span>
) : null}
</div>
{showBar ? (
<div className="mt-1 h-1.5 w-40 overflow-hidden rounded bg-gray-200 dark:bg-gray-700">
<div className="h-full" style={{ width: `${Math.max(0, Math.min(100, progress))}%` }} />
<div className="mt-1">
<ProgressBar
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="max-w-[220px]"
/>
</div>
) : null}
</div>
)
}
const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
@ -85,21 +101,67 @@ const formatDuration = (ms: number): string => {
return `${s}s`
}
const runtimeOf = (j: RecordJob) => {
const runtimeOf = (j: RecordJob, nowMs: number) => {
const start = Date.parse(String(j.startedAt || ''))
if (!Number.isFinite(start)) return '—'
const end = j.endedAt ? Date.parse(String(j.endedAt)) : Date.now()
const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs
if (!Number.isFinite(end)) return '—'
return formatDuration(end - start)
}
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob, blurPreviews }: Props) {
const [stopAllBusy, setStopAllBusy] = useState(false)
const [nowMs, setNowMs] = useState(() => Date.now())
const hasActive = useMemo(() => {
// tickt solange mind. ein Job noch nicht beendet ist
return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs])
useEffect(() => {
if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
return () => window.clearInterval(t)
}, [hasActive])
const stoppableIds = useMemo(() => {
return jobs
.filter((j) => {
const phase = (j as any).phase as string | undefined
const isStoppingOrFinalizing = Boolean(phase) || j.status !== 'running'
return !isStoppingOrFinalizing
})
.map((j) => j.id)
}, [jobs])
const onStopAll = useCallback(() => {
if (stopAllBusy) return
if (stoppableIds.length === 0) return
setStopAllBusy(true)
// fire-and-forget (onStopJob ist bei dir void)
for (const id of stoppableIds) onStopJob(id)
// Button kurz sperren; Polling/Phase-Updates übernehmen den Rest
setTimeout(() => setStopAllBusy(false), 800)
}, [stopAllBusy, stoppableIds, onStopJob])
const columns = useMemo<Column<RecordJob>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
cell: (j) => <ModelPreview jobId={j.id} blur={blurPreviews} />,
cell: (j) => (
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
/>
),
},
{
key: 'model',
@ -140,7 +202,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
{
key: 'runtime',
header: 'Dauer',
cell: (j) => runtimeOf(j),
cell: (j) => runtimeOf(j, nowMs),
},
{
key: 'actions',
@ -168,7 +230,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
},
},
]
}, [onStopJob])
}, [onStopJob, blurPreviews, nowMs])
if (jobs.length === 0 && pending.length === 0) {
return (
@ -206,12 +268,26 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
{jobs.length > 0 && (
<>
<div className="mb-2 flex items-center justify-end gap-2">
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.stopPropagation()
onStopAll()
}}
>
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
{/* ✅ Mobile: Cards */}
<div className="sm:hidden space-y-3">
{jobs.map((j) => {
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j)
const dur = runtimeOf(j, nowMs)
return (
<div
@ -240,14 +316,20 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
onStopJob(j.id)
}}
>
Stop
Stoppen
</Button>
</div>
}
>
<div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<ModelPreview jobId={j.id} blur={blurPreviews} />
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
/>
</div>
<div className="min-w-0 flex-1">

View File

@ -0,0 +1,17 @@
// components/ui/videoPolicy.ts
export const DEFAULT_INLINE_MUTED = false
export const DEFAULT_PLAYER_START_MUTED = false
export function applyInlineVideoPolicy(
el: HTMLVideoElement | null,
opts?: { muted?: boolean }
) {
if (!el) return
const muted = opts?.muted ?? DEFAULT_INLINE_MUTED
// Autoplay klappt am zuverlässigsten mit muted + playsInline
el.muted = muted
el.defaultMuted = muted
el.playsInline = true
el.setAttribute('playsinline', 'true')
}