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) { 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) { mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) {
host := strings.TrimSpace(r.URL.Query().Get("host")) host := strings.TrimSpace(r.URL.Query().Get("host"))
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(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()) 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) 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) { mux.HandleFunc("/api/models/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})

View File

@ -79,6 +79,68 @@ type ModelStore struct {
mu sync.Mutex 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: // Backwards compatible:
// - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db" // - 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. // 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(` rows, err := s.db.Query(`
SELECT SELECT
id,input,is_url,host,path,model_key, id,input,is_url,host,path,model_key,
tags,last_stream, tags, COALESCE(last_stream,''),
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at,updated_at created_at,updated_at
FROM models FROM models
@ -524,27 +586,28 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
defer s.mu.Unlock() defer s.mu.Unlock()
_, err = s.db.Exec(` _, err = s.db.Exec(`
INSERT INTO models ( INSERT INTO models (
id,input,is_url,host,path,model_key, id,input,is_url,host,path,model_key,
tags,last_stream, tags,last_stream,
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at,updated_at created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
input=excluded.input, input=excluded.input,
is_url=excluded.is_url, is_url=excluded.is_url,
host=excluded.host, host=excluded.host,
path=excluded.path, path=excluded.path,
model_key=excluded.model_key, model_key=excluded.model_key,
updated_at=excluded.updated_at; updated_at=excluded.updated_at;
`, `,
id, id,
u.String(), u.String(),
int64(1), int64(1),
host, host,
p.Path, p.Path,
modelKey, 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,
now, now,
) )
@ -592,7 +655,15 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
if patch.Keep != nil { if patch.Keep != nil {
keep = boolToInt(*patch.Keep) 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 { if patch.ClearLiked {
liked = sql.NullInt64{Valid: false} liked = sql.NullInt64{Valid: false}
} else if patch.Liked != nil { } else if patch.Liked != nil {
@ -721,7 +792,7 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT SELECT
input,is_url,host,path,model_key, input,is_url,host,path,model_key,
tags, lastStream, tags, COALESCE(last_stream,''),
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at,updated_at created_at,updated_at
FROM models FROM models

View File

@ -7,9 +7,10 @@ import Tabs, { type TabItem } from './components/ui/Tabs'
import RecorderSettings from './components/ui/RecorderSettings' import RecorderSettings from './components/ui/RecorderSettings'
import FinishedDownloads from './components/ui/FinishedDownloads' import FinishedDownloads from './components/ui/FinishedDownloads'
import Player from './components/ui/Player' import Player from './components/ui/Player'
import type { RecordJob, ParsedModel } from './types' import type { RecordJob } from './types'
import RunningDownloads from './components/ui/RunningDownloads' import RunningDownloads from './components/ui/RunningDownloads'
import ModelsTab from './components/ui/ModelsTab' import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar'
const COOKIE_STORAGE_KEY = 'record_cookies' const COOKIE_STORAGE_KEY = 'record_cookies'
@ -110,8 +111,6 @@ export default function App() {
const DONE_PAGE_SIZE = 8 const DONE_PAGE_SIZE = 8
const [sourceUrl, setSourceUrl] = useState('') const [sourceUrl, setSourceUrl] = useState('')
const [, setParsed] = useState<ParsedModel | null>(null)
const [, setParseError] = useState<string | null>(null)
const [jobs, setJobs] = useState<RecordJob[]>([]) const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([]) const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [donePage, setDonePage] = useState(1) const [donePage, setDonePage] = useState(1)
@ -120,7 +119,7 @@ export default function App() {
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null) const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: 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 [busy, setBusy] = useState(false)
const [cookieModalOpen, setCookieModalOpen] = useState(false) const [cookieModalOpen, setCookieModalOpen] = useState(false)
const [cookies, setCookies] = useState<Record<string, string>>({}) const [cookies, setCookies] = useState<Record<string, string>>({})
@ -129,6 +128,9 @@ export default function App() {
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null) const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false) 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 [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList) const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
@ -317,55 +319,73 @@ export default function App() {
}, [doneCount, donePage]) }, [doneCount, donePage])
useEffect(() => { useEffect(() => {
if (sourceUrl.trim() === '') { let cancelled = false
setParsed(null) let es: EventSource | null = null
setParseError(null) let fallbackTimer: number | null = null
return 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 () => { const loadOnce = async () => {
try { if (cancelled || inFlight) return
const p = await apiJSON<ParsedModel>('/api/models/parse', { inFlight = true
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 () => {
try { try {
const list = await apiJSON<RecordJob[]>('/api/record/list') const list = await apiJSON<RecordJob[]>('/api/record/list')
if (!cancelled) { applyList(list)
setJobs(Array.isArray(list) ? list : [])
}
} catch { } catch {
if (!cancelled) { // ignore
// optional: bei Fehler nicht alles leeren, sondern Zustand behalten } finally {
// setJobs([]) inFlight = false
}
} }
} }
// direkt einmal laden const startFallbackPolling = () => {
loadJobs() if (fallbackTimer) return
// dann jede Sekunde fallbackTimer = window.setInterval(loadOnce, document.hidden ? 15000 : 5000)
const t = setInterval(loadJobs, 1000) }
// 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 () => { return () => {
cancelled = true 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]) }, [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 { function isChaturbate(url: string): boolean {
try { try {
@ -491,11 +545,29 @@ export default function App() {
} }
}, []) // arbeitet über refs, daher keine deps nötig }, []) // 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 urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
const url = extractFirstHttpUrl(urlFromJob) const url = extractFirstHttpUrl(urlFromJob)
// 1) Wenn URL da ist: parse + upsert => liefert ID + flags // 1) Wenn URL da ist: parse + upsert
if (url) { if (url) {
const parsed = await apiJSON<any>('/api/models/parse', { const parsed = await apiJSON<any>('/api/models/parse', {
method: 'POST', method: 'POST',
@ -509,13 +581,15 @@ export default function App() {
body: JSON.stringify(parsed), body: JSON.stringify(parsed),
}) })
upsertCache(saved)
return saved return saved
} }
// 2) Fallback: aus Dateiname modelKey ableiten und im Store suchen // 2) Fallback: modelKey aus Dateiname
const key = modelKeyFromFilename(job.output || '') const key = modelKeyFromFilename(job.output || '')
if (!key) return null if (!key) return null
// Cache laden/auffrischen (nur fürs schnelle Match)
const now = Date.now() const now = Date.now()
const cached = modelsCacheRef.current const cached = modelsCacheRef.current
if (!cached || now - cached.ts > 30_000) { if (!cached || now - cached.ts > 30_000) {
@ -526,12 +600,25 @@ export default function App() {
const list = modelsCacheRef.current?.list ?? [] const list = modelsCacheRef.current?.list ?? []
const needle = key.toLowerCase() const needle = key.toLowerCase()
// wenn mehrere: nimm Favorite zuerst, dann irgendeins const hits = list.filter((m) => (m.modelKey || '').toLowerCase() === needle)
const hits = list.filter(m => (m.modelKey || '').toLowerCase() === needle) if (hits.length > 0) {
if (hits.length === 0) return null return hits.sort((a, b) => Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)))[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(() => { useEffect(() => {
let cancelled = false let cancelled = false
if (!playerJob) { 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 handleToggleHot = useCallback(async (job: RecordJob) => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
if (!file) return if (!file) return
@ -623,7 +748,7 @@ export default function App() {
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file) const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
let m = sameAsPlayer ? playerModel : null let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job) if (!m) m = await resolveModelForJob(job, { ensure: true })
if (!m) return if (!m) return
const next = !Boolean(m.favorite) const next = !Boolean(m.favorite)
@ -646,7 +771,7 @@ export default function App() {
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file) const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
let m = sameAsPlayer ? playerModel : null let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job) if (!m) m = await resolveModelForJob(job, { ensure: true })
if (!m) return if (!m) return
const curLiked = m.liked === true const curLiked = m.liked === true
@ -732,10 +857,10 @@ export default function App() {
async function stopJob(id: string) { async function stopJob(id: string) {
try { try {
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
method: 'POST', } catch (e: any) {
}) setError(e?.message ?? String(e))
} catch {} }
} }
return ( 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" 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) && ( {isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) && (
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400"> <div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
Für Chaturbate werden die Cookies <code>cf_clearance</code> und{' '} Für Chaturbate werden die Cookies <code>cf_clearance</code> und{' '}
<code>sessionId</code> benötigt. <code>sessionId</code> benötigt.
</div> </div>
)} )}
{busy ? (
<div className="mt-3">
<ProgressBar label="Starte Download…" indeterminate />
</div>
) : null}
</Card> </Card>
<Tabs <Tabs
@ -807,13 +956,14 @@ export default function App() {
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike} onToggleLike={handleToggleLike}
blurPreviews={Boolean(recSettings.blurPreviews)} blurPreviews={Boolean(recSettings.blurPreviews)}
assetNonce={assetNonce}
onRefreshDone={refreshDoneNow}
/> />
)} )}
{selectedTab === 'models' && <ModelsTab />} {selectedTab === 'models' && <ModelsTab />}
{selectedTab === 'settings' && <RecorderSettings />} {selectedTab === 'settings' && <RecorderSettings onAssetsGenerated={bumpAssets} />}
<CookieModal <CookieModal
open={cookieModalOpen} open={cookieModalOpen}
@ -845,6 +995,7 @@ export default function App() {
isHot={baseName(playerJob.output || '').startsWith('HOT ')} isHot={baseName(playerJob.output || '').startsWith('HOT ')}
isFavorite={Boolean(playerModel?.favorite)} isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true} isLiked={playerModel?.liked === true}
onKeep={handleKeepJob}
onDelete={handleDeleteJob} onDelete={handleDeleteJob}
onToggleHot={handleToggleHot} onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}

View File

@ -6,8 +6,6 @@ import Table, { type Column, type SortState } from './Table'
import Card from './Card' import Card from './Card'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
import { buildDownloadContextMenu } from './DownloadContextMenu'
import Button from './Button' import Button from './Button'
import ButtonGroup from './ButtonGroup' import ButtonGroup from './ButtonGroup'
import { import {
@ -16,7 +14,6 @@ import {
Squares2X2Icon, Squares2X2Icon,
TrashIcon, TrashIcon,
FireIcon, FireIcon,
EllipsisVerticalIcon,
BookmarkSquareIcon, BookmarkSquareIcon,
StarIcon as StarOutlineIcon, StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon, HeartIcon as HeartOutlineIcon,
@ -31,6 +28,7 @@ import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
import FinishedDownloadsTableView from './FinishedDownloadsTableView' import FinishedDownloadsTableView from './FinishedDownloadsTableView'
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView' import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
import Pagination from './Pagination' import Pagination from './Pagination'
import { applyInlineVideoPolicy } from './videoPolicy'
type Props = { type Props = {
jobs: RecordJob[] jobs: RecordJob[]
@ -45,6 +43,8 @@ type Props = {
page: number page: number
pageSize: number pageSize: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
onRefreshDone?: (preferPage?: number) => void | Promise<void>
assetNonce?: number
} }
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim() const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
@ -157,9 +157,10 @@ export default function FinishedDownloads({
doneTotal, doneTotal,
page, page,
pageSize, pageSize,
onPageChange onPageChange,
onRefreshDone,
assetNonce,
}: Props) { }: Props) {
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map()) const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null) 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 [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = 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) const [sort, setSort] = React.useState<SortState>(null)
type ViewMode = 'table' | 'cards' | 'gallery' type ViewMode = 'table' | 'cards' | 'gallery'
@ -208,6 +224,56 @@ export default function FinishedDownloads({
// ⭐ Models-Flags (Fav/Like) aus Backend-Store // ⭐ Models-Flags (Fav/Like) aus Backend-Store
const [modelsByKey, setModelsByKey] = React.useState<Record<string, StoredModelFlags>>({}) 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 () => { const refreshModelsByKey = useCallback(async () => {
try { try {
const res = await fetch('/api/models/list', { cache: 'no-store' as any }) 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 const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) return false if (!v) return false
v.muted = true // ✅ zentral
v.playsInline = true applyInlineVideoPolicy(v, { muted: true })
v.setAttribute('playsinline', 'true')
const p = v.play?.() const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {}) if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
@ -293,16 +358,6 @@ export default function FinishedDownloads({
onOpenPlayer(job) onOpenPlayer(job)
}, [onOpenPlayer]) }, [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) => { const markDeleting = useCallback((key: string, value: boolean) => {
setDeletingKeys((prev) => { setDeletingKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
@ -343,16 +398,25 @@ export default function FinishedDownloads({
}) })
}, []) }, [])
const animateRemove = useCallback((key: string) => { const animateRemove = useCallback(
// 1) rot + fade-out starten (key: string) => {
markRemoving(key, true) // 1) rot + fade-out starten
markRemoving(key, true)
// 2) nach der Animation wirklich ausblenden // 2) nach der Animation wirklich ausblenden + Seite auffüllen
window.setTimeout(() => { window.setTimeout(() => {
markDeleted(key) markDeleted(key)
markRemoving(key, false) markRemoving(key, false)
}, 320)
}, [markDeleted, markRemoving]) // ✅ 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( const releasePlayingFile = useCallback(
async (file: string, opts?: { close?: boolean }) => { async (file: string, opts?: { close?: boolean }) => {
@ -384,8 +448,8 @@ export default function FinishedDownloads({
if (onDeleteJob) { if (onDeleteJob) {
await onDeleteJob(job) await onDeleteJob(job)
// ✅ optional: sofort aus der Liste animieren (fühlt sich besser an) // ✅ nach erfolgreichem Delete die Page nachziehen
animateRemove(key) queueRefill()
return true return true
} }
@ -406,7 +470,7 @@ export default function FinishedDownloads({
markDeleting(key, false) markDeleting(key, false)
} }
}, },
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove] [deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, queueRefill]
) )
const keepVideo = useCallback( const keepVideo = useCallback(
@ -442,41 +506,53 @@ export default function FinishedDownloads({
[keepingKeys, deletingKeys, markKeeping, releasePlayingFile, animateRemove] [keepingKeys, deletingKeys, markKeeping, releasePlayingFile, animateRemove]
) )
const items = React.useMemo<ContextMenuItem[]>(() => { const toggleHotVideo = useCallback(
if (!ctx) return [] async (job: RecordJob) => {
const j = ctx.job const file = baseName(job.output || '')
const model = modelNameFromOutput(j.output) if (!file) {
window.alert('Kein Dateiname gefunden kann nicht HOT togglen.')
return
}
return buildDownloadContextMenu({ try {
job: j, await releasePlayingFile(file, { close: true })
modelName: model,
state: {
watching: false,
liked: null,
favorite: false,
hot: false,
keep: false,
},
actions: {
onPlay: onOpenPlayer,
onToggleWatch: (job) => console.log('toggle watch', job.id), // ✅ Wenn du extern einen Handler hast, kannst du den nutzen
onSetLike: (job, liked) => console.log('set like', job.id, liked), // (Wenn du KEINEN hast: läuft der Fallback unten)
onToggleFavorite: (job) => console.log('toggle favorite', job.id), if (onToggleHot) {
onMoreFromModel: (modelName) => console.log('more from', modelName), await onToggleHot(job)
return
}
onRevealInExplorer: (job) => console.log('reveal in explorer', job.output), // Fallback: Backend direkt
onAddToDownloadList: (job) => console.log('add to download list', job.id), const res = await fetch(`/api/record/toggle-hot?file=${encodeURIComponent(file)}`, { method: 'POST' })
onToggleHot: (job) => console.log('toggle hot', job.id), if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
onToggleKeep: (job) => console.log('toggle keep', job.id), const data = (await res.json().catch(() => null)) as any
onDelete: (job) => { const oldFile = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file
setCtx(null) const newFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : ''
void deleteVideo(job)
}, if (newFile) {
}, // Optimistisch umbenennen (nicht aufs nächste Polling warten)
}) setRenamedFiles((prev) => ({ ...prev, [oldFile]: newFile }))
}, [ctx, deleteVideo, onOpenPlayer])
// 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 runtimeSecondsForSort = useCallback((job: RecordJob) => {
const start = Date.parse(String(job.startedAt || '')) 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 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 rows = useMemo(() => {
const map = new Map<string, RecordJob>() const map = new Map<string, RecordJob>()
// Basis: Files aus dem Done-Ordner // 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) { for (const j of jobs) {
const k = keyFor(j) const jj = applyRenamedOutput(j)
if (map.has(k)) map.set(k, { ...map.get(k)!, ...j }) const k = keyFor(jj)
if (map.has(k)) map.set(k, { ...map.get(k)!, ...jj })
} }
const list = Array.from(map.values()).filter((j) => { 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 || ''))) list.sort((a, b) => norm(b.endedAt || '').localeCompare(norm(a.endedAt || '')))
return list return list
}, [jobs, doneJobs, deletedKeys]) }, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])
const endedAtMs = (j: RecordJob) => (j.endedAt ? new Date(j.endedAt).getTime() : 0) 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) // ✅ wenn Cards-View: Swipe schon beim Start raus (ohne Aktion, weil App die API schon macht)
if (view === 'cards') { if (view === 'cards') {
swipeRefs.current.get(key)?.swipeLeft({ runAction: false }) window.setTimeout(() => {
} markDeleted(key)
} else if (detail.phase === 'success') { void onRefreshDone?.(page) // ✅ HIER dazu
markDeleting(key, false) }, 320)
if (view === 'cards') {
// ✅ nach Swipe-Animation wirklich aus der Liste entfernen
window.setTimeout(() => markDeleted(key), 320)
} else { } else {
// table/gallery: wie bisher ausblenden
animateRemove(key) animateRemove(key)
} }
} else if (detail.phase === 'error') { } else if (detail.phase === 'error') {
@ -611,12 +702,16 @@ export default function FinishedDownloads({
if (view === 'cards') { if (view === 'cards') {
swipeRefs.current.get(key)?.reset() 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) window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('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 const viewRows = view === 'table' ? rows : sortedNonTableRows
@ -705,7 +800,6 @@ export default function FinishedDownloads({
{ {
key: 'preview', key: 'preview',
header: 'Vorschau', header: 'Vorschau',
srOnlyHeader: true,
widthClassName: 'w-[140px]', widthClassName: 'w-[140px]',
cell: (j) => { cell: (j) => {
const k = keyFor(j) const k = keyFor(j)
@ -714,11 +808,6 @@ export default function FinishedDownloads({
className="py-1" className="py-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
openCtx(j, e)
}}
> >
<FinishedVideoPreview <FinishedVideoPreview
job={j} job={j}
@ -731,6 +820,7 @@ export default function FinishedDownloads({
animated={true} animated={true}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
assetNonce={assetNonce}
/> />
</div> </div>
) )
@ -755,19 +845,17 @@ export default function FinishedDownloads({
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 min-w-0"> <div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<div className="truncate font-medium text-gray-900 dark:text-white" title={model}> <span className="truncate" title={file}>
{model} {file || '—'}
</div> </span>
{isHot ? ( {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"> <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 HOT
</span> </span>
) : null} ) : null}
</div> </div>
<div className="truncate text-xs text-gray-500 dark:text-gray-400" title={file}>
{file || '—'}
</div>
</div> </div>
) )
}, },
@ -848,15 +936,88 @@ export default function FinishedDownloads({
srOnlyHeader: true, srOnlyHeader: true,
cell: (j) => { cell: (j) => {
const k = keyFor(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 = const iconBtn =
'inline-flex items-center justify-center rounded-md p-1.5 ' + 'inline-flex items-center justify-center rounded-md p-1.5 ' +
'hover:bg-gray-100/70 dark:hover:bg-white/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 ( return (
<div className="flex items-center justify-end gap-1"> <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 */} {/* Keep */}
<button <button
type="button" type="button"
@ -864,6 +1025,7 @@ export default function FinishedDownloads({
title="Behalten (nach keep verschieben)" title="Behalten (nach keep verschieben)"
aria-label="Behalten" aria-label="Behalten"
disabled={busy} disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -880,6 +1042,7 @@ export default function FinishedDownloads({
title="Löschen" title="Löschen"
aria-label="Löschen" aria-label="Löschen"
disabled={busy} disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -888,22 +1051,6 @@ export default function FinishedDownloads({
> >
<TrashIcon className="size-5 text-red-600 dark:text-red-300" /> <TrashIcon className="size-5 text-red-600 dark:text-red-300" />
</button> </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> </div>
) )
}, },
@ -920,7 +1067,7 @@ export default function FinishedDownloads({
} }
}, [isSmall]) }, [isSmall])
if (rows.length === 0) { if (rows.length === 0 && doneTotalPage === 0) {
return ( return (
<Card grayBody> <Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300"> <div className="text-sm text-gray-600 dark:text-gray-300">
@ -1027,13 +1174,12 @@ export default function FinishedDownloads({
handleDuration={handleDuration} handleDuration={handleDuration}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}
openCtx={openCtx}
openCtxAt={openCtxAt}
releasePlayingFile={releasePlayingFile} releasePlayingFile={releasePlayingFile}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
onToggleHot={onToggleHot} onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
assetNonce={assetNonce}
/> />
)} )}
@ -1045,7 +1191,6 @@ export default function FinishedDownloads({
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
onRowClick={onOpenPlayer} onRowClick={onOpenPlayer}
onRowContextMenu={(job, e) => openCtx(job, e)}
rowClassName={(j) => { rowClassName={(j) => {
const k = keyFor(j) const k = keyFor(j)
return [ return [
@ -1079,25 +1224,21 @@ export default function FinishedDownloads({
deletedKeys={deletedKeys} deletedKeys={deletedKeys}
registerTeaserHost={registerTeaserHost} registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
openCtx={openCtx}
openCtxAt={openCtxAt}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} 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 <Pagination
page={page} page={page}
pageSize={pageSize} pageSize={pageSize}
totalItems={doneTotal} totalItems={doneTotalPage}
onPageChange={(p) => { onPageChange={(p) => {
// 1) Inline-Playback + aktiven Teaser sofort stoppen // 1) Inline-Playback + aktiven Teaser sofort stoppen
flushSync(() => { flushSync(() => {

View File

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

View File

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

View File

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

View File

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

View File

@ -8,21 +8,39 @@ import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = { type Props = {
jobId: string 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 thumbTick?: number
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
autoTickMs?: number autoTickMs?: number
blur?: boolean 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 blurCls = blur ? 'blur-md' : ''
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) 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(() => { useEffect(() => {
// Wenn Parent tickt, kein lokales Ticken // Wenn Parent tickt, kein lokales Ticken
if (typeof thumbTick === 'number') return 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 // Nur animieren, wenn im Sichtbereich UND Tab sichtbar
if (!inView || document.hidden) return 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(() => { const id = window.setInterval(() => {
setLocalTick((t) => t + 1) setLocalTick((x) => x + 1)
}, autoTickMs) }, period)
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView]) }, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs])
useEffect(() => { useEffect(() => {
const el = rootRef.current const el = rootRef.current

View File

@ -16,11 +16,13 @@ import {
XMarkIcon, XMarkIcon,
StarIcon as StarOutlineIcon, StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon, HeartIcon as HeartOutlineIcon,
BookmarkSquareIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import { import {
StarIcon as StarSolidIcon, StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon, HeartIcon as HeartSolidIcon,
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
@ -88,10 +90,13 @@ export type PlayerProps = {
isLiked?: boolean isLiked?: boolean
// actions // actions
onKeep?: (job: RecordJob) => void | Promise<void>
onDelete?: (job: RecordJob) => void | Promise<void> onDelete?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (job: RecordJob) => void | Promise<void> onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
startMuted?: boolean
} }
export default function Player({ export default function Player({
@ -103,10 +108,12 @@ export default function Player({
isHot = false, isHot = false,
isFavorite = false, isFavorite = false,
isLiked = false, isLiked = false,
onKeep,
onDelete, onDelete,
onToggleHot, onToggleHot,
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
startMuted = DEFAULT_PLAYER_START_MUTED,
}: PlayerProps) { }: PlayerProps) {
const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id]) 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, { const p = videojs(videoEl, {
autoplay: true, autoplay: true,
muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern muted: startMuted,
controls: true, controls: true,
preload: 'metadata', preload: 'metadata',
playsinline: true, playsinline: true,
@ -261,8 +268,7 @@ export default function Player({
const t = p.currentTime() || 0 const t = p.currentTime() || 0
// Autoplay-Policy: vor play() immer muted setzen p.muted(startMuted)
p.muted(true)
// Source setzen // Source setzen
p.src({ src: media.src, type: media.type }) 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) // zusätzlich sofort versuchen (hilft je nach Browser/Cache)
tryPlay() tryPlay()
}, [mounted, media.src, media.type]) }, [mounted, media.src, media.type, startMuted])
// ✅ Resize triggern bei expand/collapse // ✅ 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 ' + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
'disabled:opacity-50 disabled:cursor-not-allowed' '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 = ( const footerRight = (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@ -499,6 +511,23 @@ export default function Player({
})()} })()}
</button> </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 <button
type="button" type="button"
className={overlayBtnDanger} 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 Button from './Button'
import Card from './Card' import Card from './Card'
import LabeledSwitch from './LabeledSwitch' import LabeledSwitch from './LabeledSwitch'
import GenerateAssetsTask from './GenerateAssetsTask'
type RecorderSettings = { type RecorderSettings = {
recordDir: string recordDir: string
@ -34,7 +35,11 @@ const DEFAULTS: RecorderSettings = {
blurPreviews: false, blurPreviews: false,
} }
export default function RecorderSettings() { type Props = {
onAssetsGenerated?: () => void
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
@ -270,6 +275,12 @@ export default function RecorderSettings() {
/> />
</div> </div>
</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> </div>
</Card> </Card>
) )

View File

@ -1,13 +1,14 @@
// RunningDownloads.tsx // RunningDownloads.tsx
'use client' 'use client'
import { useMemo } from 'react' import { useMemo, useState, useCallback, useEffect } from 'react'
import Table, { type Column } from './Table' import Table, { type Column } from './Table'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
import ModelPreview from './ModelPreview' import ModelPreview from './ModelPreview'
import WaitingModelsTable, { type WaitingModelRow } from './WaitingModelsTable' import WaitingModelsTable, { type WaitingModelRow } from './WaitingModelsTable'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar'
type PendingWatchedRoom = WaitingModelRow & { type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown currentShow: string // public / private / hidden / away / unknown
@ -37,27 +38,42 @@ const phaseLabel = (p?: string) => {
} }
function StatusCell({ job }: { job: RecordJob }) { 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 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 ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate"> <div className="truncate">
<span className="font-medium">{job.status}</span> <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> </div>
{showBar ? ( {showBar ? (
<div className="mt-1 h-1.5 w-40 overflow-hidden rounded bg-gray-200 dark:bg-gray-700"> <div className="mt-1">
<div className="h-full" style={{ width: `${Math.max(0, Math.min(100, progress))}%` }} /> <ProgressBar
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="max-w-[220px]"
/>
</div> </div>
) : null} ) : null}
</div> </div>
) )
} }
const baseName = (p: string) => const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || '' (p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
@ -85,21 +101,67 @@ const formatDuration = (ms: number): string => {
return `${s}s` return `${s}s`
} }
const runtimeOf = (j: RecordJob) => { const runtimeOf = (j: RecordJob, nowMs: number) => {
const start = Date.parse(String(j.startedAt || '')) const start = Date.parse(String(j.startedAt || ''))
if (!Number.isFinite(start)) return '—' 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 '—' if (!Number.isFinite(end)) return '—'
return formatDuration(end - start) return formatDuration(end - start)
} }
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob, blurPreviews }: Props) { 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>[]>(() => { const columns = useMemo<Column<RecordJob>[]>(() => {
return [ return [
{ {
key: 'preview', key: 'preview',
header: 'Vorschau', 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', key: 'model',
@ -140,7 +202,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
{ {
key: 'runtime', key: 'runtime',
header: 'Dauer', header: 'Dauer',
cell: (j) => runtimeOf(j), cell: (j) => runtimeOf(j, nowMs),
}, },
{ {
key: 'actions', 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) { if (jobs.length === 0 && pending.length === 0) {
return ( return (
@ -206,12 +268,26 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
{jobs.length > 0 && ( {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 */} {/* ✅ Mobile: Cards */}
<div className="sm:hidden space-y-3"> <div className="sm:hidden space-y-3">
{jobs.map((j) => { {jobs.map((j) => {
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '') const file = baseName(j.output || '')
const dur = runtimeOf(j) const dur = runtimeOf(j, nowMs)
return ( return (
<div <div
@ -240,14 +316,20 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
onStopJob(j.id) onStopJob(j.id)
}} }}
> >
Stop Stoppen
</Button> </Button>
</div> </div>
} }
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}> <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>
<div className="min-w-0 flex-1"> <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')
}