updated
This commit is contained in:
parent
ca237ef2da
commit
ab3b55bcf8
Binary file not shown.
Binary file not shown.
789
backend/main.go
789
backend/main.go
File diff suppressed because it is too large
Load Diff
@ -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"})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
139
frontend/src/components/ui/GenerateAssetsTask.tsx
Normal file
139
frontend/src/components/ui/GenerateAssetsTask.tsx
Normal 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/<id>/</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
93
frontend/src/components/ui/ProgressBar.tsx
Normal file
93
frontend/src/components/ui/ProgressBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
17
frontend/src/components/ui/videoPolicy.ts
Normal file
17
frontend/src/components/ui/videoPolicy.ts
Normal 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')
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user