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)
|
||||
|
||||
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(
|
||||
cbModelStore,
|
||||
append([]ChaturbateRoom(nil), rooms...),
|
||||
copiedRooms,
|
||||
fetchedAtNow,
|
||||
)
|
||||
|
||||
// optional / best effort
|
||||
if len(rooms) > 0 {
|
||||
go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
||||
// Vollständigen Snapshot pflegen, aber offline NICHT überschreiben
|
||||
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
|
||||
|
||||
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
|
||||
if cbModelStore != nil {
|
||||
go syncChaturbateRoomStateIntoModelStore(
|
||||
cbModelStore,
|
||||
append([]ChaturbateRoom(nil), rooms...),
|
||||
fetchedAtNow,
|
||||
)
|
||||
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
|
||||
|
||||
if len(rooms) > 0 {
|
||||
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
||||
go func(roomsCopy []ChaturbateRoom, ts time.Time) {
|
||||
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
|
||||
}
|
||||
|
||||
master, err := ParseStream(body) // -> hls_source
|
||||
master, err := ParseStream(body) // hls_source
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pl, err := FetchPlaylist(ctx, hc, master, cookie) // -> beste Variant
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(pl.PlaylistURL), nil
|
||||
return strings.TrimSpace(master), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 isIndex {
|
||||
serveLiveNotReady(w, r)
|
||||
@ -362,16 +351,10 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
|
||||
commonIn = append(commonIn, "-i", m3u8URL)
|
||||
|
||||
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:a:0?",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
|
||||
"-c:v", "copy",
|
||||
"-c:a", "copy",
|
||||
"-f", "hls",
|
||||
"-hls_time", "2",
|
||||
"-hls_list_size", "6",
|
||||
@ -485,7 +468,7 @@ func rewriteM3U8WithBase(raw []byte, id string, basePath string) []byte {
|
||||
}
|
||||
|
||||
name := path.Base(u)
|
||||
out.WriteString(base + url.QueryEscape(name) + "&play=1")
|
||||
out.WriteString(base + url.QueryEscape(name))
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
@ -518,7 +501,7 @@ func rewriteAttrURIWithBase(line, base string, basePath string) string {
|
||||
}
|
||||
|
||||
name := path.Base(valTrim)
|
||||
repl := base + url.QueryEscape(name) + "&play=1"
|
||||
repl := base + url.QueryEscape(name)
|
||||
return line[:start] + repl + line[end:]
|
||||
}
|
||||
|
||||
@ -591,13 +574,6 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
job, ok := jobs[id]
|
||||
state := ""
|
||||
@ -701,20 +677,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
||||
args = append(args,
|
||||
"-map", "0:v:0",
|
||||
"-map", "0:a:0?",
|
||||
"-vf", "scale=480:-2",
|
||||
"-c:v", "libx264",
|
||||
"-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",
|
||||
"-c:v", "copy",
|
||||
"-c:a", "copy",
|
||||
)
|
||||
|
||||
// Output: fMP4 fragmented to stdout (single HTTP response)
|
||||
|
||||
@ -1191,8 +1191,8 @@ func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom,
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// q1 mit cb_online_last_error
|
||||
stmt1, err1 := tx.Prepare(`
|
||||
// ONLINE: voller Snapshot wird gespeichert
|
||||
stmtOnline1, errOnline1 := tx.Prepare(`
|
||||
UPDATE models
|
||||
SET
|
||||
last_seen_online = $1,
|
||||
@ -1213,10 +1213,9 @@ WHERE lower(trim(host)) = lower(trim($8))
|
||||
AND lower(trim(model_key)) = lower(trim($9));
|
||||
`)
|
||||
|
||||
// fallback ohne cb_online_last_error
|
||||
var stmt2 *sql.Stmt
|
||||
if err1 != nil {
|
||||
stmt2, err = tx.Prepare(`
|
||||
var stmtOnline2 *sql.Stmt
|
||||
if errOnline1 != nil {
|
||||
stmtOnline2, err = tx.Prepare(`
|
||||
UPDATE models
|
||||
SET
|
||||
last_seen_online = $1,
|
||||
@ -1238,13 +1237,43 @@ WHERE lower(trim(host)) = lower(trim($7))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt2.Close()
|
||||
defer stmtOnline2.Close()
|
||||
} 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()
|
||||
fetchedAtStr := fetchedAt.Format(time.RFC3339Nano)
|
||||
|
||||
for _, key := range knownKeys {
|
||||
key = strings.TrimSpace(key)
|
||||
@ -1252,18 +1281,10 @@ WHERE lower(trim(host)) = lower(trim($7))
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
online bool
|
||||
snap *ChaturbateOnlineSnapshot
|
||||
jsonStr string
|
||||
imgURL string
|
||||
)
|
||||
|
||||
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,
|
||||
DisplayName: rm.DisplayName,
|
||||
CurrentShow: strings.TrimSpace(rm.CurrentShow),
|
||||
@ -1284,29 +1305,52 @@ WHERE lower(trim(host)) = lower(trim($7))
|
||||
ChatRoomURLRS: rm.ChatRoomURLRS,
|
||||
Tags: rm.Tags,
|
||||
}
|
||||
} else {
|
||||
online = false
|
||||
|
||||
snap = &ChaturbateOnlineSnapshot{
|
||||
Username: key,
|
||||
CurrentShow: "offline",
|
||||
}
|
||||
}
|
||||
|
||||
if snap != nil {
|
||||
jsonStr := ""
|
||||
if b, err := json.Marshal(snap); err == nil {
|
||||
jsonStr = strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
if stmtOnline1 != nil {
|
||||
if _, err := stmtOnline1.Exec(
|
||||
true,
|
||||
fetchedAt,
|
||||
nullableStringArg(jsonStr),
|
||||
fetchedAt,
|
||||
"",
|
||||
imgURL,
|
||||
now,
|
||||
host,
|
||||
key,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := stmtOnline2.Exec(
|
||||
true,
|
||||
fetchedAt,
|
||||
nullableStringArg(jsonStr),
|
||||
fetchedAt,
|
||||
imgURL,
|
||||
now,
|
||||
host,
|
||||
key,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if stmt1 != nil {
|
||||
if _, err := stmt1.Exec(
|
||||
online,
|
||||
fetchedAt,
|
||||
nullableStringArg(jsonStr),
|
||||
// OFFLINE:
|
||||
// KEIN cb_online_json Update
|
||||
// KEIN cb_online_fetched_at Update
|
||||
if stmtOffline1 != nil {
|
||||
if _, err := stmtOffline1.Exec(
|
||||
false,
|
||||
fetchedAt,
|
||||
"",
|
||||
imgURL,
|
||||
now,
|
||||
host,
|
||||
key,
|
||||
@ -1314,12 +1358,9 @@ WHERE lower(trim(host)) = lower(trim($7))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := stmt2.Exec(
|
||||
online,
|
||||
if _, err := stmtOffline2.Exec(
|
||||
false,
|
||||
fetchedAt,
|
||||
nullableStringArg(jsonStr),
|
||||
fetchedAt,
|
||||
imgURL,
|
||||
now,
|
||||
host,
|
||||
key,
|
||||
@ -1327,8 +1368,6 @@ WHERE lower(trim(host)) = lower(trim($7))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_ = fetchedAtStr // falls du später Logging willst
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
|
||||
@ -84,7 +84,7 @@ func RecordStream(
|
||||
}
|
||||
|
||||
jobsMu.Lock()
|
||||
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
||||
job.PreviewM3U8 = strings.TrimSpace(hlsURL)
|
||||
job.PreviewCookie = httpCookie
|
||||
job.PreviewUA = hc.userAgent
|
||||
if previewDir != "" {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
||||
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
||||
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
|
||||
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
|
||||
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
|
||||
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
||||
"ffmpegPath": "",
|
||||
"autoAddToDownloadList": true,
|
||||
"autoStartAddedDownloads": true,
|
||||
@ -10,8 +10,8 @@
|
||||
"maxConcurrentDownloads": 40,
|
||||
"useChaturbateApi": true,
|
||||
"useMyFreeCamsWatcher": true,
|
||||
"autoDeleteSmallDownloads": false,
|
||||
"autoDeleteSmallDownloadsBelowMB": 50,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 100,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"teaserPlayback": "all",
|
||||
|
||||
@ -321,9 +321,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if in.MaxConcurrentDownloads < 1 {
|
||||
in.MaxConcurrentDownloads = 1
|
||||
}
|
||||
if in.MaxConcurrentDownloads > 100 {
|
||||
in.MaxConcurrentDownloads = 100
|
||||
}
|
||||
|
||||
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
|
||||
recAbs, err := resolvePathRelativeToApp(in.RecordDir)
|
||||
|
||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"hls.js": "^1.6.15",
|
||||
"flag-icons": "^7.5.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@ -2819,6 +2819,12 @@
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
@ -2933,12 +2939,6 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"hls.js": "^1.6.15",
|
||||
"flag-icons": "^7.5.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^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 LoginPage from './components/ui/LoginPage'
|
||||
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||||
import TextInput from './components/ui/TextInput'
|
||||
|
||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||
|
||||
@ -498,10 +499,6 @@ export default function App() {
|
||||
const doneCountInFlightRef = useRef(false)
|
||||
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 modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
||||
|
||||
@ -563,14 +560,16 @@ export default function App() {
|
||||
|
||||
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
||||
|
||||
const selectSourceUrl = useCallback(() => {
|
||||
const el = sourceUrlInputRef.current
|
||||
if (!el) return
|
||||
// Fokus sicherstellen, dann alles markieren
|
||||
el.focus()
|
||||
// rAF, damit der Fokus sicher "sitzt" (und für Mobile/Safari stabiler)
|
||||
requestAnimationFrame(() => el.select())
|
||||
}, [])
|
||||
const INCLUDE_KEEP_KEY = 'finishedDownloads_includeKeep_v2'
|
||||
|
||||
const [includeKeep, setIncludeKeep] = useState<boolean>(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(INCLUDE_KEEP_KEY)
|
||||
return raw === '1' || raw === 'true' || raw === 'yes'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
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 =
|
||||
| 'completed_desc'
|
||||
@ -714,6 +714,11 @@ export default function App() {
|
||||
|
||||
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) => {
|
||||
if (pageToFetch < 1) return
|
||||
if (donePrefetchInFlightRef.current) return
|
||||
@ -728,7 +733,9 @@ export default function App() {
|
||||
donePrefetchInFlightRef.current = true
|
||||
try {
|
||||
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 }
|
||||
)
|
||||
if (!res.ok) return
|
||||
@ -744,7 +751,7 @@ export default function App() {
|
||||
} finally {
|
||||
donePrefetchInFlightRef.current = false
|
||||
}
|
||||
}, [doneSort])
|
||||
}, [doneSort, includeKeep])
|
||||
|
||||
const loadDoneCount = useCallback(async () => {
|
||||
const now = Date.now()
|
||||
@ -757,7 +764,10 @@ export default function App() {
|
||||
doneCountLastAtRef.current = now
|
||||
|
||||
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
|
||||
|
||||
const data = await res.json().catch(() => null)
|
||||
@ -771,7 +781,7 @@ export default function App() {
|
||||
} finally {
|
||||
doneCountInFlightRef.current = false
|
||||
}
|
||||
}, [])
|
||||
}, [includeKeep])
|
||||
|
||||
const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
|
||||
try {
|
||||
@ -783,12 +793,14 @@ export default function App() {
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
|
||||
donePrefetchRef.current = null
|
||||
void prefetchDonePage(pageToLoad + 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
if (!res.ok) return
|
||||
@ -804,63 +816,24 @@ export default function App() {
|
||||
setDoneJobs(items)
|
||||
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 {
|
||||
// ignore
|
||||
}
|
||||
}, [donePage, doneSort, prefetchDonePage])
|
||||
|
||||
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)
|
||||
}, [])
|
||||
}, [donePage, doneSort, prefetchDonePage, includeKeep, doneCount])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (finishedReloadTimerRef.current != null) {
|
||||
window.clearTimeout(finishedReloadTimerRef.current)
|
||||
finishedReloadTimerRef.current = null
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
}, [includeKeep])
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
@ -2347,14 +2320,11 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (selectedTab !== 'finished') return
|
||||
|
||||
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
|
||||
void loadDoneCount()
|
||||
requestFinishedReload('selectedTab effect')
|
||||
|
||||
const onVis = () => {
|
||||
if (!document.hidden) {
|
||||
void loadDoneCount()
|
||||
requestFinishedReload('selectedTab visibilitychange')
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
@ -2362,7 +2332,7 @@ export default function App() {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}, [selectedTab, loadDoneCount, requestFinishedReload])
|
||||
}, [selectedTab, loadDoneCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return
|
||||
@ -2386,10 +2356,6 @@ export default function App() {
|
||||
void loadDoneCount()
|
||||
void loadJobs()
|
||||
void loadTaskStateOnce()
|
||||
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload('sse fallback poll')
|
||||
}
|
||||
}, document.hidden ? 60000 : 5000)
|
||||
}
|
||||
|
||||
@ -2469,11 +2435,6 @@ export default function App() {
|
||||
if (state === 'done' || state === 'error' || state === 'missing') {
|
||||
bumpAssets()
|
||||
}
|
||||
|
||||
// ✅ Optional: Finished-Ansicht sichtbar? Dann Liste leicht anstoßen
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload(`finishedPostwork:${state}`)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@ -2528,10 +2489,6 @@ export default function App() {
|
||||
void loadJobs()
|
||||
void loadDoneCount()
|
||||
void loadTaskStateOnce()
|
||||
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload('visibilitychange')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
@ -2559,7 +2516,7 @@ export default function App() {
|
||||
eventSourceRef.current = null
|
||||
modelEventNamesRef.current = new Set()
|
||||
}
|
||||
}, [authed, loadJobs, loadDoneCount, requestFinishedReload])
|
||||
}, [authed, loadJobs, loadDoneCount])
|
||||
|
||||
useEffect(() => {
|
||||
const desired = new Set<string>()
|
||||
@ -2830,7 +2787,7 @@ export default function App() {
|
||||
setDoneJobs((prev) => {
|
||||
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
|
||||
|
||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
||||
@ -2843,7 +2800,7 @@ export default function App() {
|
||||
const next: RecordJob[] = [...filtered]
|
||||
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 id = String(cand.id || baseName(cand.output || '')).trim()
|
||||
if (!id || used.has(id)) continue
|
||||
@ -2862,11 +2819,14 @@ export default function App() {
|
||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||
|
||||
// ✅ Prefetch wieder nachfüllen
|
||||
void prefetchDonePage(donePage + 1)
|
||||
// ✅ Prefetch wieder nachfüllen – aber nur wenn es noch eine nächste Seite gibt
|
||||
const nextDoneCount = Math.max(0, doneCount - 1)
|
||||
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount)
|
||||
|
||||
if (donePage < totalPagesAfterDelete) {
|
||||
void prefetchDonePage(donePage + 1)
|
||||
}
|
||||
|
||||
// ✅ wichtig
|
||||
requestFinishedReload('delete success')
|
||||
void loadDoneCount()
|
||||
}, 320)
|
||||
},
|
||||
@ -2881,7 +2841,7 @@ export default function App() {
|
||||
return
|
||||
}
|
||||
},
|
||||
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage]
|
||||
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage, doneCount]
|
||||
)
|
||||
|
||||
const handleKeepJob = useCallback(
|
||||
@ -2900,7 +2860,7 @@ export default function App() {
|
||||
setDoneJobs((prev) => {
|
||||
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
|
||||
|
||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
||||
@ -2913,7 +2873,7 @@ export default function App() {
|
||||
const next: RecordJob[] = [...filtered]
|
||||
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 id = String(cand.id || baseName(cand.output || '')).trim()
|
||||
if (!id || used.has(id)) continue
|
||||
@ -2932,11 +2892,14 @@ export default function App() {
|
||||
// ✅ Count runter
|
||||
setDoneCount((c) => Math.max(0, c - 1))
|
||||
|
||||
// ✅ Prefetch wieder nachfüllen
|
||||
void prefetchDonePage(donePage + 1)
|
||||
// ✅ Prefetch wieder nachfüllen – aber nur wenn es noch eine nächste Seite gibt
|
||||
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()
|
||||
}, 320)
|
||||
},
|
||||
@ -2954,8 +2917,8 @@ export default function App() {
|
||||
donePage,
|
||||
doneSort,
|
||||
prefetchDonePage,
|
||||
requestFinishedReload,
|
||||
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="relative">
|
||||
<label className="sr-only">Source URL</label>
|
||||
<input
|
||||
<TextInput
|
||||
ref={sourceUrlInputRef}
|
||||
value={sourceUrl}
|
||||
size='sm'
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
onMouseDown={(e) => {
|
||||
// nur Linksklick
|
||||
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()
|
||||
}}
|
||||
selectAllOnFocus
|
||||
selectAllOnMouseDown
|
||||
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>
|
||||
|
||||
@ -3578,7 +3530,8 @@ export default function App() {
|
||||
doneJobs={doneJobs}
|
||||
doneTotal={doneCount}
|
||||
page={donePage}
|
||||
pageSize={DONE_PAGE_SIZE}
|
||||
pageSize={donePageSize}
|
||||
onPageSizeChange={setDonePageSize}
|
||||
onPageChange={setDonePage}
|
||||
onOpenPlayer={openPlayer}
|
||||
onDeleteJob={handleDeleteJobWithUndo}
|
||||
@ -3600,6 +3553,12 @@ export default function App() {
|
||||
setDonePage(1)
|
||||
}}
|
||||
loadMode="paged"
|
||||
includeKeep={includeKeep}
|
||||
onIncludeKeepChange={(checked) => {
|
||||
setIncludeKeep(checked)
|
||||
setDonePage(1)
|
||||
donePrefetchRef.current = null
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -178,6 +178,17 @@ function effectiveRoomStatusOfJob(
|
||||
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 => {
|
||||
switch (status) {
|
||||
case 'Public':
|
||||
@ -951,7 +962,7 @@ export default function Downloads({
|
||||
growingByJobId
|
||||
)}
|
||||
alignStartAt={j.startedAt}
|
||||
alignEndAt={j.endedAt ?? null}
|
||||
alignEndAt={previewAlignEndAtOfJob(j)}
|
||||
alignEveryMs={10_000}
|
||||
fastRetryMs={1000}
|
||||
fastRetryMax={25}
|
||||
@ -1388,77 +1399,155 @@ export default function Downloads({
|
||||
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="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="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="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="shrink-0 flex items-center gap-2 min-w-0">
|
||||
{concurrentLimitHintVisible ? (
|
||||
<div
|
||||
className={[
|
||||
'flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 whitespace-nowrap',
|
||||
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(' ')}
|
||||
{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={
|
||||
concurrentLimitReached
|
||||
? 'Maximale Anzahl gleichzeitiger Downloads erreicht'
|
||||
: 'Begrenzung für gleichzeitige Downloads ist aktiv'
|
||||
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" />
|
||||
}
|
||||
>
|
||||
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
|
||||
{concurrentLimitReached ? ' · Limit erreicht' : ''}
|
||||
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>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={watchedPaused ? 'secondary' : 'primary'}
|
||||
disabled={watchedBusy || watchedPausedByDisk}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (watchedPausedByDisk) return
|
||||
void (watchedPaused ? resumeWatched() : pauseWatched())
|
||||
}}
|
||||
className="hidden sm:inline-flex"
|
||||
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>
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
{concurrentLimitHintVisible ? (
|
||||
<div
|
||||
className={[
|
||||
'flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 whitespace-nowrap',
|
||||
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
|
||||
? 'Maximale Anzahl gleichzeitiger Downloads erreicht'
|
||||
: 'Begrenzung für gleichzeitige Downloads ist aktiv'
|
||||
}
|
||||
>
|
||||
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
|
||||
{concurrentLimitReached ? ' · Limit erreicht' : ''}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={stopAllBusy || stoppableIds.length === 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void stopAll()
|
||||
}}
|
||||
className="hidden sm:inline-flex"
|
||||
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
|
||||
>
|
||||
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={watchedPaused ? 'secondary' : 'primary'}
|
||||
disabled={watchedBusy || watchedPausedByDisk}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (watchedPausedByDisk) return
|
||||
void (watchedPaused ? resumeWatched() : pauseWatched())
|
||||
}}
|
||||
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()
|
||||
}}
|
||||
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
|
||||
>
|
||||
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -184,6 +184,31 @@ function effectiveRoomStatusOfJob(
|
||||
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 => {
|
||||
switch (status) {
|
||||
case 'Public':
|
||||
@ -682,9 +707,14 @@ export default function DownloadsCardRow({
|
||||
<ModelPreview
|
||||
jobId={j.id}
|
||||
blur={blurPreviews}
|
||||
roomStatus={roomStatus}
|
||||
roomStatus={previewRoomStatusOfJob(
|
||||
j,
|
||||
roomStatusByModelKey,
|
||||
modelsByKey,
|
||||
growingByJobId
|
||||
)}
|
||||
alignStartAt={j.startedAt}
|
||||
alignEndAt={j.endedAt ?? null}
|
||||
alignEndAt={previewAlignEndAtOfJob(j)}
|
||||
alignEveryMs={10_000}
|
||||
fastRetryMs={1000}
|
||||
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,
|
||||
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,
|
||||
shouldObserveTeasers,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
import {
|
||||
buildSpriteFrameStyle,
|
||||
parseJobMeta,
|
||||
firstNonEmptyString,
|
||||
readPreviewSpriteInfo,
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
selectedKeys: Set<string>
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
onColumnCountChange?: (count: number) => void
|
||||
isLoading?: boolean
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
@ -43,6 +29,7 @@ type Props = {
|
||||
) => FinishedPostworkSummary | undefined
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||
cardScale?: number
|
||||
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
|
||||
@ -83,10 +70,15 @@ type Props = {
|
||||
forcePreviewMuted?: boolean
|
||||
}
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
// ms -> s Heuristik wie in FinishedVideoPreview
|
||||
return value > 24 * 60 * 60 ? value / 1000 : value
|
||||
function snapTo(value: number, step: number, base = 0) {
|
||||
if (!Number.isFinite(value)) return base
|
||||
if (!Number.isFinite(step) || step <= 0) return 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({
|
||||
@ -98,9 +90,10 @@ export default function FinishedDownloadsGalleryView({
|
||||
getPostworkSummaryForOutput,
|
||||
teaserState,
|
||||
renderPostworkBadge,
|
||||
cardScale,
|
||||
onColumnCountChange,
|
||||
selectedKeys,
|
||||
onToggleSelected,
|
||||
|
||||
handleDuration,
|
||||
handleScrubberClickIndex,
|
||||
jobForDetails,
|
||||
@ -110,13 +103,10 @@ export default function FinishedDownloadsGalleryView({
|
||||
runtimeOf,
|
||||
sizeBytesOf,
|
||||
formatBytes,
|
||||
|
||||
deletingKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
|
||||
registerTeaserHost,
|
||||
|
||||
onHoverPreviewKeyChange,
|
||||
onOpenPlayer,
|
||||
deleteVideo,
|
||||
@ -133,11 +123,8 @@ export default function FinishedDownloadsGalleryView({
|
||||
onAddToDownloads,
|
||||
forcePreviewMuted,
|
||||
}: Props) {
|
||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||
const hasAnySelection = selectedKeys.size > 0
|
||||
|
||||
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
|
||||
const registerTeaserHostIfNeeded = React.useCallback(
|
||||
(key: string) => (el: HTMLDivElement | null) => {
|
||||
if (!observeTeasers) {
|
||||
@ -149,444 +136,137 @@ export default function FinishedDownloadsGalleryView({
|
||||
[registerTeaserHost, observeTeasers]
|
||||
)
|
||||
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
const s = String(raw ?? '').trim()
|
||||
if (!s) return []
|
||||
const parts = s
|
||||
.split(/[\n,;|]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
const effectiveScaleRaw =
|
||||
typeof cardScale === 'number'
|
||||
? cardScale
|
||||
: 1
|
||||
|
||||
const seen = new Set<string>()
|
||||
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
|
||||
}
|
||||
const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35)
|
||||
|
||||
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
|
||||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||||
const meta = parseJobMeta((j as any)?.meta)
|
||||
const layoutScale = React.useMemo(
|
||||
() => snapTo(effectiveScale, 0.05, 0.8),
|
||||
[effectiveScale]
|
||||
)
|
||||
|
||||
const w =
|
||||
meta?.media?.video?.width ??
|
||||
meta?.file?.video?.width
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [containerWidth, setContainerWidth] = React.useState(0)
|
||||
|
||||
const h =
|
||||
meta?.media?.video?.height ??
|
||||
meta?.file?.video?.height
|
||||
const gapPx = 12
|
||||
|
||||
if (
|
||||
typeof w === 'number' &&
|
||||
Number.isFinite(w) &&
|
||||
w > 0 &&
|
||||
typeof h === 'number' &&
|
||||
Number.isFinite(h) &&
|
||||
h > 0
|
||||
) {
|
||||
return { w, h }
|
||||
const preferredCardWidth = React.useMemo(() => {
|
||||
const base = 240
|
||||
return Math.round(base * layoutScale)
|
||||
}, [layoutScale])
|
||||
|
||||
const columnCount = React.useMemo(() => {
|
||||
if (!containerWidth || containerWidth <= 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return null
|
||||
const columnsThatFit = Math.floor(
|
||||
(containerWidth + gapPx) / (preferredCardWidth + gapPx)
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ✅ 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">
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<div
|
||||
className="grid w-full gap-3"
|
||||
style={{
|
||||
...gridStyle,
|
||||
gridAutoFlow: 'row',
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{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 meta = parseJobMeta((j as any)?.meta)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// ✅ 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 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)
|
||||
|
||||
// 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)
|
||||
}}
|
||||
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 */}
|
||||
<div
|
||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
ref={registerTeaserHostIfNeeded(k)}
|
||||
onMouseEnter={() => {
|
||||
setHoveredThumbKey(k)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||
clearScrubIndex(k)
|
||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||
}}
|
||||
>
|
||||
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
||||
<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>
|
||||
|
||||
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
|
||||
{hasSpriteScrubber && spriteUrl ? (
|
||||
<img
|
||||
src={spriteUrl}
|
||||
alt=""
|
||||
className="hidden"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : 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}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
<FinishedDownloadsGalleryCard
|
||||
key={k}
|
||||
job={j}
|
||||
checked={selectedKeys.has(k)}
|
||||
hasAnySelection={selectedKeys.size > 0}
|
||||
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
|
||||
forcePreviewMuted={forcePreviewMuted}
|
||||
blurPreviews={blurPreviews}
|
||||
postworkByFile={postworkByFile}
|
||||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||||
renderPostworkBadge={renderPostworkBadge}
|
||||
durations={durations}
|
||||
teaserState={teaserState}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
lower={lower}
|
||||
modelsByKey={modelsByKey}
|
||||
activeTagSet={activeTagSet}
|
||||
deletingKeys={deletingKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
registerTeaserHost={registerTeaserHostIfNeeded}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
handleDuration={handleDuration}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
onToggleHot={onToggleHot}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
jobForDetails={jobForDetails}
|
||||
contentPaddingClass={contentPaddingClass}
|
||||
footerMinHeightClass={footerMinHeightClass}
|
||||
titleClass={titleClass}
|
||||
sublineClass={sublineClass}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import Button from './Button'
|
||||
|
||||
type ModalLayout = 'single' | 'split'
|
||||
type ModalScroll = 'body' | 'right' | 'none'
|
||||
@ -12,7 +13,7 @@ type ModalScroll = 'body' | 'right' | 'none'
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
title?: ReactNode
|
||||
titleRight?: ReactNode
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
@ -201,20 +202,17 @@ export default function Modal({
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{titleRight ? <div className="hidden sm:block">{titleRight}</div> : null}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
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"
|
||||
title="Schließen"
|
||||
className="shrink-0 px-2 min-w-0"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -272,7 +270,7 @@ export default function Modal({
|
||||
{mobileCollapsedImageSrc ? (
|
||||
<img
|
||||
src={mobileCollapsedImageSrc}
|
||||
alt={mobileCollapsedImageAlt || title || ''}
|
||||
alt={mobileCollapsedImageAlt || (typeof title === 'string' ? title : '')}
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg object-cover ring-1 ring-black/5 dark:ring-white/10',
|
||||
mobileCollapsed ? 'size-8' : 'size-10'
|
||||
@ -299,20 +297,17 @@ export default function Modal({
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{titleRight ? <div>{titleRight}</div> : null}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
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"
|
||||
title="Schließen"
|
||||
className="shrink-0 px-2 min-w-0"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</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)
|
||||
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 ? (
|
||||
@ -303,7 +303,7 @@ export default function ModelPreview({
|
||||
<div className="absolute right-2 bottom-2 z-[60]">
|
||||
<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'}
|
||||
aria-label={popupMuted ? 'Ton an' : 'Stumm'}
|
||||
onClick={(e) => {
|
||||
|
||||
@ -308,9 +308,7 @@ export default function Player({
|
||||
}))
|
||||
|
||||
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
|
||||
const isRunning = job.status === 'running'
|
||||
const [hlsReady, setHlsReady] = React.useState(false)
|
||||
const isLive = isRunning && hlsReady
|
||||
const isLive = job.status === 'running'
|
||||
|
||||
const [liveMuted, setLiveMuted] = React.useState(startMuted)
|
||||
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
|
||||
@ -321,12 +319,12 @@ export default function Player({
|
||||
const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
|
||||
|
||||
const previewId = React.useMemo(
|
||||
() => (isRunning ? job.id : finishedStem || job.id),
|
||||
[isRunning, job.id, finishedStem]
|
||||
() => (isLive ? job.id : finishedStem || job.id),
|
||||
[isLive, job.id, finishedStem]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isRunning) return
|
||||
if (isLive) return
|
||||
if (fullDurationSec > 0) return
|
||||
|
||||
const fileName = baseName(job.output?.trim() || '')
|
||||
@ -365,7 +363,7 @@ export default function Player({
|
||||
alive = false
|
||||
ctrl.abort()
|
||||
}
|
||||
}, [isRunning, fullDurationSec, job.output])
|
||||
}, [isLive, fullDurationSec, job.output])
|
||||
|
||||
const isHotFile = fileRaw.startsWith('HOT ')
|
||||
const model = React.useMemo(() => {
|
||||
@ -455,56 +453,6 @@ export default function Player({
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [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(
|
||||
(params: { file?: string; id?: string }) => {
|
||||
const qp = new URLSearchParams()
|
||||
@ -517,7 +465,7 @@ export default function Player({
|
||||
|
||||
const media = React.useMemo(() => {
|
||||
// ✅ 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
|
||||
if (!metaReady) return { src: '', type: '' }
|
||||
@ -532,7 +480,7 @@ export default function Player({
|
||||
}
|
||||
|
||||
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 playerRef = React.useRef<VideoJsPlayer | null>(null)
|
||||
@ -612,7 +560,7 @@ export default function Player({
|
||||
const appliedStartSeekRef = React.useRef<string>('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isRunning) {
|
||||
if (isLive) {
|
||||
setMetaReady(true)
|
||||
return
|
||||
}
|
||||
@ -679,7 +627,7 @@ export default function Player({
|
||||
alive = false
|
||||
ctrl.abort()
|
||||
}
|
||||
}, [isRunning, playbackKey, job.output])
|
||||
}, [isLive, playbackKey, job.output])
|
||||
|
||||
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
|
||||
const [, setVvTick] = React.useState(0)
|
||||
@ -790,7 +738,7 @@ export default function Player({
|
||||
React.useEffect(() => {
|
||||
const p: any = playerRef.current
|
||||
if (!p || p.isDisposed?.()) return
|
||||
if (isRunning) return // live nutzt Video.js nicht
|
||||
if (isLive) return // live nutzt Video.js nicht
|
||||
|
||||
installAbsoluteTimelineShim(p)
|
||||
|
||||
@ -821,13 +769,13 @@ export default function Player({
|
||||
delete p.__serverSeekAbs
|
||||
} catch {}
|
||||
}
|
||||
}, [job.output, isRunning])
|
||||
}, [job.output, isLive])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!mounted) return
|
||||
if (!containerRef.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
|
||||
|
||||
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(() => {
|
||||
const p = playerRef.current
|
||||
@ -944,7 +892,7 @@ export default function Player({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mounted) return
|
||||
if (!isRunning && !metaReady) {
|
||||
if (!isLive && !metaReady) {
|
||||
releaseMedia()
|
||||
return
|
||||
}
|
||||
@ -1025,11 +973,11 @@ export default function Player({
|
||||
})
|
||||
|
||||
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(() => {
|
||||
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 (!media.src) return
|
||||
|
||||
@ -1100,7 +1048,7 @@ export default function Player({
|
||||
}
|
||||
}, [
|
||||
mounted,
|
||||
isRunning,
|
||||
isLive,
|
||||
metaReady,
|
||||
media.src,
|
||||
playbackKey,
|
||||
@ -1108,23 +1056,6 @@ export default function Player({
|
||||
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(() => {
|
||||
const p = playerRef.current
|
||||
if (!p || (p as any).isDisposed?.()) return
|
||||
@ -1226,7 +1157,7 @@ export default function Player({
|
||||
if (typeof window === 'undefined') return r
|
||||
|
||||
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 maxW = vw - MARGIN * 2
|
||||
@ -1261,7 +1192,7 @@ export default function Player({
|
||||
|
||||
return { x, y, w: Math.round(w), h }
|
||||
},
|
||||
[getVideoAspectRatio, isRunning]
|
||||
[getVideoAspectRatio, isLive]
|
||||
)
|
||||
|
||||
const loadRect = React.useCallback(() => {
|
||||
@ -1642,11 +1573,11 @@ export default function Player({
|
||||
const phaseRaw = String((job as any).phase ?? '')
|
||||
const phase = phaseRaw.toLowerCase()
|
||||
const isStoppingLike = phase === 'stopping' || phase === 'remuxing' || phase === 'moving'
|
||||
const stopDisabled = !onStopJob || !isRunning || isStoppingLike || stopPending
|
||||
const stopDisabled = !onStopJob || !isLive || isStoppingLike || stopPending
|
||||
|
||||
const footerRight = (
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{isRunning ? (
|
||||
{isLive ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
@ -1747,7 +1678,7 @@ export default function Player({
|
||||
|
||||
const fullSize = expanded || miniDesktop
|
||||
|
||||
const metaBottom = isRunning
|
||||
const metaBottom = isLive
|
||||
? `calc(4px + 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')}
|
||||
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
|
||||
>
|
||||
{isRunning ? (
|
||||
{isLive ? (
|
||||
<div className="absolute inset-0 bg-black">
|
||||
<LiveVideo
|
||||
src={liveHlsSrc}
|
||||
@ -1782,7 +1713,7 @@ export default function Player({
|
||||
setLiveVolume(nextVolume)
|
||||
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">
|
||||
@ -1899,7 +1830,7 @@ export default function Player({
|
||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{resolutionLabel}</span>
|
||||
) : null}
|
||||
|
||||
{!isRunning ? (
|
||||
{!isLive ? (
|
||||
<>
|
||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">
|
||||
{runtimeLabel}
|
||||
@ -1945,7 +1876,7 @@ export default function Player({
|
||||
|
||||
<div className="pointer-events-auto">
|
||||
<div className="flex items-center justify-center gap-2 flex-wrap">
|
||||
{isRunning ? (
|
||||
{isLive ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
color="red"
|
||||
@ -2020,7 +1951,7 @@ export default function Player({
|
||||
}
|
||||
: 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"
|
||||
/>
|
||||
</div>
|
||||
@ -2120,7 +2051,7 @@ export default function Player({
|
||||
{resolutionLabel !== '—' ? (
|
||||
<span className="rounded bg-black/45 px-1.5 py-0.5">{resolutionLabel}</span>
|
||||
) : null}
|
||||
{!isRunning && runtimeLabel !== '—' ? (
|
||||
{!isLive && runtimeLabel !== '—' ? (
|
||||
<span className="rounded bg-black/45 px-1.5 py-0.5">{runtimeLabel}</span>
|
||||
) : null}
|
||||
</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
|
||||
gapPx?: number
|
||||
className?: string
|
||||
expandMode?: 'cover' | 'dropdown'
|
||||
dropdownMaxHeightClassName?: string
|
||||
}
|
||||
|
||||
export default function TagOverflowRow({
|
||||
@ -27,6 +29,8 @@ export default function TagOverflowRow({
|
||||
maxWidthClassName,
|
||||
gapPx = 6,
|
||||
className,
|
||||
expandMode = 'cover',
|
||||
dropdownMaxHeightClassName = 'max-h-[40vh]',
|
||||
}: Props) {
|
||||
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(() => {
|
||||
if (!open) return
|
||||
|
||||
@ -181,11 +204,15 @@ export default function TagOverflowRow({
|
||||
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
|
||||
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
|
||||
const restAll = sortedTags.length - visibleTags.length
|
||||
|
||||
const keepInlineRowVisible = expandMode === 'dropdown'
|
||||
const showInlineRow = !open || keepInlineRowVisible
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className="relative h-full min-h-[1.75rem]">
|
||||
|
||||
{/* collapsed row (in footer) */}
|
||||
{!open ? (
|
||||
{showInlineRow ? (
|
||||
<div
|
||||
ref={rowWrapRef}
|
||||
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
|
||||
type="button"
|
||||
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',
|
||||
// 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',
|
||||
// neutral colors (damit es sich als “Control” abhebt)
|
||||
'bg-gray-100 text-gray-700 hover:bg-gray-200/70',
|
||||
'dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20',
|
||||
open
|
||||
? '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'
|
||||
: '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(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
setOpen((prev) => !prev)
|
||||
}}
|
||||
title="Alle Tags anzeigen"
|
||||
title={open ? 'Tag-Liste schließen' : 'Alle Tags anzeigen'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={false}
|
||||
aria-expanded={open}
|
||||
>
|
||||
+{restAll}
|
||||
</button>
|
||||
@ -240,56 +265,107 @@ export default function TagOverflowRow({
|
||||
|
||||
{/* overlay that covers the whole footer host */}
|
||||
{open ? (
|
||||
<div
|
||||
style={overlayStyle}
|
||||
className={[
|
||||
'absolute z-30',
|
||||
'border border-gray-200 bg-white shadow-sm',
|
||||
'dark:border-white/10 dark:bg-gray-950',
|
||||
'pointer-events-auto',
|
||||
'flex flex-col',
|
||||
].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"
|
||||
expandMode === 'cover' ? (
|
||||
<div
|
||||
style={overlayStyle}
|
||||
className={[
|
||||
'absolute z-30',
|
||||
'border border-gray-200 bg-white shadow-sm',
|
||||
'dark:border-white/10 dark:bg-gray-950',
|
||||
'pointer-events-auto',
|
||||
'flex flex-col',
|
||||
].join(' ')}
|
||||
onClick={stop}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
role="dialog"
|
||||
aria-label="Tags"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<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="min-h-0 flex-1 overflow-auto px-3 pb-3 pr-2 pt-3">
|
||||
<div className="mb-2 pr-10">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Alle Tags
|
||||
<div className="min-h-0 flex-1 overflow-auto px-3 pb-3 pr-2 pt-3">
|
||||
<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>
|
||||
) : (
|
||||
<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="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 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>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* hidden measure area */}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import 'flag-icons/css/flag-icons.min.css';
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user