fixed live preview
This commit is contained in:
parent
548919cde2
commit
6337220de9
@ -473,16 +473,20 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
|
|||||||
fetchedAtNow := cbApplySnapshot(rooms)
|
fetchedAtNow := cbApplySnapshot(rooms)
|
||||||
|
|
||||||
if cbModelStore != nil {
|
if cbModelStore != nil {
|
||||||
// ✅ bekannten Store sofort auf aktuellen Snapshot ziehen
|
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
|
||||||
|
|
||||||
|
// Statusfelder pflegen (online/offline, room_status, last_online_at, ...)
|
||||||
syncChaturbateRoomStateIntoModelStore(
|
syncChaturbateRoomStateIntoModelStore(
|
||||||
cbModelStore,
|
cbModelStore,
|
||||||
append([]ChaturbateRoom(nil), rooms...),
|
copiedRooms,
|
||||||
fetchedAtNow,
|
fetchedAtNow,
|
||||||
)
|
)
|
||||||
|
|
||||||
// optional / best effort
|
// Vollständigen Snapshot pflegen, aber offline NICHT überschreiben
|
||||||
if len(rooms) > 0 {
|
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
|
||||||
go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
|
||||||
|
if len(copiedRooms) > 0 {
|
||||||
|
go cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -547,14 +551,16 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
|||||||
|
|
||||||
// ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren
|
// ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren
|
||||||
if cbModelStore != nil {
|
if cbModelStore != nil {
|
||||||
go syncChaturbateRoomStateIntoModelStore(
|
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
|
||||||
cbModelStore,
|
|
||||||
append([]ChaturbateRoom(nil), rooms...),
|
|
||||||
fetchedAtNow,
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(rooms) > 0 {
|
go func(roomsCopy []ChaturbateRoom, ts time.Time) {
|
||||||
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts)
|
||||||
|
|
||||||
|
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts)
|
||||||
|
}(copiedRooms, fetchedAtNow)
|
||||||
|
|
||||||
|
if len(copiedRooms) > 0 {
|
||||||
|
cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -686,17 +692,12 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
master, err := ParseStream(body) // -> hls_source
|
master, err := ParseStream(body) // hls_source
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
pl, err := FetchPlaylist(ctx, hc, master, cookie) // -> beste Variant
|
return strings.TrimSpace(master), nil
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(pl.PlaylistURL), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs.
|
// refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs.
|
||||||
|
|||||||
@ -202,17 +202,6 @@ func servePreviewHLSFileWithBase(w http.ResponseWriter, r *http.Request, id, fil
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// activation: hover or play=1
|
|
||||||
active := isHover(r) || strings.TrimSpace(r.URL.Query().Get("play")) == "1"
|
|
||||||
if !active {
|
|
||||||
if isIndex {
|
|
||||||
serveLiveNotReady(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, "preview not active", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ok || job == nil {
|
if !ok || job == nil {
|
||||||
if isIndex {
|
if isIndex {
|
||||||
serveLiveNotReady(w, r)
|
serveLiveNotReady(w, r)
|
||||||
@ -362,16 +351,10 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
|
|||||||
commonIn = append(commonIn, "-i", m3u8URL)
|
commonIn = append(commonIn, "-i", m3u8URL)
|
||||||
|
|
||||||
hqArgs := append(commonIn,
|
hqArgs := append(commonIn,
|
||||||
"-vf", "scale=480:-2",
|
|
||||||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-profile:v", "main",
|
|
||||||
"-level", "3.1",
|
|
||||||
"-threads", "4",
|
|
||||||
"-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
|
|
||||||
"-map", "0:v:0",
|
"-map", "0:v:0",
|
||||||
"-map", "0:a:0?",
|
"-map", "0:a:0?",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
|
"-c:v", "copy",
|
||||||
|
"-c:a", "copy",
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", "2",
|
"-hls_time", "2",
|
||||||
"-hls_list_size", "6",
|
"-hls_list_size", "6",
|
||||||
@ -485,7 +468,7 @@ func rewriteM3U8WithBase(raw []byte, id string, basePath string) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := path.Base(u)
|
name := path.Base(u)
|
||||||
out.WriteString(base + url.QueryEscape(name) + "&play=1")
|
out.WriteString(base + url.QueryEscape(name))
|
||||||
out.WriteByte('\n')
|
out.WriteByte('\n')
|
||||||
}
|
}
|
||||||
if err := sc.Err(); err != nil {
|
if err := sc.Err(); err != nil {
|
||||||
@ -518,7 +501,7 @@ func rewriteAttrURIWithBase(line, base string, basePath string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := path.Base(valTrim)
|
name := path.Base(valTrim)
|
||||||
repl := base + url.QueryEscape(name) + "&play=1"
|
repl := base + url.QueryEscape(name)
|
||||||
return line[:start] + repl + line[end:]
|
return line[:start] + repl + line[end:]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -591,13 +574,6 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// activation: hover or play=1 (wie bei HLS)
|
|
||||||
active := isHover(r) || strings.TrimSpace(r.URL.Query().Get("play")) == "1"
|
|
||||||
if !active {
|
|
||||||
http.Error(w, "preview not active", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job, ok := jobs[id]
|
job, ok := jobs[id]
|
||||||
state := ""
|
state := ""
|
||||||
@ -701,20 +677,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
args = append(args,
|
args = append(args,
|
||||||
"-map", "0:v:0",
|
"-map", "0:v:0",
|
||||||
"-map", "0:a:0?",
|
"-map", "0:a:0?",
|
||||||
"-vf", "scale=480:-2",
|
"-c:v", "copy",
|
||||||
"-c:v", "libx264",
|
"-c:a", "copy",
|
||||||
"-preset", "veryfast",
|
|
||||||
"-tune", "zerolatency",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-profile:v", "main",
|
|
||||||
"-level", "3.1",
|
|
||||||
"-g", "48",
|
|
||||||
"-keyint_min", "48",
|
|
||||||
"-sc_threshold", "0",
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", "128k",
|
|
||||||
"-ac", "2",
|
|
||||||
"-ar", "48000",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Output: fMP4 fragmented to stdout (single HTTP response)
|
// Output: fMP4 fragmented to stdout (single HTTP response)
|
||||||
|
|||||||
@ -1191,8 +1191,8 @@ func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom,
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
// q1 mit cb_online_last_error
|
// ONLINE: voller Snapshot wird gespeichert
|
||||||
stmt1, err1 := tx.Prepare(`
|
stmtOnline1, errOnline1 := tx.Prepare(`
|
||||||
UPDATE models
|
UPDATE models
|
||||||
SET
|
SET
|
||||||
last_seen_online = $1,
|
last_seen_online = $1,
|
||||||
@ -1213,10 +1213,9 @@ WHERE lower(trim(host)) = lower(trim($8))
|
|||||||
AND lower(trim(model_key)) = lower(trim($9));
|
AND lower(trim(model_key)) = lower(trim($9));
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// fallback ohne cb_online_last_error
|
var stmtOnline2 *sql.Stmt
|
||||||
var stmt2 *sql.Stmt
|
if errOnline1 != nil {
|
||||||
if err1 != nil {
|
stmtOnline2, err = tx.Prepare(`
|
||||||
stmt2, err = tx.Prepare(`
|
|
||||||
UPDATE models
|
UPDATE models
|
||||||
SET
|
SET
|
||||||
last_seen_online = $1,
|
last_seen_online = $1,
|
||||||
@ -1238,13 +1237,43 @@ WHERE lower(trim(host)) = lower(trim($7))
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stmt2.Close()
|
defer stmtOnline2.Close()
|
||||||
} else {
|
} else {
|
||||||
defer stmt1.Close()
|
defer stmtOnline1.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OFFLINE: nur Status updaten, Snapshot NICHT überschreiben
|
||||||
|
stmtOffline1, errOffline1 := tx.Prepare(`
|
||||||
|
UPDATE models
|
||||||
|
SET
|
||||||
|
last_seen_online = $1,
|
||||||
|
last_seen_online_at = $2,
|
||||||
|
cb_online_last_error = $3,
|
||||||
|
updated_at = $4
|
||||||
|
WHERE lower(trim(host)) = lower(trim($5))
|
||||||
|
AND lower(trim(model_key)) = lower(trim($6));
|
||||||
|
`)
|
||||||
|
|
||||||
|
var stmtOffline2 *sql.Stmt
|
||||||
|
if errOffline1 != nil {
|
||||||
|
stmtOffline2, err = tx.Prepare(`
|
||||||
|
UPDATE models
|
||||||
|
SET
|
||||||
|
last_seen_online = $1,
|
||||||
|
last_seen_online_at = $2,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE lower(trim(host)) = lower(trim($4))
|
||||||
|
AND lower(trim(model_key)) = lower(trim($5));
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmtOffline2.Close()
|
||||||
|
} else {
|
||||||
|
defer stmtOffline1.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
fetchedAtStr := fetchedAt.Format(time.RFC3339Nano)
|
|
||||||
|
|
||||||
for _, key := range knownKeys {
|
for _, key := range knownKeys {
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
@ -1252,18 +1281,10 @@ WHERE lower(trim(host)) = lower(trim($7))
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
online bool
|
|
||||||
snap *ChaturbateOnlineSnapshot
|
|
||||||
jsonStr string
|
|
||||||
imgURL string
|
|
||||||
)
|
|
||||||
|
|
||||||
if rm, ok := roomsByUser[strings.ToLower(key)]; ok {
|
if rm, ok := roomsByUser[strings.ToLower(key)]; ok {
|
||||||
online = true
|
imgURL := strings.TrimSpace(selectBestRoomImageURL(rm))
|
||||||
imgURL = strings.TrimSpace(selectBestRoomImageURL(rm))
|
|
||||||
|
|
||||||
snap = &ChaturbateOnlineSnapshot{
|
snap := &ChaturbateOnlineSnapshot{
|
||||||
Username: rm.Username,
|
Username: rm.Username,
|
||||||
DisplayName: rm.DisplayName,
|
DisplayName: rm.DisplayName,
|
||||||
CurrentShow: strings.TrimSpace(rm.CurrentShow),
|
CurrentShow: strings.TrimSpace(rm.CurrentShow),
|
||||||
@ -1284,24 +1305,15 @@ WHERE lower(trim(host)) = lower(trim($7))
|
|||||||
ChatRoomURLRS: rm.ChatRoomURLRS,
|
ChatRoomURLRS: rm.ChatRoomURLRS,
|
||||||
Tags: rm.Tags,
|
Tags: rm.Tags,
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
online = false
|
|
||||||
|
|
||||||
snap = &ChaturbateOnlineSnapshot{
|
jsonStr := ""
|
||||||
Username: key,
|
|
||||||
CurrentShow: "offline",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if snap != nil {
|
|
||||||
if b, err := json.Marshal(snap); err == nil {
|
if b, err := json.Marshal(snap); err == nil {
|
||||||
jsonStr = strings.TrimSpace(string(b))
|
jsonStr = strings.TrimSpace(string(b))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if stmt1 != nil {
|
if stmtOnline1 != nil {
|
||||||
if _, err := stmt1.Exec(
|
if _, err := stmtOnline1.Exec(
|
||||||
online,
|
true,
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
nullableStringArg(jsonStr),
|
nullableStringArg(jsonStr),
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
@ -1314,8 +1326,8 @@ WHERE lower(trim(host)) = lower(trim($7))
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, err := stmt2.Exec(
|
if _, err := stmtOnline2.Exec(
|
||||||
online,
|
true,
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
nullableStringArg(jsonStr),
|
nullableStringArg(jsonStr),
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
@ -1328,7 +1340,34 @@ WHERE lower(trim(host)) = lower(trim($7))
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = fetchedAtStr // falls du später Logging willst
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// OFFLINE:
|
||||||
|
// KEIN cb_online_json Update
|
||||||
|
// KEIN cb_online_fetched_at Update
|
||||||
|
if stmtOffline1 != nil {
|
||||||
|
if _, err := stmtOffline1.Exec(
|
||||||
|
false,
|
||||||
|
fetchedAt,
|
||||||
|
"",
|
||||||
|
now,
|
||||||
|
host,
|
||||||
|
key,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := stmtOffline2.Exec(
|
||||||
|
false,
|
||||||
|
fetchedAt,
|
||||||
|
now,
|
||||||
|
host,
|
||||||
|
key,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
|
|||||||
@ -84,7 +84,7 @@ func RecordStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
job.PreviewM3U8 = strings.TrimSpace(hlsURL)
|
||||||
job.PreviewCookie = httpCookie
|
job.PreviewCookie = httpCookie
|
||||||
job.PreviewUA = hc.userAgent
|
job.PreviewUA = hc.userAgent
|
||||||
if previewDir != "" {
|
if previewDir != "" {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
||||||
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
||||||
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
|
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
|
||||||
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
|
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
||||||
"ffmpegPath": "",
|
"ffmpegPath": "",
|
||||||
"autoAddToDownloadList": true,
|
"autoAddToDownloadList": true,
|
||||||
"autoStartAddedDownloads": true,
|
"autoStartAddedDownloads": true,
|
||||||
@ -10,8 +10,8 @@
|
|||||||
"maxConcurrentDownloads": 40,
|
"maxConcurrentDownloads": 40,
|
||||||
"useChaturbateApi": true,
|
"useChaturbateApi": true,
|
||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": false,
|
"autoDeleteSmallDownloads": true,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 50,
|
"autoDeleteSmallDownloadsBelowMB": 100,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "all",
|
"teaserPlayback": "all",
|
||||||
|
|||||||
@ -321,9 +321,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.MaxConcurrentDownloads < 1 {
|
if in.MaxConcurrentDownloads < 1 {
|
||||||
in.MaxConcurrentDownloads = 1
|
in.MaxConcurrentDownloads = 1
|
||||||
}
|
}
|
||||||
if in.MaxConcurrentDownloads > 100 {
|
|
||||||
in.MaxConcurrentDownloads = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
|
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
|
||||||
recAbs, err := resolvePathRelativeToApp(in.RecordDir)
|
recAbs, err := resolvePathRelativeToApp(in.RecordDir)
|
||||||
|
|||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"hls.js": "^1.6.15",
|
"flag-icons": "^7.5.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@ -2819,6 +2819,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flag-icons": {
|
||||||
|
"version": "7.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||||
|
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
@ -2933,12 +2939,6 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hls.js": {
|
|
||||||
"version": "1.6.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
|
||||||
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"hls.js": "^1.6.15",
|
"flag-icons": "^7.5.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import { useNotify } from './components/ui/notify'
|
|||||||
import CategoriesTab from './components/ui/CategoriesTab'
|
import CategoriesTab from './components/ui/CategoriesTab'
|
||||||
import LoginPage from './components/ui/LoginPage'
|
import LoginPage from './components/ui/LoginPage'
|
||||||
import VideoSplitModal from './components/ui/VideoSplitModal'
|
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||||||
|
import TextInput from './components/ui/TextInput'
|
||||||
|
|
||||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||||
|
|
||||||
@ -498,10 +499,6 @@ export default function App() {
|
|||||||
const doneCountInFlightRef = useRef(false)
|
const doneCountInFlightRef = useRef(false)
|
||||||
const doneCountLastAtRef = useRef(0)
|
const doneCountLastAtRef = useRef(0)
|
||||||
|
|
||||||
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
|
|
||||||
const finishedReloadTimerRef = useRef<number | null>(null)
|
|
||||||
const finishedReloadLastDispatchAtRef = useRef(0)
|
|
||||||
|
|
||||||
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
|
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
|
||||||
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
||||||
|
|
||||||
@ -563,14 +560,16 @@ export default function App() {
|
|||||||
|
|
||||||
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
||||||
|
|
||||||
const selectSourceUrl = useCallback(() => {
|
const INCLUDE_KEEP_KEY = 'finishedDownloads_includeKeep_v2'
|
||||||
const el = sourceUrlInputRef.current
|
|
||||||
if (!el) return
|
const [includeKeep, setIncludeKeep] = useState<boolean>(() => {
|
||||||
// Fokus sicherstellen, dann alles markieren
|
try {
|
||||||
el.focus()
|
const raw = window.localStorage.getItem(INCLUDE_KEEP_KEY)
|
||||||
// rAF, damit der Fokus sicher "sitzt" (und für Mobile/Safari stabiler)
|
return raw === '1' || raw === 'true' || raw === 'yes'
|
||||||
requestAnimationFrame(() => el.select())
|
} catch {
|
||||||
}, [])
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const checkAuth = useCallback(async () => {
|
const checkAuth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -684,7 +683,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const DONE_PAGE_SIZE = 8
|
const BASE_DONE_PAGE_SIZE = 6
|
||||||
|
const [donePageSize, setDonePageSize] = useState<number>(BASE_DONE_PAGE_SIZE)
|
||||||
|
|
||||||
type DoneSortMode =
|
type DoneSortMode =
|
||||||
| 'completed_desc'
|
| 'completed_desc'
|
||||||
@ -714,6 +714,11 @@ export default function App() {
|
|||||||
|
|
||||||
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
|
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
|
||||||
|
|
||||||
|
const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
|
||||||
|
const safeCount = Number.isFinite(count) && count > 0 ? count : 0
|
||||||
|
return Math.max(1, Math.ceil(safeCount / pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
|
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
|
||||||
if (pageToFetch < 1) return
|
if (pageToFetch < 1) return
|
||||||
if (donePrefetchInFlightRef.current) return
|
if (donePrefetchInFlightRef.current) return
|
||||||
@ -728,7 +733,9 @@ export default function App() {
|
|||||||
donePrefetchInFlightRef.current = true
|
donePrefetchInFlightRef.current = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/record/done?page=${pageToFetch}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
|
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${
|
||||||
|
includeKeep ? '&includeKeep=1' : ''
|
||||||
|
}`,
|
||||||
{ cache: 'no-store' as any }
|
{ cache: 'no-store' as any }
|
||||||
)
|
)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
@ -744,7 +751,7 @@ export default function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
donePrefetchInFlightRef.current = false
|
donePrefetchInFlightRef.current = false
|
||||||
}
|
}
|
||||||
}, [doneSort])
|
}, [doneSort, includeKeep])
|
||||||
|
|
||||||
const loadDoneCount = useCallback(async () => {
|
const loadDoneCount = useCallback(async () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -757,7 +764,10 @@ export default function App() {
|
|||||||
doneCountLastAtRef.current = now
|
doneCountLastAtRef.current = now
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
|
const res = await fetch(
|
||||||
|
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
|
||||||
|
{ cache: 'no-store' as any }
|
||||||
|
)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
const data = await res.json().catch(() => null)
|
const data = await res.json().catch(() => null)
|
||||||
@ -771,7 +781,7 @@ export default function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
doneCountInFlightRef.current = false
|
doneCountInFlightRef.current = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [includeKeep])
|
||||||
|
|
||||||
const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
|
const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
|
||||||
try {
|
try {
|
||||||
@ -783,12 +793,14 @@ export default function App() {
|
|||||||
setLastHeaderUpdateAtMs(Date.now())
|
setLastHeaderUpdateAtMs(Date.now())
|
||||||
|
|
||||||
donePrefetchRef.current = null
|
donePrefetchRef.current = null
|
||||||
void prefetchDonePage(pageToLoad + 1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/record/done?page=${pageToLoad}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(sortToLoad)}`,
|
`/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}${
|
||||||
|
includeKeep ? '&includeKeep=1' : ''
|
||||||
|
}`,
|
||||||
{ cache: 'no-store' as any }
|
{ cache: 'no-store' as any }
|
||||||
)
|
)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
@ -804,63 +816,24 @@ export default function App() {
|
|||||||
setDoneJobs(items)
|
setDoneJobs(items)
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
setLastHeaderUpdateAtMs(Date.now())
|
||||||
|
|
||||||
void prefetchDonePage(pageToLoad + 1)
|
const countRaw = Number(data?.count ?? data?.totalCount ?? doneCount)
|
||||||
|
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||||
|
|
||||||
|
if (count !== doneCount) {
|
||||||
|
setDoneCount(count)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [donePage, doneSort, prefetchDonePage])
|
}, [donePage, doneSort, prefetchDonePage, includeKeep, doneCount])
|
||||||
|
|
||||||
const requestFinishedReload = useCallback((reason = 'unknown') => {
|
|
||||||
const now = Date.now()
|
|
||||||
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
|
|
||||||
|
|
||||||
// Wenn wir nicht im Finished-Tab sind, keine Reload-Events feuern.
|
|
||||||
// (Count kann separat aktualisiert werden.)
|
|
||||||
if (selectedTabRef.current !== 'finished') return
|
|
||||||
|
|
||||||
// Bereits geplant -> nur koaleszieren
|
|
||||||
if (finishedReloadTimerRef.current != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Harte Dedupe-Schranke
|
|
||||||
const sinceLast = now - finishedReloadLastDispatchAtRef.current
|
|
||||||
if (sinceLast < cooldownMs) {
|
|
||||||
finishedReloadTimerRef.current = window.setTimeout(() => {
|
|
||||||
finishedReloadTimerRef.current = null
|
|
||||||
finishedReloadLastDispatchAtRef.current = Date.now()
|
|
||||||
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('finished-downloads:reload', {
|
|
||||||
detail: { source: `App.requestFinishedReload(cooldown-tail)`, reason },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, cooldownMs - sinceLast)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leichtes Coalescing für Burst im selben Moment
|
|
||||||
finishedReloadTimerRef.current = window.setTimeout(() => {
|
|
||||||
finishedReloadTimerRef.current = null
|
|
||||||
finishedReloadLastDispatchAtRef.current = Date.now()
|
|
||||||
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('finished-downloads:reload', {
|
|
||||||
detail: { source: 'App.requestFinishedReload', reason },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, 150)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
try {
|
||||||
if (finishedReloadTimerRef.current != null) {
|
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
|
||||||
window.clearTimeout(finishedReloadTimerRef.current)
|
} catch {
|
||||||
finishedReloadTimerRef.current = null
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}, [includeKeep])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -2347,14 +2320,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTab !== 'finished') return
|
if (selectedTab !== 'finished') return
|
||||||
|
|
||||||
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
|
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload('selectedTab effect')
|
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload('selectedTab visibilitychange')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('visibilitychange', onVis)
|
document.addEventListener('visibilitychange', onVis)
|
||||||
@ -2362,7 +2332,7 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', onVis)
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
}
|
}
|
||||||
}, [selectedTab, loadDoneCount, requestFinishedReload])
|
}, [selectedTab, loadDoneCount])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed) return
|
if (!authed) return
|
||||||
@ -2386,10 +2356,6 @@ export default function App() {
|
|||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
void loadJobs()
|
void loadJobs()
|
||||||
void loadTaskStateOnce()
|
void loadTaskStateOnce()
|
||||||
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
|
||||||
requestFinishedReload('sse fallback poll')
|
|
||||||
}
|
|
||||||
}, document.hidden ? 60000 : 5000)
|
}, document.hidden ? 60000 : 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2469,11 +2435,6 @@ export default function App() {
|
|||||||
if (state === 'done' || state === 'error' || state === 'missing') {
|
if (state === 'done' || state === 'error' || state === 'missing') {
|
||||||
bumpAssets()
|
bumpAssets()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Optional: Finished-Ansicht sichtbar? Dann Liste leicht anstoßen
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
|
||||||
requestFinishedReload(`finishedPostwork:${state}`)
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2528,10 +2489,6 @@ export default function App() {
|
|||||||
void loadJobs()
|
void loadJobs()
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
void loadTaskStateOnce()
|
void loadTaskStateOnce()
|
||||||
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
|
||||||
requestFinishedReload('visibilitychange')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVis)
|
document.addEventListener('visibilitychange', onVis)
|
||||||
@ -2559,7 +2516,7 @@ export default function App() {
|
|||||||
eventSourceRef.current = null
|
eventSourceRef.current = null
|
||||||
modelEventNamesRef.current = new Set()
|
modelEventNamesRef.current = new Set()
|
||||||
}
|
}
|
||||||
}, [authed, loadJobs, loadDoneCount, requestFinishedReload])
|
}, [authed, loadJobs, loadDoneCount])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const desired = new Set<string>()
|
const desired = new Set<string>()
|
||||||
@ -2830,7 +2787,7 @@ export default function App() {
|
|||||||
setDoneJobs((prev) => {
|
setDoneJobs((prev) => {
|
||||||
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
|
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
|
||||||
|
|
||||||
const need = DONE_PAGE_SIZE - filtered.length
|
const need = donePageSize - filtered.length
|
||||||
if (need <= 0) return filtered
|
if (need <= 0) return filtered
|
||||||
|
|
||||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
||||||
@ -2843,7 +2800,7 @@ export default function App() {
|
|||||||
const next: RecordJob[] = [...filtered]
|
const next: RecordJob[] = [...filtered]
|
||||||
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
|
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
|
||||||
|
|
||||||
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
|
while (next.length < donePageSize && buf.items.length > 0) {
|
||||||
const cand = buf.items.shift()!
|
const cand = buf.items.shift()!
|
||||||
const id = String(cand.id || baseName(cand.output || '')).trim()
|
const id = String(cand.id || baseName(cand.output || '')).trim()
|
||||||
if (!id || used.has(id)) continue
|
if (!id || used.has(id)) continue
|
||||||
@ -2862,11 +2819,14 @@ export default function App() {
|
|||||||
setJobs((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))
|
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||||
|
|
||||||
// ✅ Prefetch wieder nachfüllen
|
// ✅ Prefetch wieder nachfüllen – aber nur wenn es noch eine nächste Seite gibt
|
||||||
void prefetchDonePage(donePage + 1)
|
const nextDoneCount = Math.max(0, doneCount - 1)
|
||||||
|
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount)
|
||||||
|
|
||||||
|
if (donePage < totalPagesAfterDelete) {
|
||||||
|
void prefetchDonePage(donePage + 1)
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ wichtig
|
|
||||||
requestFinishedReload('delete success')
|
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
}, 320)
|
}, 320)
|
||||||
},
|
},
|
||||||
@ -2881,7 +2841,7 @@ export default function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage]
|
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage, doneCount]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleKeepJob = useCallback(
|
const handleKeepJob = useCallback(
|
||||||
@ -2900,7 +2860,7 @@ export default function App() {
|
|||||||
setDoneJobs((prev) => {
|
setDoneJobs((prev) => {
|
||||||
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
|
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
|
||||||
|
|
||||||
const need = DONE_PAGE_SIZE - filtered.length
|
const need = donePageSize - filtered.length
|
||||||
if (need <= 0) return filtered
|
if (need <= 0) return filtered
|
||||||
|
|
||||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
||||||
@ -2913,7 +2873,7 @@ export default function App() {
|
|||||||
const next: RecordJob[] = [...filtered]
|
const next: RecordJob[] = [...filtered]
|
||||||
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
|
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
|
||||||
|
|
||||||
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
|
while (next.length < donePageSize && buf.items.length > 0) {
|
||||||
const cand = buf.items.shift()!
|
const cand = buf.items.shift()!
|
||||||
const id = String(cand.id || baseName(cand.output || '')).trim()
|
const id = String(cand.id || baseName(cand.output || '')).trim()
|
||||||
if (!id || used.has(id)) continue
|
if (!id || used.has(id)) continue
|
||||||
@ -2932,11 +2892,14 @@ export default function App() {
|
|||||||
// ✅ Count runter
|
// ✅ Count runter
|
||||||
setDoneCount((c) => Math.max(0, c - 1))
|
setDoneCount((c) => Math.max(0, c - 1))
|
||||||
|
|
||||||
// ✅ Prefetch wieder nachfüllen
|
// ✅ Prefetch wieder nachfüllen – aber nur wenn es noch eine nächste Seite gibt
|
||||||
void prefetchDonePage(donePage + 1)
|
const nextDoneCount = Math.max(0, doneCount - 1)
|
||||||
|
const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount)
|
||||||
|
|
||||||
|
if (donePage < totalPagesAfterKeep) {
|
||||||
|
void prefetchDonePage(donePage + 1)
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Finished-Liste aktiv neu laden
|
|
||||||
requestFinishedReload('keep success')
|
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
}, 320)
|
}, 320)
|
||||||
},
|
},
|
||||||
@ -2954,8 +2917,8 @@ export default function App() {
|
|||||||
donePage,
|
donePage,
|
||||||
doneSort,
|
doneSort,
|
||||||
prefetchDonePage,
|
prefetchDonePage,
|
||||||
requestFinishedReload,
|
|
||||||
loadDoneCount,
|
loadDoneCount,
|
||||||
|
doneCount,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -3469,25 +3432,14 @@ export default function App() {
|
|||||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
|
<div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="sr-only">Source URL</label>
|
<label className="sr-only">Source URL</label>
|
||||||
<input
|
<TextInput
|
||||||
ref={sourceUrlInputRef}
|
ref={sourceUrlInputRef}
|
||||||
value={sourceUrl}
|
value={sourceUrl}
|
||||||
|
size='sm'
|
||||||
onChange={(e) => setSourceUrl(e.target.value)}
|
onChange={(e) => setSourceUrl(e.target.value)}
|
||||||
onMouseDown={(e) => {
|
selectAllOnFocus
|
||||||
// nur Linksklick
|
selectAllOnMouseDown
|
||||||
if (e.button !== 0) return
|
|
||||||
|
|
||||||
// wenn schon fokussiert: Browser soll Caret nicht irgendwohin setzen
|
|
||||||
// und wir markieren gleich alles
|
|
||||||
e.preventDefault()
|
|
||||||
selectSourceUrl()
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
// z.B. Tab-Navigation ins Feld
|
|
||||||
selectSourceUrl()
|
|
||||||
}}
|
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -3578,7 +3530,8 @@ export default function App() {
|
|||||||
doneJobs={doneJobs}
|
doneJobs={doneJobs}
|
||||||
doneTotal={doneCount}
|
doneTotal={doneCount}
|
||||||
page={donePage}
|
page={donePage}
|
||||||
pageSize={DONE_PAGE_SIZE}
|
pageSize={donePageSize}
|
||||||
|
onPageSizeChange={setDonePageSize}
|
||||||
onPageChange={setDonePage}
|
onPageChange={setDonePage}
|
||||||
onOpenPlayer={openPlayer}
|
onOpenPlayer={openPlayer}
|
||||||
onDeleteJob={handleDeleteJobWithUndo}
|
onDeleteJob={handleDeleteJobWithUndo}
|
||||||
@ -3600,6 +3553,12 @@ export default function App() {
|
|||||||
setDonePage(1)
|
setDonePage(1)
|
||||||
}}
|
}}
|
||||||
loadMode="paged"
|
loadMode="paged"
|
||||||
|
includeKeep={includeKeep}
|
||||||
|
onIncludeKeepChange={(checked) => {
|
||||||
|
setIncludeKeep(checked)
|
||||||
|
setDonePage(1)
|
||||||
|
donePrefetchRef.current = null
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@ -178,6 +178,17 @@ function effectiveRoomStatusOfJob(
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewAlignEndAtOfJob(job: RecordJob): string | null {
|
||||||
|
const postworkState = getEffectivePostworkState(job)
|
||||||
|
|
||||||
|
// Während Nachbearbeitung Preview nicht "abschalten"
|
||||||
|
if (postworkState === 'running' || postworkState === 'queued') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return job.endedAt ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const roomStatusTone = (status: string): string => {
|
const roomStatusTone = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Public':
|
case 'Public':
|
||||||
@ -951,7 +962,7 @@ export default function Downloads({
|
|||||||
growingByJobId
|
growingByJobId
|
||||||
)}
|
)}
|
||||||
alignStartAt={j.startedAt}
|
alignStartAt={j.startedAt}
|
||||||
alignEndAt={j.endedAt ?? null}
|
alignEndAt={previewAlignEndAtOfJob(j)}
|
||||||
alignEveryMs={10_000}
|
alignEveryMs={10_000}
|
||||||
fastRetryMs={1000}
|
fastRetryMs={1000}
|
||||||
fastRetryMax={25}
|
fastRetryMax={25}
|
||||||
@ -1388,7 +1399,10 @@ export default function Downloads({
|
|||||||
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
|
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 p-3">
|
<div className="p-3">
|
||||||
|
{/* Mobile */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Downloads
|
Downloads
|
||||||
@ -1398,7 +1412,83 @@ export default function Downloads({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-2 min-w-0">
|
{concurrentLimitHintVisible ? (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1',
|
||||||
|
'max-w-[55%] truncate',
|
||||||
|
concurrentLimitReached
|
||||||
|
? 'bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-100 dark:ring-amber-400/20'
|
||||||
|
: 'bg-blue-50 text-blue-900 ring-blue-200 dark:bg-blue-400/10 dark:text-blue-100 dark:ring-blue-400/20',
|
||||||
|
].join(' ')}
|
||||||
|
title={
|
||||||
|
concurrentLimitReached
|
||||||
|
? `Downloads: ${activeDownloadCount}/${maxConcurrentDownloads} · Limit erreicht`
|
||||||
|
: `Downloads: ${activeDownloadCount}/${maxConcurrentDownloads}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={watchedPaused ? 'secondary' : 'primary'}
|
||||||
|
disabled={watchedBusy || watchedPausedByDisk}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (watchedPausedByDisk) return
|
||||||
|
void (watchedPaused ? resumeWatched() : pauseWatched())
|
||||||
|
}}
|
||||||
|
className="w-full justify-center"
|
||||||
|
title={
|
||||||
|
watchedPausedByDisk
|
||||||
|
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
|
||||||
|
: watchedPaused
|
||||||
|
? 'Autostart fortsetzen'
|
||||||
|
: 'Autostart pausieren'
|
||||||
|
}
|
||||||
|
leadingIcon={
|
||||||
|
watchedPaused
|
||||||
|
? <PauseIcon className="size-4 shrink-0" />
|
||||||
|
: <PlayIcon className="size-4 shrink-0" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Autostart
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
disabled={stopAllBusy || stoppableIds.length === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void stopAll()
|
||||||
|
}}
|
||||||
|
className="w-full justify-center"
|
||||||
|
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
|
||||||
|
>
|
||||||
|
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stoppableIds.length})`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop */}
|
||||||
|
<div className="hidden sm:flex sm:items-center sm:justify-between sm:gap-2">
|
||||||
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Downloads
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
|
||||||
|
{totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
{concurrentLimitHintVisible ? (
|
{concurrentLimitHintVisible ? (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
@ -1428,7 +1518,6 @@ export default function Downloads({
|
|||||||
if (watchedPausedByDisk) return
|
if (watchedPausedByDisk) return
|
||||||
void (watchedPaused ? resumeWatched() : pauseWatched())
|
void (watchedPaused ? resumeWatched() : pauseWatched())
|
||||||
}}
|
}}
|
||||||
className="hidden sm:inline-flex"
|
|
||||||
title={
|
title={
|
||||||
watchedPausedByDisk
|
watchedPausedByDisk
|
||||||
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
|
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
|
||||||
@ -1454,7 +1543,6 @@ export default function Downloads({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
void stopAll()
|
void stopAll()
|
||||||
}}
|
}}
|
||||||
className="hidden sm:inline-flex"
|
|
||||||
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
|
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
|
||||||
>
|
>
|
||||||
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
|
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
|
||||||
@ -1463,6 +1551,7 @@ export default function Downloads({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
|
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -184,6 +184,31 @@ function effectiveRoomStatusOfJob(
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewRoomStatusOfJob(
|
||||||
|
job: RecordJob,
|
||||||
|
roomStatusByModelKey?: Record<string, string>,
|
||||||
|
modelsByKey?: Record<string, { roomStatus?: string }>,
|
||||||
|
growingByJobId?: Record<string, boolean>
|
||||||
|
): string {
|
||||||
|
const postworkState = getEffectivePostworkState(job)
|
||||||
|
|
||||||
|
if (postworkState === 'running' || postworkState === 'queued') {
|
||||||
|
return 'Public'
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAlignEndAtOfJob(job: RecordJob): string | null {
|
||||||
|
const postworkState = getEffectivePostworkState(job)
|
||||||
|
|
||||||
|
if (postworkState === 'running' || postworkState === 'queued') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return job.endedAt ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const roomStatusTone = (status: string): string => {
|
const roomStatusTone = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Public':
|
case 'Public':
|
||||||
@ -682,9 +707,14 @@ export default function DownloadsCardRow({
|
|||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={roomStatus}
|
roomStatus={previewRoomStatusOfJob(
|
||||||
|
j,
|
||||||
|
roomStatusByModelKey,
|
||||||
|
modelsByKey,
|
||||||
|
growingByJobId
|
||||||
|
)}
|
||||||
alignStartAt={j.startedAt}
|
alignStartAt={j.startedAt}
|
||||||
alignEndAt={j.endedAt ?? null}
|
alignEndAt={previewAlignEndAtOfJob(j)}
|
||||||
alignEveryMs={10_000}
|
alignEveryMs={10_000}
|
||||||
fastRetryMs={1000}
|
fastRetryMs={1000}
|
||||||
fastRetryMax={25}
|
fastRetryMax={25}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
521
frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx
Normal file
521
frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
// frontend\src\components\ui\FinishedDownloadsGalleryCard.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import type {
|
||||||
|
RecordJob,
|
||||||
|
GalleryModelFlags,
|
||||||
|
FinishedPostworkSummary,
|
||||||
|
} from '../../types'
|
||||||
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||||
|
import RecordJobActions from './RecordJobActions'
|
||||||
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
|
import { formatResolution } from './formatters'
|
||||||
|
import PreviewScrubber from './PreviewScrubber'
|
||||||
|
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||||
|
import {
|
||||||
|
shouldAnimateTeaser,
|
||||||
|
shouldEnableTeaserAudio,
|
||||||
|
} from './teaserPlayback'
|
||||||
|
import Checkbox from './Checkbox'
|
||||||
|
import {
|
||||||
|
buildSpriteFrameStyle,
|
||||||
|
parseJobMeta,
|
||||||
|
firstNonEmptyString,
|
||||||
|
readPreviewSpriteInfo,
|
||||||
|
scrubProgressRatioFromIndex,
|
||||||
|
} from './previewSprite'
|
||||||
|
|
||||||
|
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||||
|
return value > 24 * 60 * 60 ? value / 1000 : value
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
job: RecordJob
|
||||||
|
checked: boolean
|
||||||
|
hasAnySelection: boolean
|
||||||
|
isPreviewActive: boolean
|
||||||
|
forcePreviewMuted?: boolean
|
||||||
|
blurPreviews?: boolean
|
||||||
|
|
||||||
|
postworkByFile: Record<string, FinishedPostworkSummary>
|
||||||
|
getPostworkSummaryForOutput: (
|
||||||
|
output: string | undefined,
|
||||||
|
summaries: Record<string, FinishedPostworkSummary>
|
||||||
|
) => FinishedPostworkSummary | undefined
|
||||||
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||||
|
|
||||||
|
durations: Record<string, number>
|
||||||
|
teaserState: FinishedDownloadsTeaserState
|
||||||
|
|
||||||
|
keyFor: (j: RecordJob) => string
|
||||||
|
baseName: (p: string) => string
|
||||||
|
modelNameFromOutput: (output?: string) => string
|
||||||
|
runtimeOf: (job: RecordJob) => string
|
||||||
|
sizeBytesOf: (job: RecordJob) => number | null
|
||||||
|
formatBytes: (bytes?: number | null) => string
|
||||||
|
lower: (s: string) => string
|
||||||
|
|
||||||
|
modelsByKey: Record<string, GalleryModelFlags>
|
||||||
|
activeTagSet: Set<string>
|
||||||
|
|
||||||
|
deletingKeys: Set<string>
|
||||||
|
keepingKeys: Set<string>
|
||||||
|
removingKeys: Set<string>
|
||||||
|
|
||||||
|
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||||
|
|
||||||
|
onToggleSelected: (job: RecordJob) => void
|
||||||
|
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||||
|
onToggleTagFilter: (tag: string) => void
|
||||||
|
onOpenPlayer: (job: RecordJob) => void
|
||||||
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||||
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
|
|
||||||
|
contentPaddingClass: string
|
||||||
|
footerMinHeightClass: string
|
||||||
|
titleClass: string
|
||||||
|
sublineClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinishedDownloadsGalleryCardInner({
|
||||||
|
job: j,
|
||||||
|
checked,
|
||||||
|
hasAnySelection,
|
||||||
|
isPreviewActive,
|
||||||
|
forcePreviewMuted,
|
||||||
|
blurPreviews,
|
||||||
|
postworkByFile,
|
||||||
|
getPostworkSummaryForOutput,
|
||||||
|
renderPostworkBadge,
|
||||||
|
durations,
|
||||||
|
teaserState,
|
||||||
|
keyFor,
|
||||||
|
baseName,
|
||||||
|
modelNameFromOutput,
|
||||||
|
runtimeOf,
|
||||||
|
sizeBytesOf,
|
||||||
|
formatBytes,
|
||||||
|
lower,
|
||||||
|
modelsByKey,
|
||||||
|
activeTagSet,
|
||||||
|
deletingKeys,
|
||||||
|
keepingKeys,
|
||||||
|
removingKeys,
|
||||||
|
registerTeaserHost,
|
||||||
|
onToggleSelected,
|
||||||
|
onHoverPreviewKeyChange,
|
||||||
|
onToggleTagFilter,
|
||||||
|
onOpenPlayer,
|
||||||
|
handleDuration,
|
||||||
|
handleScrubberClickIndex,
|
||||||
|
deleteVideo,
|
||||||
|
keepVideo,
|
||||||
|
onToggleHot,
|
||||||
|
onToggleFavorite,
|
||||||
|
onToggleLike,
|
||||||
|
onToggleWatch,
|
||||||
|
onSplit,
|
||||||
|
onAddToDownloads,
|
||||||
|
jobForDetails,
|
||||||
|
contentPaddingClass,
|
||||||
|
footerMinHeightClass,
|
||||||
|
titleClass,
|
||||||
|
sublineClass,
|
||||||
|
}: Props) {
|
||||||
|
const k = keyFor(j)
|
||||||
|
|
||||||
|
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
|
||||||
|
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
|
||||||
|
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
|
||||||
|
setScrubIndexByKey((prev) => {
|
||||||
|
if (index === undefined) {
|
||||||
|
if (!(key in prev)) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
if (prev[key] === index) return prev
|
||||||
|
return { ...prev, [key]: index }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearScrubIndex = React.useCallback((key: string) => {
|
||||||
|
setScrubIndexForKey(key, undefined)
|
||||||
|
}, [setScrubIndexForKey])
|
||||||
|
|
||||||
|
const allowSound = shouldEnableTeaserAudio({
|
||||||
|
state: teaserState,
|
||||||
|
itemKey: k,
|
||||||
|
})
|
||||||
|
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||||
|
|
||||||
|
const model = modelNameFromOutput(j.output)
|
||||||
|
const modelKey = lower(model)
|
||||||
|
const flags = modelsByKey[modelKey]
|
||||||
|
|
||||||
|
const tags = React.useMemo(() => {
|
||||||
|
const s = String(flags?.tags ?? '').trim()
|
||||||
|
if (!s) return [] as string[]
|
||||||
|
const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const p of parts) {
|
||||||
|
const kk = p.toLowerCase()
|
||||||
|
if (seen.has(kk)) continue
|
||||||
|
seen.add(kk)
|
||||||
|
out.push(p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [flags?.tags])
|
||||||
|
|
||||||
|
const isFav = Boolean(flags?.favorite)
|
||||||
|
const isLiked = flags?.liked === true
|
||||||
|
const isWatching = Boolean(flags?.watching)
|
||||||
|
|
||||||
|
const fileRaw = baseName(j.output || '')
|
||||||
|
const isHot = isHotName(fileRaw)
|
||||||
|
const file = stripHotPrefix(fileRaw)
|
||||||
|
|
||||||
|
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
|
||||||
|
const showPostworkBadge = !!postworkBadge
|
||||||
|
|
||||||
|
const dur = runtimeOf(j)
|
||||||
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
|
|
||||||
|
const meta = React.useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta])
|
||||||
|
|
||||||
|
const resObj = React.useMemo(() => {
|
||||||
|
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
|
||||||
|
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
|
||||||
|
if (
|
||||||
|
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
|
||||||
|
typeof h === 'number' && Number.isFinite(h) && h > 0
|
||||||
|
) {
|
||||||
|
return { w, h }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [meta])
|
||||||
|
|
||||||
|
const resLabel = formatResolution(resObj)
|
||||||
|
|
||||||
|
const isRemoving = removingKeys.has(k)
|
||||||
|
const isDeleting = deletingKeys.has(k)
|
||||||
|
const isKeeping = keepingKeys.has(k)
|
||||||
|
const busy = isDeleting || isKeeping || isRemoving
|
||||||
|
|
||||||
|
const modelImageSrc = firstNonEmptyString(
|
||||||
|
flags?.image,
|
||||||
|
flags?.imageUrl,
|
||||||
|
(j as any)?.meta?.modelImage,
|
||||||
|
(j as any)?.meta?.modelImageUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
const sprite = React.useMemo(
|
||||||
|
() =>
|
||||||
|
readPreviewSpriteInfo((j as any)?.meta, {
|
||||||
|
fallbackPath: flags?.previewScrubberPath,
|
||||||
|
fallbackCount: flags?.previewScrubberCount,
|
||||||
|
}),
|
||||||
|
[(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount]
|
||||||
|
)
|
||||||
|
|
||||||
|
const spriteUrl = sprite.url
|
||||||
|
const hasScrubberUi = sprite.hasScrubberUi
|
||||||
|
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
||||||
|
const scrubberCount = sprite.count
|
||||||
|
const scrubberStepSeconds = sprite.stepSeconds
|
||||||
|
const hasScrubber = hasScrubberUi
|
||||||
|
|
||||||
|
const activeScrubIndex = scrubIndexByKey[k]
|
||||||
|
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
|
||||||
|
|
||||||
|
const spriteFrameStyle = React.useMemo(
|
||||||
|
() =>
|
||||||
|
buildSpriteFrameStyle({
|
||||||
|
spriteUrl,
|
||||||
|
spriteCols: sprite.cols,
|
||||||
|
spriteRows: sprite.rows,
|
||||||
|
spriteCount: sprite.count,
|
||||||
|
activeIndex: activeScrubIndex,
|
||||||
|
}),
|
||||||
|
[spriteUrl, sprite.cols, sprite.rows, sprite.count, activeScrubIndex]
|
||||||
|
)
|
||||||
|
|
||||||
|
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
|
||||||
|
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
|
||||||
|
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
|
||||||
|
|
||||||
|
const previewDurationSeconds =
|
||||||
|
durations[k] ??
|
||||||
|
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
||||||
|
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-finished-hover-key={k}
|
||||||
|
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (hasAnySelection) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleSelected(j)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onOpenPlayer(j)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
onHoverPreviewKeyChange?.(null)
|
||||||
|
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||||
|
clearScrubIndex(k)
|
||||||
|
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||||
|
'bg-white dark:bg-gray-900/40',
|
||||||
|
'transition-[transform,opacity,box-shadow,background-color] duration-180 ease-out',
|
||||||
|
'will-change-[transform,opacity]',
|
||||||
|
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||||
|
busy && !isRemoving && 'pointer-events-none opacity-70',
|
||||||
|
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||||
|
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||||
|
isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||||
|
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||||
|
].join(' ')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
|
||||||
|
onChange={() => onToggleSelected(j)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||||
|
ref={registerTeaserHost(k)}
|
||||||
|
onMouseEnter={() => setHoveredThumbKey(k)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||||
|
clearScrubIndex(k)
|
||||||
|
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<FinishedVideoPreview
|
||||||
|
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
|
||||||
|
job={j}
|
||||||
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
|
durationSeconds={previewDurationSeconds}
|
||||||
|
onDuration={handleDuration}
|
||||||
|
variant="fill"
|
||||||
|
showPopover={false}
|
||||||
|
blur={blurPreviews}
|
||||||
|
animated={shouldAnimateTeaser({
|
||||||
|
state: teaserState,
|
||||||
|
itemKey: k,
|
||||||
|
disabled: hideTeaserUnderOverlay,
|
||||||
|
})}
|
||||||
|
animatedMode="teaser"
|
||||||
|
animatedTrigger="always"
|
||||||
|
muted={previewMuted}
|
||||||
|
popoverMuted={previewMuted}
|
||||||
|
scrubProgressRatio={scrubProgressRatio}
|
||||||
|
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||||||
|
forceActive={isPreviewActive}
|
||||||
|
teaserPreloadEnabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSpriteScrubber && spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={spriteUrl}
|
||||||
|
alt=""
|
||||||
|
className="hidden"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
||||||
|
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||||
|
<div className="h-full w-full" style={spriteFrameStyle} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showModelPreviewInThumb && modelImageSrc ? (
|
||||||
|
<div className="absolute inset-0 z-[6]">
|
||||||
|
<img
|
||||||
|
src={modelImageSrc}
|
||||||
|
alt={model ? `${model} preview` : 'Model preview'}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
|
||||||
|
<div className="text-[10px] font-semibold tracking-wide text-white/95">
|
||||||
|
MODEL PREVIEW
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasScrubber && hoveredThumbKey === k ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<PreviewScrubber
|
||||||
|
className="pointer-events-auto px-1"
|
||||||
|
imageCount={scrubberCount}
|
||||||
|
activeIndex={activeScrubIndex}
|
||||||
|
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
|
||||||
|
onIndexClick={(index) => {
|
||||||
|
setScrubIndexForKey(k, index)
|
||||||
|
handleScrubberClickIndex(j, index, scrubberCount)
|
||||||
|
}}
|
||||||
|
stepSeconds={scrubberStepSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||||
|
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
|
||||||
|
>
|
||||||
|
<span>{dur}</span>
|
||||||
|
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||||
|
{resLabel ? <span>{resLabel}</span> : null}
|
||||||
|
<span aria-hidden="true">•</span>
|
||||||
|
<span>{size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'relative flex flex-col rounded-b-lg border-t border-black/5 bg-white dark:border-white/10 dark:bg-gray-900',
|
||||||
|
footerMinHeightClass,
|
||||||
|
contentPaddingClass,
|
||||||
|
].join(' ')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpenPlayer(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0">
|
||||||
|
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
|
||||||
|
{model}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPostworkBadge && renderPostworkBadge ? (
|
||||||
|
<div className="relative z-20 shrink-0">
|
||||||
|
{renderPostworkBadge(postworkBadge)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-0.5 flex min-h-[18px] items-start gap-2 min-w-0">
|
||||||
|
{isHot ? (
|
||||||
|
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||||||
|
HOT
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={['min-w-0 truncate text-gray-500 dark:text-gray-400', sublineClass].join(' ')}
|
||||||
|
title={stripHotPrefix(file) || '—'}
|
||||||
|
>
|
||||||
|
{stripHotPrefix(file) || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-2 shrink-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<RecordJobActions
|
||||||
|
job={jobForDetails(j)}
|
||||||
|
variant="table"
|
||||||
|
busy={busy}
|
||||||
|
collapseToMenu
|
||||||
|
compact={true}
|
||||||
|
isHot={isHot}
|
||||||
|
isFavorite={isFav}
|
||||||
|
isLiked={isLiked}
|
||||||
|
isWatching={isWatching}
|
||||||
|
onToggleWatch={onToggleWatch}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onToggleLike={onToggleLike}
|
||||||
|
onToggleHot={onToggleHot}
|
||||||
|
onKeep={keepVideo}
|
||||||
|
onDelete={deleteVideo}
|
||||||
|
onSplit={onSplit}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||||
|
className="w-full gap-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<TagOverflowRow
|
||||||
|
rowKey={k}
|
||||||
|
tags={tags}
|
||||||
|
activeTagSet={activeTagSet}
|
||||||
|
lower={lower}
|
||||||
|
onToggleTagFilter={onToggleTagFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FinishedDownloadsGalleryCard = React.memo(FinishedDownloadsGalleryCardInner)
|
||||||
|
export default FinishedDownloadsGalleryCard
|
||||||
@ -7,32 +7,18 @@ import type {
|
|||||||
GalleryModelFlags,
|
GalleryModelFlags,
|
||||||
FinishedPostworkSummary,
|
FinishedPostworkSummary,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
|
||||||
import RecordJobActions from './RecordJobActions'
|
|
||||||
import TagOverflowRow from './TagOverflowRow'
|
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
|
||||||
import { formatResolution } from './formatters'
|
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
|
||||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||||
import {
|
import {
|
||||||
shouldAnimateTeaser,
|
|
||||||
shouldEnableTeaserAudio,
|
|
||||||
shouldObserveTeasers,
|
shouldObserveTeasers,
|
||||||
} from './teaserPlayback'
|
} from './teaserPlayback'
|
||||||
import Checkbox from './Checkbox'
|
import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard'
|
||||||
import {
|
|
||||||
buildSpriteFrameStyle,
|
|
||||||
parseJobMeta,
|
|
||||||
firstNonEmptyString,
|
|
||||||
readPreviewSpriteInfo,
|
|
||||||
scrubProgressRatioFromIndex,
|
|
||||||
} from './previewSprite'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: RecordJob[]
|
rows: RecordJob[]
|
||||||
selectedKeys: Set<string>
|
selectedKeys: Set<string>
|
||||||
onToggleSelected: (job: RecordJob) => void
|
onToggleSelected: (job: RecordJob) => void
|
||||||
onToggleSelectAllPage: (checked: boolean) => void
|
onToggleSelectAllPage: (checked: boolean) => void
|
||||||
|
onColumnCountChange?: (count: number) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
durations: Record<string, number>
|
durations: Record<string, number>
|
||||||
@ -43,6 +29,7 @@ type Props = {
|
|||||||
) => FinishedPostworkSummary | undefined
|
) => FinishedPostworkSummary | undefined
|
||||||
teaserState: FinishedDownloadsTeaserState
|
teaserState: FinishedDownloadsTeaserState
|
||||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||||
|
cardScale?: number
|
||||||
|
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
|
||||||
@ -83,10 +70,15 @@ type Props = {
|
|||||||
forcePreviewMuted?: boolean
|
forcePreviewMuted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
function snapTo(value: number, step: number, base = 0) {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
if (!Number.isFinite(value)) return base
|
||||||
// ms -> s Heuristik wie in FinishedVideoPreview
|
if (!Number.isFinite(step) || step <= 0) return value
|
||||||
return value > 24 * 60 * 60 ? value / 1000 : value
|
const snapped = Math.round((value - base) / step) * step + base
|
||||||
|
return Number(snapped.toFixed(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedDownloadsGalleryView({
|
export default function FinishedDownloadsGalleryView({
|
||||||
@ -98,9 +90,10 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
getPostworkSummaryForOutput,
|
getPostworkSummaryForOutput,
|
||||||
teaserState,
|
teaserState,
|
||||||
renderPostworkBadge,
|
renderPostworkBadge,
|
||||||
|
cardScale,
|
||||||
|
onColumnCountChange,
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
onToggleSelected,
|
onToggleSelected,
|
||||||
|
|
||||||
handleDuration,
|
handleDuration,
|
||||||
handleScrubberClickIndex,
|
handleScrubberClickIndex,
|
||||||
jobForDetails,
|
jobForDetails,
|
||||||
@ -110,13 +103,10 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
runtimeOf,
|
runtimeOf,
|
||||||
sizeBytesOf,
|
sizeBytesOf,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
|
||||||
deletingKeys,
|
deletingKeys,
|
||||||
keepingKeys,
|
keepingKeys,
|
||||||
removingKeys,
|
removingKeys,
|
||||||
|
|
||||||
registerTeaserHost,
|
registerTeaserHost,
|
||||||
|
|
||||||
onHoverPreviewKeyChange,
|
onHoverPreviewKeyChange,
|
||||||
onOpenPlayer,
|
onOpenPlayer,
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
@ -133,11 +123,8 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
forcePreviewMuted,
|
forcePreviewMuted,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
|
||||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||||
const hasAnySelection = selectedKeys.size > 0
|
|
||||||
|
|
||||||
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
|
|
||||||
const registerTeaserHostIfNeeded = React.useCallback(
|
const registerTeaserHostIfNeeded = React.useCallback(
|
||||||
(key: string) => (el: HTMLDivElement | null) => {
|
(key: string) => (el: HTMLDivElement | null) => {
|
||||||
if (!observeTeasers) {
|
if (!observeTeasers) {
|
||||||
@ -149,444 +136,137 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
[registerTeaserHost, observeTeasers]
|
[registerTeaserHost, observeTeasers]
|
||||||
)
|
)
|
||||||
|
|
||||||
const parseTags = (raw?: string): string[] => {
|
const effectiveScaleRaw =
|
||||||
const s = String(raw ?? '').trim()
|
typeof cardScale === 'number'
|
||||||
if (!s) return []
|
? cardScale
|
||||||
const parts = s
|
: 1
|
||||||
.split(/[\n,;|]+/g)
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
const seen = new Set<string>()
|
const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35)
|
||||||
const out: string[] = []
|
|
||||||
for (const p of parts) {
|
|
||||||
const k = p.toLowerCase()
|
|
||||||
if (seen.has(k)) continue
|
|
||||||
seen.add(k)
|
|
||||||
out.push(p)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
|
const layoutScale = React.useMemo(
|
||||||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
() => snapTo(effectiveScale, 0.05, 0.8),
|
||||||
const meta = parseJobMeta((j as any)?.meta)
|
[effectiveScale]
|
||||||
|
|
||||||
const w =
|
|
||||||
meta?.media?.video?.width ??
|
|
||||||
meta?.file?.video?.width
|
|
||||||
|
|
||||||
const h =
|
|
||||||
meta?.media?.video?.height ??
|
|
||||||
meta?.file?.video?.height
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof w === 'number' &&
|
|
||||||
Number.isFinite(w) &&
|
|
||||||
w > 0 &&
|
|
||||||
typeof h === 'number' &&
|
|
||||||
Number.isFinite(h) &&
|
|
||||||
h > 0
|
|
||||||
) {
|
|
||||||
return { w, h }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet)
|
|
||||||
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
|
|
||||||
|
|
||||||
// ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte)
|
|
||||||
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
|
|
||||||
|
|
||||||
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
|
||||||
|
|
||||||
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
|
|
||||||
setScrubIndexByKey((prev) => {
|
|
||||||
if (index === undefined) {
|
|
||||||
if (!(key in prev)) return prev
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[key]
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev[key] === index) return prev
|
|
||||||
return { ...prev, [key]: index }
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const clearScrubIndex = React.useCallback((key: string) => {
|
|
||||||
setScrubIndexForKey(key, undefined)
|
|
||||||
}, [setScrubIndexForKey])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{rows.map((j) => {
|
|
||||||
const k = keyFor(j)
|
|
||||||
const checked = selectedKeys.has(k)
|
|
||||||
|
|
||||||
const isPreviewActive =
|
|
||||||
teaserState.activeKey === k || teaserState.hoverKey === k
|
|
||||||
|
|
||||||
// Sound nur bei Hover auf genau diesem Teaser
|
|
||||||
const allowSound = shouldEnableTeaserAudio({
|
|
||||||
state: teaserState,
|
|
||||||
itemKey: k,
|
|
||||||
})
|
|
||||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
|
||||||
|
|
||||||
const model = modelNameFromOutput(j.output)
|
|
||||||
const modelKey = lower(model)
|
|
||||||
const flags = modelsByKey[modelKey]
|
|
||||||
const isFav = Boolean(flags?.favorite)
|
|
||||||
const isLiked = flags?.liked === true
|
|
||||||
const isWatching = Boolean(flags?.watching)
|
|
||||||
|
|
||||||
const tags = parseTags(flags?.tags)
|
|
||||||
|
|
||||||
const fileRaw = baseName(j.output || '')
|
|
||||||
const isHot = isHotName(fileRaw)
|
|
||||||
const file = stripHotPrefix(fileRaw)
|
|
||||||
|
|
||||||
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
|
|
||||||
const showPostworkBadge = !!postworkBadge
|
|
||||||
|
|
||||||
const dur = runtimeOf(j)
|
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
|
||||||
|
|
||||||
const resObj = resolutionObjOf(j)
|
|
||||||
const resLabel = formatResolution(resObj)
|
|
||||||
|
|
||||||
const isRemoving = removingKeys.has(k)
|
|
||||||
const isDeleting = deletingKeys.has(k)
|
|
||||||
const isKeeping = keepingKeys.has(k)
|
|
||||||
const busy = isDeleting || isKeeping || isRemoving
|
|
||||||
|
|
||||||
// ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
|
|
||||||
const modelImageSrc = firstNonEmptyString(
|
|
||||||
flags?.image,
|
|
||||||
flags?.imageUrl,
|
|
||||||
(j as any)?.meta?.modelImage,
|
|
||||||
(j as any)?.meta?.modelImageUrl
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// meta robust lesen (Objekt oder JSON-String)
|
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const meta = parseJobMeta((j as any)?.meta)
|
const [containerWidth, setContainerWidth] = React.useState(0)
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
const gapPx = 12
|
||||||
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
|
|
||||||
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
const sprite = readPreviewSpriteInfo((j as any)?.meta, {
|
|
||||||
fallbackPath: flags?.previewScrubberPath,
|
|
||||||
fallbackCount: flags?.previewScrubberCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
const spriteUrl = sprite.url
|
const preferredCardWidth = React.useMemo(() => {
|
||||||
const hasScrubberUi = sprite.hasScrubberUi
|
const base = 240
|
||||||
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
return Math.round(base * layoutScale)
|
||||||
const scrubberCount = sprite.count
|
}, [layoutScale])
|
||||||
const scrubberStepSeconds = sprite.stepSeconds
|
|
||||||
const hasScrubber = hasScrubberUi
|
|
||||||
|
|
||||||
const activeScrubIndex = scrubIndexByKey[k]
|
const columnCount = React.useMemo(() => {
|
||||||
|
if (!containerWidth || containerWidth <= 0) {
|
||||||
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
|
return 1
|
||||||
|
|
||||||
// Sprite-Overlay-Frame (kein Request pro Move)
|
|
||||||
const spriteFrameStyle = buildSpriteFrameStyle({
|
|
||||||
spriteUrl,
|
|
||||||
spriteCols: sprite.cols,
|
|
||||||
spriteRows: sprite.rows,
|
|
||||||
spriteCount: sprite.count,
|
|
||||||
activeIndex: activeScrubIndex,
|
|
||||||
})
|
|
||||||
|
|
||||||
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
|
|
||||||
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
|
|
||||||
|
|
||||||
const hideTeaserUnderOverlay =
|
|
||||||
showModelPreviewInThumb || showScrubberSpriteInThumb
|
|
||||||
|
|
||||||
const previewDurationSeconds =
|
|
||||||
durations[k] ??
|
|
||||||
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
|
||||||
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={k} className="relative">
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
data-finished-hover-key={k}
|
|
||||||
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (hasAnySelection) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onToggleSelected(j)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpenPlayer(j)
|
const columnsThatFit = Math.floor(
|
||||||
}}
|
(containerWidth + gapPx) / (preferredCardWidth + gapPx)
|
||||||
onMouseEnter={() => {
|
)
|
||||||
onHoverPreviewKeyChange?.(k)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
onHoverPreviewKeyChange?.(null)
|
|
||||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
|
||||||
clearScrubIndex(k)
|
|
||||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
|
||||||
'bg-white dark:bg-gray-900/40',
|
|
||||||
'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
|
|
||||||
'will-change-[transform,opacity]',
|
|
||||||
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
|
||||||
busy && !isRemoving && 'pointer-events-none opacity-70',
|
|
||||||
isDeleting &&
|
|
||||||
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
|
||||||
isKeeping &&
|
|
||||||
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
|
||||||
isRemoving
|
|
||||||
? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
|
|
||||||
: 'opacity-100 translate-y-0 scale-100',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
|
||||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
|
||||||
].join(' ')}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
|
|
||||||
onChange={() => onToggleSelected(j)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumb */}
|
return Math.max(1, columnsThatFit)
|
||||||
|
}, [containerWidth, preferredCardWidth])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onColumnCountChange?.(columnCount)
|
||||||
|
}, [columnCount, onColumnCountChange])
|
||||||
|
|
||||||
|
const cardMinWidth = React.useMemo(() => {
|
||||||
|
return preferredCardWidth
|
||||||
|
}, [preferredCardWidth])
|
||||||
|
|
||||||
|
const gridStyle = React.useMemo<React.CSSProperties>(() => {
|
||||||
|
return {
|
||||||
|
['--gallery-card-min' as any]: `${cardMinWidth}px`,
|
||||||
|
}
|
||||||
|
}, [cardMinWidth])
|
||||||
|
|
||||||
|
const contentPaddingClass = 'px-3.5 py-3'
|
||||||
|
const footerMinHeightClass = 'min-h-[118px]'
|
||||||
|
const titleClass = 'text-sm'
|
||||||
|
const sublineClass = 'text-xs'
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
setContainerWidth(el.clientWidth || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
update()
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => update())
|
||||||
|
ro.observe(el)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full" ref={containerRef}>
|
||||||
<div
|
<div
|
||||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
className="grid w-full gap-3"
|
||||||
ref={registerTeaserHostIfNeeded(k)}
|
style={{
|
||||||
onMouseEnter={() => {
|
...gridStyle,
|
||||||
setHoveredThumbKey(k)
|
gridAutoFlow: 'row',
|
||||||
}}
|
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||||
onMouseLeave={() => {
|
|
||||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
|
||||||
clearScrubIndex(k)
|
|
||||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
{rows.map((j) => {
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
const k = keyFor(j)
|
||||||
<div className="absolute inset-0">
|
|
||||||
<FinishedVideoPreview
|
return (
|
||||||
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
|
<FinishedDownloadsGalleryCard
|
||||||
|
key={k}
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
checked={selectedKeys.has(k)}
|
||||||
durationSeconds={previewDurationSeconds}
|
hasAnySelection={selectedKeys.size > 0}
|
||||||
onDuration={handleDuration}
|
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
|
||||||
variant="fill"
|
forcePreviewMuted={forcePreviewMuted}
|
||||||
showPopover={false}
|
blurPreviews={blurPreviews}
|
||||||
blur={blurPreviews}
|
postworkByFile={postworkByFile}
|
||||||
animated={shouldAnimateTeaser({
|
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||||||
state: teaserState,
|
renderPostworkBadge={renderPostworkBadge}
|
||||||
itemKey: k,
|
durations={durations}
|
||||||
disabled: hideTeaserUnderOverlay,
|
teaserState={teaserState}
|
||||||
})}
|
keyFor={keyFor}
|
||||||
animatedMode="teaser"
|
baseName={baseName}
|
||||||
animatedTrigger="always"
|
modelNameFromOutput={modelNameFromOutput}
|
||||||
muted={previewMuted}
|
runtimeOf={runtimeOf}
|
||||||
popoverMuted={previewMuted}
|
sizeBytesOf={sizeBytesOf}
|
||||||
scrubProgressRatio={scrubProgressRatio}
|
formatBytes={formatBytes}
|
||||||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
lower={lower}
|
||||||
forceActive={isPreviewActive}
|
modelsByKey={modelsByKey}
|
||||||
teaserPreloadEnabled
|
activeTagSet={activeTagSet}
|
||||||
/>
|
deletingKeys={deletingKeys}
|
||||||
</div>
|
keepingKeys={keepingKeys}
|
||||||
|
removingKeys={removingKeys}
|
||||||
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
|
registerTeaserHost={registerTeaserHostIfNeeded}
|
||||||
{hasSpriteScrubber && spriteUrl ? (
|
onToggleSelected={onToggleSelected}
|
||||||
<img
|
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
|
||||||
src={spriteUrl}
|
onToggleTagFilter={onToggleTagFilter}
|
||||||
alt=""
|
onOpenPlayer={onOpenPlayer}
|
||||||
className="hidden"
|
handleDuration={handleDuration}
|
||||||
loading="lazy"
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||||
decoding="async"
|
deleteVideo={deleteVideo}
|
||||||
aria-hidden="true"
|
keepVideo={keepVideo}
|
||||||
/>
|
onToggleHot={onToggleHot}
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
|
|
||||||
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
|
||||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
|
||||||
<div
|
|
||||||
className="h-full w-full"
|
|
||||||
style={spriteFrameStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
|
|
||||||
{showModelPreviewInThumb && modelImageSrc ? (
|
|
||||||
<div className="absolute inset-0 z-[6]">
|
|
||||||
<img
|
|
||||||
src={modelImageSrc}
|
|
||||||
alt={model ? `${model} preview` : 'Model preview'}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
|
|
||||||
<div className="text-[10px] font-semibold tracking-wide text-white/95">
|
|
||||||
MODEL PREVIEW
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
|
|
||||||
{hasScrubber && hoveredThumbKey === k ? (
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<PreviewScrubber
|
|
||||||
className="pointer-events-auto px-1"
|
|
||||||
imageCount={scrubberCount}
|
|
||||||
activeIndex={activeScrubIndex}
|
|
||||||
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
|
|
||||||
onIndexClick={(index) => {
|
|
||||||
setScrubIndexForKey(k, index)
|
|
||||||
handleScrubberClickIndex(j, index, scrubberCount)
|
|
||||||
}}
|
|
||||||
stepSeconds={scrubberStepSeconds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Meta-Overlay im Video: unten rechts */}
|
|
||||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
|
||||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' • ')}
|
|
||||||
>
|
|
||||||
<span>{dur}</span>
|
|
||||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
|
||||||
{resLabel ? <span>{resLabel}</span> : null}
|
|
||||||
<span aria-hidden="true">•</span>
|
|
||||||
<span>{size}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer / Meta */}
|
|
||||||
<div
|
|
||||||
className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onOpenPlayer(j)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Model oben + Badge direkt dahinter */}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="mt-0.5 flex items-start justify-between gap-2 min-w-0">
|
|
||||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
{model}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showPostworkBadge && renderPostworkBadge ? (
|
|
||||||
<div className="relative z-20 shrink-0">
|
|
||||||
{renderPostworkBadge(postworkBadge)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
|
||||||
{isHot ? (
|
|
||||||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
|
||||||
HOT
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
|
|
||||||
title={stripHotPrefix(file) || '—'}
|
|
||||||
>
|
|
||||||
{stripHotPrefix(file) || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions (wie CardView: im Footer statt im Video) */}
|
|
||||||
<div
|
|
||||||
className="mt-2 shrink-0"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
||||||
<RecordJobActions
|
|
||||||
job={jobForDetails(j)}
|
|
||||||
variant="table"
|
|
||||||
busy={busy}
|
|
||||||
collapseToMenu
|
|
||||||
compact={true}
|
|
||||||
isHot={isHot}
|
|
||||||
isFavorite={isFav}
|
|
||||||
isLiked={isLiked}
|
|
||||||
isWatching={isWatching}
|
|
||||||
onToggleWatch={onToggleWatch}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleHot={onToggleHot}
|
onToggleWatch={onToggleWatch}
|
||||||
onKeep={keepVideo}
|
|
||||||
onDelete={deleteVideo}
|
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
onAddToDownloads={onAddToDownloads}
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
jobForDetails={jobForDetails}
|
||||||
className="w-full gap-1.5"
|
contentPaddingClass={contentPaddingClass}
|
||||||
|
footerMinHeightClass={footerMinHeightClass}
|
||||||
|
titleClass={titleClass}
|
||||||
|
sublineClass={sublineClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
||||||
<TagOverflowRow
|
|
||||||
rowKey={k}
|
|
||||||
tags={tags}
|
|
||||||
activeTagSet={activeTagSet}
|
|
||||||
lower={lower}
|
|
||||||
onToggleTagFilter={onToggleTagFilter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
|
import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
type ModalLayout = 'single' | 'split'
|
type ModalLayout = 'single' | 'split'
|
||||||
type ModalScroll = 'body' | 'right' | 'none'
|
type ModalScroll = 'body' | 'right' | 'none'
|
||||||
@ -12,7 +13,7 @@ type ModalScroll = 'body' | 'right' | 'none'
|
|||||||
type ModalProps = {
|
type ModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
title?: string
|
title?: ReactNode
|
||||||
titleRight?: ReactNode
|
titleRight?: ReactNode
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
@ -201,20 +202,17 @@ export default function Modal({
|
|||||||
<div className="shrink-0 flex items-center gap-2">
|
<div className="shrink-0 flex items-center gap-2">
|
||||||
{titleRight ? <div className="hidden sm:block">{titleRight}</div> : null}
|
{titleRight ? <div className="hidden sm:block">{titleRight}</div> : null}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
|
||||||
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
|
|
||||||
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
|
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
|
|
||||||
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
|
|
||||||
)}
|
|
||||||
aria-label="Schließen"
|
aria-label="Schließen"
|
||||||
title="Schließen"
|
title="Schließen"
|
||||||
|
className="shrink-0 px-2 min-w-0"
|
||||||
>
|
>
|
||||||
<XMarkIcon className="size-5" />
|
<XMarkIcon className="size-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -272,7 +270,7 @@ export default function Modal({
|
|||||||
{mobileCollapsedImageSrc ? (
|
{mobileCollapsedImageSrc ? (
|
||||||
<img
|
<img
|
||||||
src={mobileCollapsedImageSrc}
|
src={mobileCollapsedImageSrc}
|
||||||
alt={mobileCollapsedImageAlt || title || ''}
|
alt={mobileCollapsedImageAlt || (typeof title === 'string' ? title : '')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 rounded-lg object-cover ring-1 ring-black/5 dark:ring-white/10',
|
'shrink-0 rounded-lg object-cover ring-1 ring-black/5 dark:ring-white/10',
|
||||||
mobileCollapsed ? 'size-8' : 'size-10'
|
mobileCollapsed ? 'size-8' : 'size-10'
|
||||||
@ -299,20 +297,17 @@ export default function Modal({
|
|||||||
<div className="shrink-0 flex items-center gap-2">
|
<div className="shrink-0 flex items-center gap-2">
|
||||||
{titleRight ? <div>{titleRight}</div> : null}
|
{titleRight ? <div>{titleRight}</div> : null}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
|
||||||
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
|
|
||||||
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
|
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
|
|
||||||
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
|
|
||||||
)}
|
|
||||||
aria-label="Schließen"
|
aria-label="Schließen"
|
||||||
title="Schließen"
|
title="Schließen"
|
||||||
|
className="shrink-0 px-2 min-w-0"
|
||||||
>
|
>
|
||||||
<XMarkIcon className="size-5" />
|
<XMarkIcon className="size-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
171
frontend/src/components/ui/ModelGenderIcon.tsx
Normal file
171
frontend/src/components/ui/ModelGenderIcon.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
// frontend\src\components\ui\ModelGenderIcon.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenderKind = 'female' | 'male' | 'trans' | 'couple' | null
|
||||||
|
|
||||||
|
function normalizeGender(
|
||||||
|
gender?: string | null,
|
||||||
|
sex?: string | null,
|
||||||
|
subgender?: string | null
|
||||||
|
): GenderKind {
|
||||||
|
const raw = `${gender || ''} ${sex || ''} ${subgender || ''}`.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
if (raw.includes('couple') || raw.includes('pair')) {
|
||||||
|
return 'couple'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
raw.includes('trans') ||
|
||||||
|
raw.includes('transgender') ||
|
||||||
|
raw.includes('ts') ||
|
||||||
|
raw.includes('shemale')
|
||||||
|
) {
|
||||||
|
return 'trans'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
raw.includes('female') ||
|
||||||
|
raw.includes('girl') ||
|
||||||
|
raw.includes('woman')
|
||||||
|
) {
|
||||||
|
return 'female'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
raw.includes('male') ||
|
||||||
|
raw.includes('boy') ||
|
||||||
|
raw.includes('man')
|
||||||
|
) {
|
||||||
|
return 'male'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function BaseIcon({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('size-3.5', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FemaleIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon className={className}>
|
||||||
|
<circle cx="12" cy="8" r="4.25" />
|
||||||
|
<path d="M12 12.25V20" />
|
||||||
|
<path d="M9 17H15" />
|
||||||
|
</BaseIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaleIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon className={className}>
|
||||||
|
<circle cx="9" cy="15" r="4.25" />
|
||||||
|
<path d="M12 12L19 5" />
|
||||||
|
<path d="M14.75 5H19V9.25" />
|
||||||
|
</BaseIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon className={className}>
|
||||||
|
<circle cx="10.5" cy="10.5" r="3.75" />
|
||||||
|
<path d="M13.25 7.75L18.5 2.5" />
|
||||||
|
<path d="M15.75 2.5H18.5V5.25" />
|
||||||
|
<path d="M10.5 14.25V21" />
|
||||||
|
<path d="M7.75 18.25H13.25" />
|
||||||
|
<path d="M6.5 10.5H2.75" />
|
||||||
|
<path d="M4.5 8.75L2.75 10.5L4.5 12.25" />
|
||||||
|
</BaseIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoupleIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon className={className}>
|
||||||
|
<circle cx="8" cy="9" r="3" />
|
||||||
|
<circle cx="16" cy="9" r="3" />
|
||||||
|
<path d="M3.75 18.5C4.5 15.75 6 14.5 8 14.5C10 14.5 11.5 15.75 12.25 18.5" />
|
||||||
|
<path d="M11.75 18.5C12.5 15.75 14 14.5 16 14.5C18 14.5 19.5 15.75 20.25 18.5" />
|
||||||
|
</BaseIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
gender?: string | null
|
||||||
|
sex?: string | null
|
||||||
|
subgender?: string | null
|
||||||
|
variant?: 'hero' | 'default'
|
||||||
|
className?: string
|
||||||
|
iconClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelGenderIcon({
|
||||||
|
gender,
|
||||||
|
sex,
|
||||||
|
subgender,
|
||||||
|
variant = 'default',
|
||||||
|
className,
|
||||||
|
iconClassName,
|
||||||
|
}: Props) {
|
||||||
|
const kind = normalizeGender(gender, sex, subgender)
|
||||||
|
|
||||||
|
if (!kind) return null
|
||||||
|
|
||||||
|
const map: Record<Exclude<GenderKind, null>, { label: string; Icon: React.ComponentType<IconProps> }> = {
|
||||||
|
female: { label: 'Female', Icon: FemaleIcon },
|
||||||
|
male: { label: 'Male', Icon: MaleIcon },
|
||||||
|
trans: { label: 'Trans', Icon: TransIcon },
|
||||||
|
couple: { label: 'Couple', Icon: CoupleIcon },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, Icon } = map[kind]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center justify-center rounded-full ring-1 ring-inset',
|
||||||
|
variant === 'hero'
|
||||||
|
? 'size-5 bg-black/30 text-white ring-white/20 backdrop-blur'
|
||||||
|
: 'size-5 bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-white dark:ring-white/15',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<Icon className={iconClassName} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -290,7 +290,7 @@ export default function ModelPreview({
|
|||||||
setPopupVolume(nextVolume)
|
setPopupVolume(nextVolume)
|
||||||
setPopupMuted(nextMuted)
|
setPopupMuted(nextMuted)
|
||||||
}}
|
}}
|
||||||
className="w-full h-full object-contain object-bottom relative z-0"
|
className="w-full h-full object-contain object-center relative z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showLiveBadge ? (
|
{showLiveBadge ? (
|
||||||
@ -303,7 +303,7 @@ export default function ModelPreview({
|
|||||||
<div className="absolute right-2 bottom-2 z-[60]">
|
<div className="absolute right-2 bottom-2 z-[60]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-black/65 px-2.5 py-1.5 text-white shadow-sm ring-1 ring-white/10 hover:bg-black/75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
|
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-white/90 px-2.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-black/10 hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500/70 dark:bg-black/55 dark:text-white dark:ring-white/10 dark:hover:bg-black/70 dark:focus-visible:outline-white/70"
|
||||||
title={popupMuted ? 'Ton an' : 'Stumm'}
|
title={popupMuted ? 'Ton an' : 'Stumm'}
|
||||||
aria-label={popupMuted ? 'Ton an' : 'Stumm'}
|
aria-label={popupMuted ? 'Ton an' : 'Stumm'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@ -308,9 +308,7 @@ export default function Player({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
|
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
|
||||||
const isRunning = job.status === 'running'
|
const isLive = job.status === 'running'
|
||||||
const [hlsReady, setHlsReady] = React.useState(false)
|
|
||||||
const isLive = isRunning && hlsReady
|
|
||||||
|
|
||||||
const [liveMuted, setLiveMuted] = React.useState(startMuted)
|
const [liveMuted, setLiveMuted] = React.useState(startMuted)
|
||||||
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
|
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
|
||||||
@ -321,12 +319,12 @@ export default function Player({
|
|||||||
const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
|
const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
|
||||||
|
|
||||||
const previewId = React.useMemo(
|
const previewId = React.useMemo(
|
||||||
() => (isRunning ? job.id : finishedStem || job.id),
|
() => (isLive ? job.id : finishedStem || job.id),
|
||||||
[isRunning, job.id, finishedStem]
|
[isLive, job.id, finishedStem]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isRunning) return
|
if (isLive) return
|
||||||
if (fullDurationSec > 0) return
|
if (fullDurationSec > 0) return
|
||||||
|
|
||||||
const fileName = baseName(job.output?.trim() || '')
|
const fileName = baseName(job.output?.trim() || '')
|
||||||
@ -365,7 +363,7 @@ export default function Player({
|
|||||||
alive = false
|
alive = false
|
||||||
ctrl.abort()
|
ctrl.abort()
|
||||||
}
|
}
|
||||||
}, [isRunning, fullDurationSec, job.output])
|
}, [isLive, fullDurationSec, job.output])
|
||||||
|
|
||||||
const isHotFile = fileRaw.startsWith('HOT ')
|
const isHotFile = fileRaw.startsWith('HOT ')
|
||||||
const model = React.useMemo(() => {
|
const model = React.useMemo(() => {
|
||||||
@ -455,56 +453,6 @@ export default function Player({
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
const hlsIndexUrl = React.useMemo(() => {
|
|
||||||
const u = `/api/preview?id=${encodeURIComponent(previewId)}&play=1`
|
|
||||||
return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
|
|
||||||
}, [previewId, isRunning])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isRunning) {
|
|
||||||
setHlsReady(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let alive = true
|
|
||||||
const ctrl = new AbortController()
|
|
||||||
setHlsReady(false)
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
for (let i = 0; i < 120 && alive && !ctrl.signal.aborted; i++) {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch(hlsIndexUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: ctrl.signal,
|
|
||||||
headers: { 'cache-control': 'no-cache' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const text = await res.text()
|
|
||||||
|
|
||||||
// ✅ muss wirklich wie eine m3u8 aussehen und mindestens 1 Segment enthalten
|
|
||||||
const hasM3u = text.includes('#EXTM3U')
|
|
||||||
const hasSegment =
|
|
||||||
/#EXTINF:/i.test(text) || /\.ts(\?|$)/i.test(text) || /\.m4s(\?|$)/i.test(text)
|
|
||||||
|
|
||||||
if (hasM3u && hasSegment) {
|
|
||||||
if (alive) setHlsReady(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
poll()
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
ctrl.abort()
|
|
||||||
}
|
|
||||||
}, [isRunning, hlsIndexUrl])
|
|
||||||
|
|
||||||
const buildVideoSrc = React.useCallback(
|
const buildVideoSrc = React.useCallback(
|
||||||
(params: { file?: string; id?: string }) => {
|
(params: { file?: string; id?: string }) => {
|
||||||
const qp = new URLSearchParams()
|
const qp = new URLSearchParams()
|
||||||
@ -517,7 +465,7 @@ export default function Player({
|
|||||||
|
|
||||||
const media = React.useMemo(() => {
|
const media = React.useMemo(() => {
|
||||||
// ✅ Live wird NICHT mehr über Video.js gespielt
|
// ✅ Live wird NICHT mehr über Video.js gespielt
|
||||||
if (isRunning) return { src: '', type: '' }
|
if (isLive) return { src: '', type: '' }
|
||||||
|
|
||||||
// ✅ Warten bis meta.json existiert + Infos geladen
|
// ✅ Warten bis meta.json existiert + Infos geladen
|
||||||
if (!metaReady) return { src: '', type: '' }
|
if (!metaReady) return { src: '', type: '' }
|
||||||
@ -532,7 +480,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' }
|
return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' }
|
||||||
}, [isRunning, metaReady, job.output, job.id, buildVideoSrc])
|
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
|
||||||
|
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const playerRef = React.useRef<VideoJsPlayer | null>(null)
|
const playerRef = React.useRef<VideoJsPlayer | null>(null)
|
||||||
@ -612,7 +560,7 @@ export default function Player({
|
|||||||
const appliedStartSeekRef = React.useRef<string>('')
|
const appliedStartSeekRef = React.useRef<string>('')
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isRunning) {
|
if (isLive) {
|
||||||
setMetaReady(true)
|
setMetaReady(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -679,7 +627,7 @@ export default function Player({
|
|||||||
alive = false
|
alive = false
|
||||||
ctrl.abort()
|
ctrl.abort()
|
||||||
}
|
}
|
||||||
}, [isRunning, playbackKey, job.output])
|
}, [isLive, playbackKey, job.output])
|
||||||
|
|
||||||
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
|
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
|
||||||
const [, setVvTick] = React.useState(0)
|
const [, setVvTick] = React.useState(0)
|
||||||
@ -790,7 +738,7 @@ export default function Player({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const p: any = playerRef.current
|
const p: any = playerRef.current
|
||||||
if (!p || p.isDisposed?.()) return
|
if (!p || p.isDisposed?.()) return
|
||||||
if (isRunning) return // live nutzt Video.js nicht
|
if (isLive) return // live nutzt Video.js nicht
|
||||||
|
|
||||||
installAbsoluteTimelineShim(p)
|
installAbsoluteTimelineShim(p)
|
||||||
|
|
||||||
@ -821,13 +769,13 @@ export default function Player({
|
|||||||
delete p.__serverSeekAbs
|
delete p.__serverSeekAbs
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}, [job.output, isRunning])
|
}, [job.output, isLive])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (!containerRef.current) return
|
if (!containerRef.current) return
|
||||||
if (playerRef.current) return
|
if (playerRef.current) return
|
||||||
if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
|
if (isLive) return // ✅ neu: für Live keinen Video.js mounten
|
||||||
if (!metaReady) return
|
if (!metaReady) return
|
||||||
|
|
||||||
const videoEl = document.createElement('video')
|
const videoEl = document.createElement('video')
|
||||||
@ -896,7 +844,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [mounted, startMuted, isRunning, metaReady, videoH, updateIntrinsicDims])
|
}, [mounted, startMuted, isLive, metaReady, videoH, updateIntrinsicDims])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
@ -944,7 +892,7 @@ export default function Player({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (!isRunning && !metaReady) {
|
if (!isLive && !metaReady) {
|
||||||
releaseMedia()
|
releaseMedia()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1025,11 +973,11 @@ export default function Player({
|
|||||||
})
|
})
|
||||||
|
|
||||||
tryPlay()
|
tryPlay()
|
||||||
}, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
|
}, [mounted, isLive, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (isRunning) return // Live spielt nicht über Video.js
|
if (isLive) return // Live spielt nicht über Video.js
|
||||||
if (!metaReady) return
|
if (!metaReady) return
|
||||||
if (!media.src) return
|
if (!media.src) return
|
||||||
|
|
||||||
@ -1100,7 +1048,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
mounted,
|
mounted,
|
||||||
isRunning,
|
isLive,
|
||||||
metaReady,
|
metaReady,
|
||||||
media.src,
|
media.src,
|
||||||
playbackKey,
|
playbackKey,
|
||||||
@ -1108,23 +1056,6 @@ export default function Player({
|
|||||||
seekPlayerToAbsolute,
|
seekPlayerToAbsolute,
|
||||||
])
|
])
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!mounted) return
|
|
||||||
const p = playerRef.current
|
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
|
||||||
|
|
||||||
const onErr = () => {
|
|
||||||
if (job.status === 'running') setHlsReady(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.on('error', onErr)
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
p.off('error', onErr)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}, [mounted, job.status])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
@ -1226,7 +1157,7 @@ export default function Player({
|
|||||||
if (typeof window === 'undefined') return r
|
if (typeof window === 'undefined') return r
|
||||||
|
|
||||||
const ratio = getVideoAspectRatio()
|
const ratio = getVideoAspectRatio()
|
||||||
const BAR_H = isRunning ? 0 : 30 // gewünschter fixer Platz unter dem Video für die Controlbar
|
const BAR_H = isLive ? 0 : 30 // gewünschter fixer Platz unter dem Video für die Controlbar
|
||||||
|
|
||||||
const { w: vw, h: vh } = getViewport()
|
const { w: vw, h: vh } = getViewport()
|
||||||
const maxW = vw - MARGIN * 2
|
const maxW = vw - MARGIN * 2
|
||||||
@ -1261,7 +1192,7 @@ export default function Player({
|
|||||||
|
|
||||||
return { x, y, w: Math.round(w), h }
|
return { x, y, w: Math.round(w), h }
|
||||||
},
|
},
|
||||||
[getVideoAspectRatio, isRunning]
|
[getVideoAspectRatio, isLive]
|
||||||
)
|
)
|
||||||
|
|
||||||
const loadRect = React.useCallback(() => {
|
const loadRect = React.useCallback(() => {
|
||||||
@ -1642,11 +1573,11 @@ export default function Player({
|
|||||||
const phaseRaw = String((job as any).phase ?? '')
|
const phaseRaw = String((job as any).phase ?? '')
|
||||||
const phase = phaseRaw.toLowerCase()
|
const phase = phaseRaw.toLowerCase()
|
||||||
const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving'
|
const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving'
|
||||||
const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending
|
const stopDisabled = !onStopJob || !isLive || isStoppingLike || stopPending
|
||||||
|
|
||||||
const footerRight = (
|
const footerRight = (
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{isRunning ? (
|
{isLive ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@ -1747,7 +1678,7 @@ export default function Player({
|
|||||||
|
|
||||||
const fullSize = expanded || miniDesktop
|
const fullSize = expanded || miniDesktop
|
||||||
|
|
||||||
const metaBottom = isRunning
|
const metaBottom = isLive
|
||||||
? `calc(4px + env(safe-area-inset-bottom))`
|
? `calc(4px + env(safe-area-inset-bottom))`
|
||||||
: `calc(${controlBarH + 2}px + env(safe-area-inset-bottom))`
|
: `calc(${controlBarH + 2}px + env(safe-area-inset-bottom))`
|
||||||
|
|
||||||
@ -1772,7 +1703,7 @@ export default function Player({
|
|||||||
className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}
|
className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}
|
||||||
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
|
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
|
||||||
>
|
>
|
||||||
{isRunning ? (
|
{isLive ? (
|
||||||
<div className="absolute inset-0 bg-black">
|
<div className="absolute inset-0 bg-black">
|
||||||
<LiveVideo
|
<LiveVideo
|
||||||
src={liveHlsSrc}
|
src={liveHlsSrc}
|
||||||
@ -1782,7 +1713,7 @@ export default function Player({
|
|||||||
setLiveVolume(nextVolume)
|
setLiveVolume(nextVolume)
|
||||||
setLiveMuted(nextMuted)
|
setLiveMuted(nextMuted)
|
||||||
}}
|
}}
|
||||||
className="w-full h-full object-contain object-bottom"
|
className="w-full h-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
|
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
|
||||||
@ -1899,7 +1830,7 @@ export default function Player({
|
|||||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{resolutionLabel}</span>
|
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{resolutionLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isRunning ? (
|
{!isLive ? (
|
||||||
<>
|
<>
|
||||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
|
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
|
||||||
{runtimeLabel}
|
{runtimeLabel}
|
||||||
@ -1945,7 +1876,7 @@ export default function Player({
|
|||||||
|
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<div className="flex items-center justify-center gap-2 flex-wrap">
|
<div className="flex items-center justify-center gap-2 flex-wrap">
|
||||||
{isRunning ? (
|
{isLive ? (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
color="red"
|
color="red"
|
||||||
@ -2020,7 +1951,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
order={isRunning ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
|
order={isLive ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
|
||||||
className="flex items-center justify-start gap-1"
|
className="flex items-center justify-start gap-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2120,7 +2051,7 @@ export default function Player({
|
|||||||
{resolutionLabel !== '—' ? (
|
{resolutionLabel !== '—' ? (
|
||||||
<span className="rounded bg-black/45 px-1.5 py-0.5">{resolutionLabel}</span>
|
<span className="rounded bg-black/45 px-1.5 py-0.5">{resolutionLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{!isRunning && runtimeLabel !== '—' ? (
|
{!isLive && runtimeLabel !== '—' ? (
|
||||||
<span className="rounded bg-black/45 px-1.5 py-0.5">{runtimeLabel}</span>
|
<span className="rounded bg-black/45 px-1.5 py-0.5">{runtimeLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
460
frontend/src/components/ui/Slider.tsx
Normal file
460
frontend/src/components/ui/Slider.tsx
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
// frontend\src\components\ui\Slider.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id?: string
|
||||||
|
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
onChangeEnd?: (value: number) => void
|
||||||
|
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
disabled?: boolean
|
||||||
|
|
||||||
|
label?: string
|
||||||
|
hint?: string
|
||||||
|
|
||||||
|
valueLabel?: string
|
||||||
|
valueFormatter?: (value: number) => string
|
||||||
|
showValueLabel?: boolean
|
||||||
|
|
||||||
|
showMinMaxLabels?: boolean
|
||||||
|
minLabel?: string
|
||||||
|
maxLabel?: string
|
||||||
|
|
||||||
|
showReset?: boolean
|
||||||
|
resetValue?: number
|
||||||
|
resetLabel?: string
|
||||||
|
|
||||||
|
compact?: boolean
|
||||||
|
softStep?: boolean
|
||||||
|
|
||||||
|
className?: string
|
||||||
|
inputClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDecimals(value: number) {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
const s = String(value)
|
||||||
|
const i = s.indexOf('.')
|
||||||
|
return i >= 0 ? s.length - i - 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToStep(value: number, step: number, min: number) {
|
||||||
|
if (!Number.isFinite(step) || step <= 0) return value
|
||||||
|
const precision = Math.max(countDecimals(step), countDecimals(min))
|
||||||
|
const rounded = Math.round((value - min) / step) * step + min
|
||||||
|
return Number(rounded.toFixed(precision))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Slider({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onChangeEnd,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
valueLabel,
|
||||||
|
valueFormatter,
|
||||||
|
showValueLabel = false,
|
||||||
|
showMinMaxLabels = false,
|
||||||
|
minLabel,
|
||||||
|
maxLabel,
|
||||||
|
showReset = false,
|
||||||
|
resetValue,
|
||||||
|
resetLabel = 'Reset',
|
||||||
|
compact = false,
|
||||||
|
softStep = true,
|
||||||
|
className = '',
|
||||||
|
inputClassName = '',
|
||||||
|
}: Props) {
|
||||||
|
const reactId = React.useId()
|
||||||
|
const inputId = id ?? `slider-${reactId}`
|
||||||
|
|
||||||
|
const safeValue = Number.isFinite(value) ? value : min
|
||||||
|
const clamped = clamp(safeValue, min, max)
|
||||||
|
const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0
|
||||||
|
|
||||||
|
const emitCommit = () => {
|
||||||
|
onChangeEnd?.(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedValueLabel =
|
||||||
|
valueLabel ??
|
||||||
|
(valueFormatter ? valueFormatter(clamped) : String(clamped))
|
||||||
|
|
||||||
|
const resolvedMinLabel =
|
||||||
|
minLabel ?? (valueFormatter ? valueFormatter(min) : String(min))
|
||||||
|
|
||||||
|
const resolvedMaxLabel =
|
||||||
|
maxLabel ?? (valueFormatter ? valueFormatter(max) : String(max))
|
||||||
|
|
||||||
|
const canReset =
|
||||||
|
showReset &&
|
||||||
|
typeof resetValue === 'number' &&
|
||||||
|
Number.isFinite(resetValue)
|
||||||
|
|
||||||
|
const trackH = compact ? 4 : 8
|
||||||
|
const thumbSize = compact ? 14 : 16
|
||||||
|
const inputH = compact ? 14 : 16
|
||||||
|
const thumbOffset = -((thumbSize - trackH) / 2)
|
||||||
|
|
||||||
|
const handleChange = (next: number) => {
|
||||||
|
const raw = clamp(next, min, max)
|
||||||
|
const finalValue = softStep ? raw : roundToStep(raw, step, min)
|
||||||
|
onChange(finalValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
compact
|
||||||
|
? 'inline-flex min-w-0 items-center gap-3 rounded-md border border-gray-200/70 bg-white/60 px-3 h-9 shadow-sm dark:border-white/10 dark:bg-white/5'
|
||||||
|
: 'rounded-xl border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{compact ? (
|
||||||
|
<>
|
||||||
|
{label ? (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="shrink-0 text-sm font-medium leading-none text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 right-0">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 rounded-full bg-gray-200/90 dark:bg-white/10"
|
||||||
|
style={{
|
||||||
|
height: `${trackH}px`,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
|
||||||
|
style={{
|
||||||
|
width: `${percent}%`,
|
||||||
|
height: `${trackH}px`,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={softStep ? 'any' : step}
|
||||||
|
value={clamped}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label ?? 'Slider'}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={clamped}
|
||||||
|
className={[
|
||||||
|
`
|
||||||
|
relative z-10 block w-full cursor-pointer appearance-none bg-transparent
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
focus:outline-none touch-pan-x
|
||||||
|
|
||||||
|
[&::-webkit-slider-runnable-track]:bg-transparent
|
||||||
|
[&::-webkit-slider-runnable-track]:appearance-none
|
||||||
|
|
||||||
|
[&::-webkit-slider-thumb]:appearance-none
|
||||||
|
[&::-webkit-slider-thumb]:rounded-full
|
||||||
|
[&::-webkit-slider-thumb]:border
|
||||||
|
[&::-webkit-slider-thumb]:border-white/80
|
||||||
|
[&::-webkit-slider-thumb]:bg-indigo-500
|
||||||
|
[&::-webkit-slider-thumb]:shadow-sm
|
||||||
|
dark:[&::-webkit-slider-thumb]:border-gray-950
|
||||||
|
dark:[&::-webkit-slider-thumb]:bg-indigo-400
|
||||||
|
|
||||||
|
[&::-moz-range-track]:bg-transparent
|
||||||
|
[&::-moz-range-track]:border-0
|
||||||
|
|
||||||
|
[&::-moz-range-thumb]:rounded-full
|
||||||
|
[&::-moz-range-thumb]:border
|
||||||
|
[&::-moz-range-thumb]:border-white/80
|
||||||
|
[&::-moz-range-thumb]:bg-indigo-500
|
||||||
|
[&::-moz-range-thumb]:shadow-sm
|
||||||
|
dark:[&::-moz-range-thumb]:border-gray-950
|
||||||
|
dark:[&::-moz-range-thumb]:bg-indigo-400
|
||||||
|
`,
|
||||||
|
inputClassName,
|
||||||
|
].join(' ')}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
height: `${inputH}px`,
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
appearance: 'none',
|
||||||
|
['--slider-track-h' as any]: `${trackH}px`,
|
||||||
|
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
||||||
|
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
onChange={(e) => handleChange(Number(e.target.value))}
|
||||||
|
onPointerUp={emitCommit}
|
||||||
|
onMouseUp={emitCommit}
|
||||||
|
onTouchEnd={emitCommit}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === 'ArrowLeft' ||
|
||||||
|
e.key === 'ArrowRight' ||
|
||||||
|
e.key === 'ArrowUp' ||
|
||||||
|
e.key === 'ArrowDown' ||
|
||||||
|
e.key === 'Home' ||
|
||||||
|
e.key === 'End' ||
|
||||||
|
e.key === 'PageUp' ||
|
||||||
|
e.key === 'PageDown'
|
||||||
|
) {
|
||||||
|
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
|
height: var(--slider-track-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
width: var(--slider-thumb-size);
|
||||||
|
height: var(--slider-thumb-size);
|
||||||
|
margin-top: var(--slider-thumb-offset);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-track {
|
||||||
|
height: var(--slider-track-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
width: var(--slider-thumb-size);
|
||||||
|
height: var(--slider-thumb-size);
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showValueLabel ? (
|
||||||
|
<div className="shrink-0 text-xs font-medium tabular-nums text-gray-500 dark:text-gray-400">
|
||||||
|
{resolvedValueLabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canReset ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleChange(resetValue!)}
|
||||||
|
className="
|
||||||
|
inline-flex h-7 shrink-0 items-center rounded-md px-2 text-xs font-medium
|
||||||
|
text-gray-700 ring-1 ring-gray-200 transition-colors
|
||||||
|
hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{resetLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(label || hint || showValueLabel || canReset) ? (
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-3">
|
||||||
|
{(label || hint) ? (
|
||||||
|
<div className="min-w-0">
|
||||||
|
{label ? (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hint ? (
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(showValueLabel || canReset) ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{showValueLabel ? (
|
||||||
|
<div className="rounded-md bg-gray-100 px-2 py-1 text-xs font-medium tabular-nums text-gray-500 dark:bg-white/10 dark:text-gray-400">
|
||||||
|
{resolvedValueLabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canReset ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleChange(resetValue!)}
|
||||||
|
className="
|
||||||
|
inline-flex h-7 items-center rounded-md px-2 text-xs font-medium
|
||||||
|
text-gray-700 ring-1 ring-gray-200 transition-colors
|
||||||
|
hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{resetLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 right-0">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 rounded-full bg-gray-200 dark:bg-white/10"
|
||||||
|
style={{
|
||||||
|
height: `${trackH}px`,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
|
||||||
|
style={{
|
||||||
|
width: `${percent}%`,
|
||||||
|
height: `${trackH}px`,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={softStep ? 'any' : step}
|
||||||
|
value={clamped}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label ?? 'Slider'}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={clamped}
|
||||||
|
className={[
|
||||||
|
`
|
||||||
|
relative z-10 block w-full cursor-pointer appearance-none bg-transparent
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
focus:outline-none touch-pan-x
|
||||||
|
|
||||||
|
[&::-webkit-slider-runnable-track]:bg-transparent
|
||||||
|
[&::-webkit-slider-runnable-track]:appearance-none
|
||||||
|
|
||||||
|
[&::-webkit-slider-thumb]:appearance-none
|
||||||
|
[&::-webkit-slider-thumb]:rounded-full
|
||||||
|
[&::-webkit-slider-thumb]:border
|
||||||
|
[&::-webkit-slider-thumb]:border-white/80
|
||||||
|
[&::-webkit-slider-thumb]:bg-indigo-500
|
||||||
|
[&::-webkit-slider-thumb]:shadow-sm
|
||||||
|
dark:[&::-webkit-slider-thumb]:border-gray-950
|
||||||
|
dark:[&::-webkit-slider-thumb]:bg-indigo-400
|
||||||
|
|
||||||
|
[&::-moz-range-track]:bg-transparent
|
||||||
|
[&::-moz-range-track]:border-0
|
||||||
|
|
||||||
|
[&::-moz-range-thumb]:rounded-full
|
||||||
|
[&::-moz-range-thumb]:border
|
||||||
|
[&::-moz-range-thumb]:border-white/80
|
||||||
|
[&::-moz-range-thumb]:bg-indigo-500
|
||||||
|
[&::-moz-range-thumb]:shadow-sm
|
||||||
|
dark:[&::-moz-range-thumb]:border-gray-950
|
||||||
|
dark:[&::-moz-range-thumb]:bg-indigo-400
|
||||||
|
`,
|
||||||
|
inputClassName,
|
||||||
|
].join(' ')}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
height: `${thumbSize}px`,
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
appearance: 'none',
|
||||||
|
['--slider-track-h' as any]: `${trackH}px`,
|
||||||
|
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
||||||
|
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
onChange={(e) => handleChange(Number(e.target.value))}
|
||||||
|
onPointerUp={emitCommit}
|
||||||
|
onMouseUp={emitCommit}
|
||||||
|
onTouchEnd={emitCommit}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === 'ArrowLeft' ||
|
||||||
|
e.key === 'ArrowRight' ||
|
||||||
|
e.key === 'ArrowUp' ||
|
||||||
|
e.key === 'ArrowDown' ||
|
||||||
|
e.key === 'Home' ||
|
||||||
|
e.key === 'End' ||
|
||||||
|
e.key === 'PageUp' ||
|
||||||
|
e.key === 'PageDown'
|
||||||
|
) {
|
||||||
|
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
|
height: var(--slider-track-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
width: var(--slider-thumb-size);
|
||||||
|
height: var(--slider-thumb-size);
|
||||||
|
margin-top: var(--slider-thumb-offset);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-track {
|
||||||
|
height: var(--slider-track-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
width: var(--slider-thumb-size);
|
||||||
|
height: var(--slider-thumb-size);
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMinMaxLabels ? (
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{resolvedMinLabel}</span>
|
||||||
|
<span>{resolvedMaxLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -15,6 +15,8 @@ type Props = {
|
|||||||
maxWidthClassName?: string
|
maxWidthClassName?: string
|
||||||
gapPx?: number
|
gapPx?: number
|
||||||
className?: string
|
className?: string
|
||||||
|
expandMode?: 'cover' | 'dropdown'
|
||||||
|
dropdownMaxHeightClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagOverflowRow({
|
export default function TagOverflowRow({
|
||||||
@ -27,6 +29,8 @@ export default function TagOverflowRow({
|
|||||||
maxWidthClassName,
|
maxWidthClassName,
|
||||||
gapPx = 6,
|
gapPx = 6,
|
||||||
className,
|
className,
|
||||||
|
expandMode = 'cover',
|
||||||
|
dropdownMaxHeightClassName = 'max-h-[40vh]',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
@ -141,6 +145,25 @@ export default function TagOverflowRow({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!open || expandMode !== 'cover') return
|
||||||
|
|
||||||
|
recalcOverlay()
|
||||||
|
|
||||||
|
const host = hostRef.current
|
||||||
|
const parent = host?.offsetParent as HTMLElement | null
|
||||||
|
if (!parent) return
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
recalcOverlay()
|
||||||
|
})
|
||||||
|
|
||||||
|
ro.observe(parent)
|
||||||
|
if (host) ro.observe(host)
|
||||||
|
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [open, expandMode, recalcOverlay])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
@ -182,10 +205,14 @@ export default function TagOverflowRow({
|
|||||||
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
|
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
|
||||||
const restAll = sortedTags.length - visibleTags.length
|
const restAll = sortedTags.length - visibleTags.length
|
||||||
|
|
||||||
|
const keepInlineRowVisible = expandMode === 'dropdown'
|
||||||
|
const showInlineRow = !open || keepInlineRowVisible
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={hostRef} className="relative h-full min-h-[1.75rem]">
|
<div ref={hostRef} className="relative h-full min-h-[1.75rem]">
|
||||||
|
|
||||||
{/* collapsed row (in footer) */}
|
{/* collapsed row (in footer) */}
|
||||||
{!open ? (
|
{showInlineRow ? (
|
||||||
<div
|
<div
|
||||||
ref={rowWrapRef}
|
ref={rowWrapRef}
|
||||||
className={['min-h-[1.75rem] h-full flex items-start gap-1.5 overflow-visible', className].filter(Boolean).join(' ')}
|
className={['min-h-[1.75rem] h-full flex items-start gap-1.5 overflow-visible', className].filter(Boolean).join(' ')}
|
||||||
@ -215,22 +242,20 @@ export default function TagOverflowRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={[
|
className={[
|
||||||
// TagBadge-like sizing + shape
|
|
||||||
'inline-flex min-h-[1.375rem] shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-medium leading-none',
|
'inline-flex min-h-[1.375rem] shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-medium leading-none',
|
||||||
// TagBadge-like focus behavior
|
|
||||||
'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||||
// neutral colors (damit es sich als “Control” abhebt)
|
open
|
||||||
'bg-gray-100 text-gray-700 hover:bg-gray-200/70',
|
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200/70 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
|
||||||
'dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20',
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200/70 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpen(true)
|
setOpen((prev) => !prev)
|
||||||
}}
|
}}
|
||||||
title="Alle Tags anzeigen"
|
title={open ? 'Tag-Liste schließen' : 'Alle Tags anzeigen'}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-expanded={false}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
+{restAll}
|
+{restAll}
|
||||||
</button>
|
</button>
|
||||||
@ -240,6 +265,7 @@ export default function TagOverflowRow({
|
|||||||
|
|
||||||
{/* overlay that covers the whole footer host */}
|
{/* overlay that covers the whole footer host */}
|
||||||
{open ? (
|
{open ? (
|
||||||
|
expandMode === 'cover' ? (
|
||||||
<div
|
<div
|
||||||
style={overlayStyle}
|
style={overlayStyle}
|
||||||
className={[
|
className={[
|
||||||
@ -290,6 +316,56 @@ export default function TagOverflowRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'absolute left-0 right-0 top-full mt-2 z-40',
|
||||||
|
'overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
|
||||||
|
'dark:border-white/10 dark:bg-gray-950',
|
||||||
|
'pointer-events-auto',
|
||||||
|
].join(' ')}
|
||||||
|
onClick={stop}
|
||||||
|
onMouseDown={stop}
|
||||||
|
onPointerDown={stop}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Tags"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="
|
||||||
|
absolute right-2 top-2 z-10
|
||||||
|
rounded-md bg-gray-100/80 px-2 py-1 text-xs font-semibold text-gray-700
|
||||||
|
hover:bg-gray-200/80
|
||||||
|
dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20
|
||||||
|
"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Schließen"
|
||||||
|
title="Schließen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={['overflow-auto px-3 pb-3 pr-2 pt-3', dropdownMaxHeightClassName].join(' ')}>
|
||||||
|
<div className="mb-2 pr-10">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Alle Tags
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start gap-1.5 overflow-visible p-0.5">
|
||||||
|
{sortedTags.map((t) => (
|
||||||
|
<TagBadge
|
||||||
|
key={t}
|
||||||
|
tag={t}
|
||||||
|
active={activeTagSet.has(lower(t))}
|
||||||
|
onClick={onToggleTagFilter}
|
||||||
|
maxWidthClassName={maxWidthClassName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* hidden measure area */}
|
{/* hidden measure area */}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import 'flag-icons/css/flag-icons.min.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user