updated
This commit is contained in:
parent
ae67c817ac
commit
a229e1ef2c
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -255,10 +255,15 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
|
|||||||
}
|
}
|
||||||
|
|
||||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||||
|
|
||||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||||
if assetID == "" {
|
if assetID == "" {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
assetID, err = sanitizeID(assetID)
|
||||||
|
if err != nil || assetID == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
metaPath, err := metaJSONPathForAssetID(assetID)
|
metaPath, err := metaJSONPathForAssetID(assetID)
|
||||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||||
|
|||||||
Binary file not shown.
@ -650,83 +650,21 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID: basename ohne Ext + ohne HOT
|
videoPath = strings.TrimSpace(videoPath)
|
||||||
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
if videoPath == "" {
|
||||||
id := stripHotPrefix(base)
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if id == "" {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
metaPath, err := generatedMetaFile(id)
|
fi, err := os.Stat(videoPath)
|
||||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// existiert schon?
|
// ✅ Zentrale Meta-Logik benutzen (meta.go)
|
||||||
if fi, err := os.Stat(metaPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
// - liest vorhandene gültige meta
|
||||||
return
|
// - erzeugt v2-meta bei Bedarf
|
||||||
}
|
// - fallbackt auf duration-cache-only meta, wenn ffprobe gerade nicht geht
|
||||||
|
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
|
||||||
// Video stat für spätere Checks / Meta-Write
|
|
||||||
vfi, err := os.Stat(videoPath)
|
|
||||||
if err != nil || vfi == nil || vfi.IsDir() || vfi.Size() == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// kleiner Timeout: wir wollen Playback nicht “ewig” blockieren
|
|
||||||
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Dauer (best effort)
|
|
||||||
dur := 0.0
|
|
||||||
if d, derr := durationSecondsCached(pctx, videoPath); derr == nil && d > 0 {
|
|
||||||
dur = d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height/Width optional nicht mehr berechnen (wenn helper entfernt wurde)
|
|
||||||
h := 0
|
|
||||||
|
|
||||||
// FPS optional – wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen.
|
|
||||||
fps := 0.0
|
|
||||||
|
|
||||||
// Quelle URL ist bei done-files oft nur in meta; wenn unbekannt, leer lassen.
|
|
||||||
srcURL := ""
|
|
||||||
|
|
||||||
// Sicherstellen, dass Ordner existiert
|
|
||||||
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
|
||||||
|
|
||||||
// Format: passe an deine readVideoMeta(...) / readVideoMetaDuration(...) Parser-Struktur an!
|
|
||||||
// Ich nehme hier eine sehr typische Struktur an:
|
|
||||||
type videoMeta struct {
|
|
||||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
|
||||||
Width int `json:"width,omitempty"`
|
|
||||||
Height int `json:"height,omitempty"`
|
|
||||||
FPS float64 `json:"fps,omitempty"`
|
|
||||||
SourceURL string `json:"sourceUrl,omitempty"`
|
|
||||||
// optional: file info / updatedAt
|
|
||||||
UpdatedAtUnix int64 `json:"updatedAtUnix,omitempty"`
|
|
||||||
FileSizeBytes int64 `json:"fileSizeBytes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
m := videoMeta{
|
|
||||||
DurationSeconds: dur,
|
|
||||||
Width: 0,
|
|
||||||
Height: h,
|
|
||||||
FPS: fps,
|
|
||||||
SourceURL: srcURL,
|
|
||||||
UpdatedAtUnix: time.Now().Unix(),
|
|
||||||
FileSizeBytes: vfi.Size(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomisch schreiben (damit parallele Requests kein kaputtes JSON sehen)
|
|
||||||
tmp := metaPath + ".tmp"
|
|
||||||
if b, err := json.MarshalIndent(m, "", " "); err == nil {
|
|
||||||
_ = os.WriteFile(tmp, b, 0o644)
|
|
||||||
_ = os.Rename(tmp, metaPath)
|
|
||||||
} else {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordVideo(w http.ResponseWriter, r *http.Request) {
|
func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
1
backend/web/dist/assets/index-3IFBscEU.css
vendored
Normal file
1
backend/web/dist/assets/index-3IFBscEU.css
vendored
Normal file
File diff suppressed because one or more lines are too long
465
backend/web/dist/assets/index-BZ38s29o.js
vendored
465
backend/web/dist/assets/index-BZ38s29o.js
vendored
File diff suppressed because one or more lines are too long
465
backend/web/dist/assets/index-C4whm-WW.js
vendored
Normal file
465
backend/web/dist/assets/index-C4whm-WW.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-CZMtb58J.css
vendored
1
backend/web/dist/assets/index-CZMtb58J.css
vendored
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>App</title>
|
<title>App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
|
<script type="module" crossorigin src="/assets/index-C4whm-WW.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-3IFBscEU.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -476,15 +476,48 @@ export default function App() {
|
|||||||
|
|
||||||
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
|
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
|
||||||
const finishedReloadTimerRef = useRef<number | null>(null)
|
const finishedReloadTimerRef = useRef<number | null>(null)
|
||||||
|
const finishedReloadLastDispatchAtRef = useRef(0)
|
||||||
|
|
||||||
const requestFinishedReload = useCallback(() => {
|
const requestFinishedReload = useCallback((reason = 'unknown') => {
|
||||||
|
const now = Date.now()
|
||||||
|
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
|
||||||
|
|
||||||
|
// Wenn wir nicht im Finished-Tab sind, keine Reload-Events feuern.
|
||||||
|
// (Count kann separat aktualisiert werden.)
|
||||||
|
if (selectedTabRef.current !== 'finished') return
|
||||||
|
|
||||||
|
// Bereits geplant -> nur koaleszieren
|
||||||
if (finishedReloadTimerRef.current != null) {
|
if (finishedReloadTimerRef.current != null) {
|
||||||
window.clearTimeout(finishedReloadTimerRef.current)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Harte Dedupe-Schranke
|
||||||
|
const sinceLast = now - finishedReloadLastDispatchAtRef.current
|
||||||
|
if (sinceLast < cooldownMs) {
|
||||||
|
finishedReloadTimerRef.current = window.setTimeout(() => {
|
||||||
|
finishedReloadTimerRef.current = null
|
||||||
|
finishedReloadLastDispatchAtRef.current = Date.now()
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:reload', {
|
||||||
|
detail: { source: `App.requestFinishedReload(cooldown-tail)`, reason },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, cooldownMs - sinceLast)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leichtes Coalescing für Burst im selben Moment
|
||||||
finishedReloadTimerRef.current = window.setTimeout(() => {
|
finishedReloadTimerRef.current = window.setTimeout(() => {
|
||||||
finishedReloadTimerRef.current = null
|
finishedReloadTimerRef.current = null
|
||||||
window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
|
finishedReloadLastDispatchAtRef.current = Date.now()
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:reload', {
|
||||||
|
detail: { source: 'App.requestFinishedReload', reason },
|
||||||
|
})
|
||||||
|
)
|
||||||
}, 150)
|
}, 150)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -1249,12 +1282,12 @@ export default function App() {
|
|||||||
|
|
||||||
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
|
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload()
|
requestFinishedReload('selectedTab effect')
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload()
|
requestFinishedReload('selectedTab visibilitychange')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('visibilitychange', onVis)
|
document.addEventListener('visibilitychange', onVis)
|
||||||
@ -1290,7 +1323,7 @@ export default function App() {
|
|||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
if (selectedTabRef.current === 'finished') {
|
if (selectedTabRef.current === 'finished') {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload()
|
requestFinishedReload('done-stream poll tick')
|
||||||
} else {
|
} else {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
}
|
}
|
||||||
@ -1312,7 +1345,7 @@ export default function App() {
|
|||||||
lastFireRef.t = Date.now()
|
lastFireRef.t = Date.now()
|
||||||
if (selectedTabRef.current === 'finished') {
|
if (selectedTabRef.current === 'finished') {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload()
|
requestFinishedReload('done-stream coalesced requestRefresh')
|
||||||
} else {
|
} else {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
}
|
}
|
||||||
@ -1323,7 +1356,7 @@ export default function App() {
|
|||||||
lastFireRef.t = now
|
lastFireRef.t = now
|
||||||
if (selectedTabRef.current === 'finished') {
|
if (selectedTabRef.current === 'finished') {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload()
|
requestFinishedReload('done-stream requestRefresh')
|
||||||
} else {
|
} else {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
}
|
}
|
||||||
@ -1436,18 +1469,14 @@ export default function App() {
|
|||||||
setDoneCount((c) => Math.max(0, c + delta))
|
setDoneCount((c) => Math.max(0, c + delta))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count darf immer aktualisiert werden (Badge/Header)
|
// ✅ Nur Header/Badge nachziehen
|
||||||
|
// FinishedDownloads macht seinen Refill selbst.
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
|
|
||||||
// ✅ Nur Finished-Tab wirklich neu laden
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
|
||||||
requestFinishedReload()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||||
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||||
}, [loadDoneCount, requestFinishedReload])
|
}, [loadDoneCount])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onNav = (ev: Event) => {
|
const onNav = (ev: Event) => {
|
||||||
|
|||||||
@ -352,6 +352,9 @@ export default function FinishedDownloads({
|
|||||||
const refillInFlightRef = React.useRef(false)
|
const refillInFlightRef = React.useRef(false)
|
||||||
const refillQueuedWhileInFlightRef = React.useRef(false)
|
const refillQueuedWhileInFlightRef = React.useRef(false)
|
||||||
|
|
||||||
|
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
|
||||||
|
const refillSessionRef = React.useRef(0)
|
||||||
|
|
||||||
type UndoAction =
|
type UndoAction =
|
||||||
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
|
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
|
||||||
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
|
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
|
||||||
@ -416,12 +419,6 @@ export default function FinishedDownloads({
|
|||||||
const [refillTick, setRefillTick] = React.useState(0)
|
const [refillTick, setRefillTick] = React.useState(0)
|
||||||
const refillTimerRef = React.useRef<number | null>(null)
|
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 countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
|
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
|
||||||
|
|
||||||
const emitCountHint = React.useCallback((delta: number) => {
|
const emitCountHint = React.useCallback((delta: number) => {
|
||||||
@ -552,6 +549,16 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
}, []) // nur einmal beim Mount
|
}, []) // nur einmal beim Mount
|
||||||
|
|
||||||
|
const queueRefill = useCallback(() => {
|
||||||
|
// ✅ Schon geplant? Dann nicht nochmal planen.
|
||||||
|
if (refillTimerRef.current != null) return
|
||||||
|
|
||||||
|
refillTimerRef.current = window.setTimeout(() => {
|
||||||
|
refillTimerRef.current = null
|
||||||
|
setRefillTick((n) => n + 1)
|
||||||
|
}, 80)
|
||||||
|
}, [page, pageSize, sortMode, includeKeep])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!effectiveAllMode) return
|
if (!effectiveAllMode) return
|
||||||
|
|
||||||
@ -573,19 +580,32 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
let alive = true
|
let alive = true
|
||||||
|
let finished = false // ✅ pro Effect-Instanz idempotent
|
||||||
|
|
||||||
|
// ✅ eigene Session-ID für diese Effect-Instanz
|
||||||
|
const mySession = ++refillSessionRef.current
|
||||||
|
|
||||||
const finishRefill = () => {
|
const finishRefill = () => {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
|
||||||
|
// ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern
|
||||||
|
if (refillSessionRef.current !== mySession) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
refillInFlightRef.current = false
|
refillInFlightRef.current = false
|
||||||
|
|
||||||
// ✅ Nachlauf-Reload ausführen, falls während des laufenden Refills ein Event kam
|
|
||||||
if (refillQueuedWhileInFlightRef.current) {
|
if (refillQueuedWhileInFlightRef.current) {
|
||||||
refillQueuedWhileInFlightRef.current = false
|
refillQueuedWhileInFlightRef.current = false
|
||||||
queueRefill()
|
queueRefill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Refill läuft
|
// ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
|
||||||
refillInFlightRef.current = true
|
if (refillSessionRef.current === mySession) {
|
||||||
|
refillInFlightRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
|
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
|
||||||
if (effectiveAllMode) {
|
if (effectiveAllMode) {
|
||||||
@ -615,23 +635,17 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
// 1) Liste + count holen
|
// 1) Liste + optional count in EINEM Request holen
|
||||||
const [listRes, metaRes] = await Promise.all([
|
const listRes = await fetch(
|
||||||
fetch(
|
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
|
||||||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
|
includeKeep ? '&includeKeep=1' : ''
|
||||||
includeKeep ? '&includeKeep=1' : ''
|
}`,
|
||||||
}`,
|
{ cache: 'no-store' as any, signal: ac.signal }
|
||||||
{ cache: 'no-store' as any, signal: ac.signal }
|
)
|
||||||
),
|
|
||||||
fetch(
|
|
||||||
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
|
|
||||||
{ cache: 'no-store' as any, signal: ac.signal }
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!alive || ac.signal.aborted) return
|
if (!alive || ac.signal.aborted) return
|
||||||
|
|
||||||
let okAll = listRes.ok && metaRes.ok
|
const okAll = listRes.ok
|
||||||
|
|
||||||
if (listRes.ok) {
|
if (listRes.ok) {
|
||||||
const data = await listRes.json().catch(() => null)
|
const data = await listRes.json().catch(() => null)
|
||||||
@ -644,13 +658,9 @@ export default function FinishedDownloads({
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
setOverrideDoneJobs(items)
|
setOverrideDoneJobs(items)
|
||||||
}
|
|
||||||
|
|
||||||
if (metaRes.ok) {
|
// ✅ Count direkt aus /done lesen (withCount=1)
|
||||||
const meta = await metaRes.json().catch(() => null)
|
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
|
||||||
if (!alive || ac.signal.aborted) return
|
|
||||||
|
|
||||||
const countRaw = Number(meta?.count ?? 0)
|
|
||||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||||
|
|
||||||
setOverrideDoneTotal(count)
|
setOverrideDoneTotal(count)
|
||||||
@ -1796,12 +1806,15 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onReload = () => {
|
const onReload = () => {
|
||||||
// ✅ Wenn gerade ein Refill läuft, Reload nicht verlieren, sondern merken
|
|
||||||
if (refillInFlightRef.current) {
|
if (refillInFlightRef.current) {
|
||||||
refillQueuedWhileInFlightRef.current = true
|
if (!refillQueuedWhileInFlightRef.current) {
|
||||||
|
refillQueuedWhileInFlightRef.current = true
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (refillTimerRef.current != null) return
|
||||||
|
|
||||||
queueRefill()
|
queueRefill()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2108,7 +2121,6 @@ export default function FinishedDownloads({
|
|||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
if (page !== 1) onPageChange(1)
|
if (page !== 1) onPageChange(1)
|
||||||
setIncludeKeep(checked)
|
setIncludeKeep(checked)
|
||||||
queueRefill()
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2267,7 +2279,6 @@ export default function FinishedDownloads({
|
|||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
if (page !== 1) onPageChange(1)
|
if (page !== 1) onPageChange(1)
|
||||||
setIncludeKeep(checked)
|
setIncludeKeep(checked)
|
||||||
queueRefill()
|
|
||||||
}}
|
}}
|
||||||
ariaLabel="Behaltene Downloads anzeigen"
|
ariaLabel="Behaltene Downloads anzeigen"
|
||||||
size="default"
|
size="default"
|
||||||
|
|||||||
@ -617,7 +617,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
? undefined
|
? undefined
|
||||||
: registerTeaserHost(k)
|
: registerTeaserHost(k)
|
||||||
}
|
}
|
||||||
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||||
onMouseEnter={
|
onMouseEnter={
|
||||||
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
||||||
}
|
}
|
||||||
@ -639,7 +639,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
||||||
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
|
'group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0 ' +
|
||||||
(inlineActive ? 'opacity-0' : 'opacity-100')
|
(inlineActive ? 'opacity-0' : 'opacity-100')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -462,7 +462,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
>
|
>
|
||||||
{/* Thumb */}
|
{/* Thumb */}
|
||||||
<div
|
<div
|
||||||
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||||
ref={registerTeaserHostIfNeeded(k)}
|
ref={registerTeaserHostIfNeeded(k)}
|
||||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
@ -566,7 +566,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Meta-Overlay im Video: unten rechts */}
|
{/* Meta-Overlay im Video: unten rechts */}
|
||||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover:opacity-0 group-focus-within:opacity-0">
|
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
|
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
|
||||||
|
|||||||
@ -141,6 +141,9 @@ export default function FinishedVideoPreview({
|
|||||||
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
|
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
|
||||||
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
|
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
|
||||||
|
|
||||||
|
// ✅ verhindert mehrfachen fallback-fetch pro Datei in derselben Komponenteninstanz
|
||||||
|
const fetchedMetaFilesRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
// ✅ merge statt "meta ?? fetchedMeta"
|
// ✅ merge statt "meta ?? fetchedMeta"
|
||||||
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
|
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
|
||||||
const metaForPreview = useMemo(() => {
|
const metaForPreview = useMemo(() => {
|
||||||
@ -150,6 +153,32 @@ export default function FinishedVideoPreview({
|
|||||||
return { ...meta, ...fetchedMeta }
|
return { ...meta, ...fetchedMeta }
|
||||||
}, [meta, fetchedMeta])
|
}, [meta, fetchedMeta])
|
||||||
|
|
||||||
|
// ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta)
|
||||||
|
const hasPreviewClipsInAnyMeta = useMemo(() => {
|
||||||
|
let pcs: any = (metaForPreview as any)?.previewClips
|
||||||
|
|
||||||
|
if (typeof pcs === 'string') {
|
||||||
|
const s = pcs.trim()
|
||||||
|
if (s.length > 0) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s)
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) return true
|
||||||
|
// falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden"
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(pcs) && pcs.length > 0) return true
|
||||||
|
|
||||||
|
const nested = (metaForPreview as any)?.preview?.clips
|
||||||
|
if (Array.isArray(nested) && nested.length > 0) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}, [metaForPreview])
|
||||||
|
|
||||||
const [progressMountTick, setProgressMountTick] = useState(0)
|
const [progressMountTick, setProgressMountTick] = useState(0)
|
||||||
|
|
||||||
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
|
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
|
||||||
@ -299,6 +328,10 @@ export default function FinishedVideoPreview({
|
|||||||
const [teaserReady, setTeaserReady] = useState(false)
|
const [teaserReady, setTeaserReady] = useState(false)
|
||||||
const [teaserOk, setTeaserOk] = useState(true)
|
const [teaserOk, setTeaserOk] = useState(true)
|
||||||
|
|
||||||
|
// ✅ verhindert doppelte Parent-Callbacks (z.B. Count/Meta-Refresh im Parent)
|
||||||
|
const emittedDurationRef = useRef<string>('')
|
||||||
|
const emittedResolutionRef = useRef<string>('')
|
||||||
|
|
||||||
// inView (Viewport)
|
// inView (Viewport)
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [inView, setInView] = useState(false)
|
const [inView, setInView] = useState(false)
|
||||||
@ -443,6 +476,12 @@ export default function FinishedVideoPreview({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTeaserReady(false)
|
setTeaserReady(false)
|
||||||
setTeaserOk(true)
|
setTeaserOk(true)
|
||||||
|
setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen
|
||||||
|
|
||||||
|
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
|
||||||
|
emittedDurationRef.current = ''
|
||||||
|
emittedResolutionRef.current = ''
|
||||||
|
setMetaLoaded(false)
|
||||||
}, [previewId, assetNonce, noGenerateTeaser])
|
}, [previewId, assetNonce, noGenerateTeaser])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -589,34 +628,50 @@ export default function FinishedVideoPreview({
|
|||||||
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
|
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
|
||||||
|
|
||||||
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
|
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
|
||||||
|
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let did = false
|
let did = false
|
||||||
|
|
||||||
if (onDuration && hasDuration) {
|
if (onDuration && hasDuration) {
|
||||||
onDuration(job, Number(effectiveDurationSec))
|
const secs = Number(effectiveDurationSec)
|
||||||
did = true
|
const durationKey = `${file}|dur|${secs}`
|
||||||
|
|
||||||
|
if (emittedDurationRef.current !== durationKey) {
|
||||||
|
emittedDurationRef.current = durationKey
|
||||||
|
onDuration(job, secs)
|
||||||
|
did = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onResolution && hasResolution) {
|
if (onResolution && hasResolution) {
|
||||||
onResolution(job, Number(effectiveW), Number(effectiveH))
|
const w = Number(effectiveW)
|
||||||
did = true
|
const h = Number(effectiveH)
|
||||||
|
const resKey = `${file}|res|${w}x${h}`
|
||||||
|
|
||||||
|
if (emittedResolutionRef.current !== resKey) {
|
||||||
|
emittedResolutionRef.current = resKey
|
||||||
|
onResolution(job, w, h)
|
||||||
|
did = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (did) setMetaLoaded(true)
|
if (did) setMetaLoaded(true)
|
||||||
}, [job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
|
}, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
|
||||||
|
|
||||||
|
|
||||||
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
|
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
|
||||||
|
// und pro Datei in dieser Komponenteninstanz nur 1x
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewId) return
|
if (!previewId) return
|
||||||
|
if (!file) return
|
||||||
if (!animated || animatedMode !== 'teaser') return
|
if (!animated || animatedMode !== 'teaser') return
|
||||||
if (!(shouldPreloadAnimatedAssets)) return
|
if (!shouldPreloadAnimatedAssets) return
|
||||||
|
|
||||||
const pcs = (meta as any)?.previewClips
|
// ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request
|
||||||
const hasPcs =
|
if (hasPreviewClipsInAnyMeta) return
|
||||||
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
|
|
||||||
|
|
||||||
if (hasPcs) return
|
// ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern
|
||||||
|
if (fetchedMetaFilesRef.current.has(file)) return
|
||||||
|
|
||||||
let aborted = false
|
let aborted = false
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
@ -636,15 +691,29 @@ export default function FinishedVideoPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
|
const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`)
|
||||||
if (!aborted && byFile) setFetchedMeta(byFile)
|
if (aborted) return
|
||||||
|
|
||||||
|
// ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt
|
||||||
|
fetchedMetaFilesRef.current.add(file)
|
||||||
|
|
||||||
|
if (byFile) {
|
||||||
|
setFetchedMeta(byFile)
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
aborted = true
|
aborted = true
|
||||||
ctrl.abort()
|
ctrl.abort()
|
||||||
}
|
}
|
||||||
}, [previewId, file, animated, animatedMode, meta, shouldPreloadAnimatedAssets])
|
}, [
|
||||||
|
previewId,
|
||||||
|
file,
|
||||||
|
animated,
|
||||||
|
animatedMode,
|
||||||
|
shouldPreloadAnimatedAssets,
|
||||||
|
hasPreviewClipsInAnyMeta,
|
||||||
|
])
|
||||||
|
|
||||||
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
|
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
|
||||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||||
@ -654,14 +723,24 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
if (onDuration && !hasDuration) {
|
if (onDuration && !hasDuration) {
|
||||||
const secs = Number(vv.duration)
|
const secs = Number(vv.duration)
|
||||||
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
|
if (Number.isFinite(secs) && secs > 0) {
|
||||||
|
const durationKey = `${file}|dur|${secs}`
|
||||||
|
if (emittedDurationRef.current !== durationKey) {
|
||||||
|
emittedDurationRef.current = durationKey
|
||||||
|
onDuration(job, secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onResolution && !hasResolution) {
|
if (onResolution && !hasResolution) {
|
||||||
const w = Number(vv.videoWidth)
|
const w = Number(vv.videoWidth)
|
||||||
const h = Number(vv.videoHeight)
|
const h = Number(vv.videoHeight)
|
||||||
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
||||||
onResolution(job, w, h)
|
const resKey = `${file}|res|${w}x${h}`
|
||||||
|
if (emittedResolutionRef.current !== resKey) {
|
||||||
|
emittedResolutionRef.current = resKey
|
||||||
|
onResolution(job, w, h)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user