updated finisheddownloads
This commit is contained in:
parent
dffe5dde16
commit
6682b51f76
@ -695,7 +695,7 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// ffmpeg args: input = m3u8, output = fragmented mp4 to stdout
|
||||
// ✅ nur Video (kein Audio), damit MSE codecs stabil sind
|
||||
// ✅ Video + Audio für Browser-Playback
|
||||
args := []string{"-hide_banner", "-loglevel", "error"}
|
||||
if ua != "" {
|
||||
args = append(args, "-user_agent", ua)
|
||||
@ -707,9 +707,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
||||
// Input
|
||||
args = append(args, "-i", m3u8)
|
||||
|
||||
// Video encode (low-latency-ish)
|
||||
// Video + Audio encode (low-latency-ish)
|
||||
args = append(args,
|
||||
"-an",
|
||||
"-map", "0:v:0",
|
||||
"-map", "0:a:0?",
|
||||
"-vf", "scale=480:-2",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
@ -720,6 +721,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
||||
"-g", "48",
|
||||
"-keyint_min", "48",
|
||||
"-sc_threshold", "0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ac", "2",
|
||||
"-ar", "48000",
|
||||
)
|
||||
|
||||
// Output: fMP4 fragmented to stdout (single HTTP response)
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -60,22 +59,6 @@ func RecordStream(
|
||||
jobsMu.Unlock()
|
||||
}
|
||||
|
||||
if job != nil && strings.TrimSpace(job.PreviewDir) == "" {
|
||||
assetID := assetIDForJob(job)
|
||||
if strings.TrimSpace(assetID) == "" {
|
||||
assetID = job.ID
|
||||
}
|
||||
previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID)
|
||||
|
||||
jobsMu.Lock()
|
||||
job.PreviewDir = previewDir
|
||||
jobsMu.Unlock()
|
||||
|
||||
if err := startPreviewHLS(ctx, job, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil {
|
||||
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Datei öffnen
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -89,22 +88,6 @@ func RecordStreamMFC(
|
||||
_ = publishJob(job.ID)
|
||||
}
|
||||
|
||||
// ✅ Preview starten
|
||||
if job != nil && job.PreviewDir == "" {
|
||||
assetID := assetIDForJob(job)
|
||||
if strings.TrimSpace(assetID) == "" {
|
||||
assetID = job.ID
|
||||
}
|
||||
previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID)
|
||||
|
||||
job.PreviewDir = previewDir
|
||||
|
||||
if err := startPreviewHLS(ctx, job, m3u8URL, previewDir, "", hc.userAgent); err != nil {
|
||||
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
|
||||
job.PreviewDir = "" // rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Aufnahme starten
|
||||
return handleM3U8Mode(ctx, m3u8URL, outputPath, job)
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"teaserPlayback": "hover",
|
||||
"teaserAudio": false,
|
||||
"teaserAudio": true,
|
||||
"enableNotifications": true,
|
||||
"encryptedCookies": ""
|
||||
}
|
||||
|
||||
@ -171,10 +171,10 @@ func loadSettings() {
|
||||
|
||||
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
|
||||
ffmpegPath = detectFFmpegPath()
|
||||
//fmt.Println("🔍 ffmpegPath:", ffmpegPath)
|
||||
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
|
||||
|
||||
ffprobePath = detectFFprobePath()
|
||||
//fmt.Println("🔍 ffprobePath:", ffprobePath)
|
||||
fmt.Println("🔍 ffprobePath:", ffprobePath)
|
||||
|
||||
}
|
||||
|
||||
@ -403,10 +403,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
ffmpegPath = detectFFmpegPath()
|
||||
}
|
||||
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
|
||||
//fmt.Println("🔍 ffmpegPath:", ffmpegPath)
|
||||
|
||||
ffprobePath = detectFFprobePath()
|
||||
fmt.Println("🔍 ffprobePath:", ffprobePath)
|
||||
//fmt.Println("🔍 ffprobePath:", ffprobePath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
@ -27,6 +27,7 @@ import { isHotName, stripHotPrefix } from './hotName'
|
||||
import LabeledSwitch from './LabeledSwitch'
|
||||
import Switch from './Switch'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'
|
||||
|
||||
type SortMode =
|
||||
| 'completed_desc'
|
||||
@ -38,8 +39,6 @@ type SortMode =
|
||||
| 'size_desc'
|
||||
| 'size_asc'
|
||||
|
||||
type TeaserPlaybackMode = 'still' | 'hover' | 'all'
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
doneJobs: RecordJob[]
|
||||
@ -357,6 +356,16 @@ export default function FinishedDownloads({
|
||||
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
|
||||
const refillSessionRef = React.useRef(0)
|
||||
|
||||
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
|
||||
() => ({
|
||||
mode: teaserPlaybackMode,
|
||||
activeKey: teaserKey,
|
||||
hoverKey: hoverTeaserKey,
|
||||
audioEnabled: Boolean(teaserAudio),
|
||||
}),
|
||||
[teaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
|
||||
)
|
||||
|
||||
type UndoAction =
|
||||
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
|
||||
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
|
||||
@ -1928,7 +1937,7 @@ export default function FinishedDownloads({
|
||||
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
|
||||
useEffect(() => {
|
||||
const active =
|
||||
teaserPlaybackMode === 'hover' &&
|
||||
teaserState.mode === 'hover' &&
|
||||
!canHover &&
|
||||
(view === 'cards' || view === 'gallery' || view === 'table')
|
||||
|
||||
@ -1985,7 +1994,7 @@ export default function FinishedDownloads({
|
||||
io.disconnect()
|
||||
if (teaserIORef.current === io) teaserIORef.current = null
|
||||
}
|
||||
}, [view, teaserPlaybackMode, canHover, inlinePlay?.key])
|
||||
}, [view, teaserState.mode, canHover, inlinePlay?.key])
|
||||
|
||||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||||
const runtimeOf = (job: RecordJob): string => {
|
||||
@ -2456,10 +2465,7 @@ export default function FinishedDownloads({
|
||||
isLoading={isLoading}
|
||||
blurPreviews={blurPreviews}
|
||||
durations={durations}
|
||||
teaserKey={teaserKey}
|
||||
teaserPlayback={teaserPlaybackMode}
|
||||
teaserAudio={teaserAudio}
|
||||
hoverTeaserKey={hoverTeaserKey}
|
||||
teaserState={teaserState}
|
||||
inlinePlay={inlinePlay}
|
||||
deletingKeys={deletingKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
@ -2515,11 +2521,8 @@ export default function FinishedDownloads({
|
||||
resolutions={resolutions}
|
||||
durations={durations}
|
||||
canHover={canHover}
|
||||
teaserAudio={teaserAudio}
|
||||
hoverTeaserKey={hoverTeaserKey}
|
||||
teaserState={teaserState}
|
||||
setHoverTeaserKey={setHoverTeaserKey}
|
||||
teaserPlayback={teaserPlaybackMode}
|
||||
teaserKey={teaserKey}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
handleDuration={handleDuration}
|
||||
handleResolution={handleResolution}
|
||||
@ -2556,10 +2559,7 @@ export default function FinishedDownloads({
|
||||
blurPreviews={blurPreviews}
|
||||
durations={durations}
|
||||
handleDuration={handleDuration}
|
||||
teaserKey={teaserKey}
|
||||
teaserPlayback={teaserPlaybackMode}
|
||||
teaserAudio={teaserAudio}
|
||||
hoverTeaserKey={hoverTeaserKey}
|
||||
teaserState={teaserState}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
|
||||
@ -11,6 +11,11 @@ import TagOverflowRow from './TagOverflowRow'
|
||||
import PreviewScrubber from './PreviewScrubber'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
import { formatResolution } from './formatters'
|
||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||
import {
|
||||
shouldAnimateTeaser,
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
|
||||
type InlinePlayState = { key: string; nonce: number } | null
|
||||
|
||||
@ -18,14 +23,11 @@ type Props = {
|
||||
rows: RecordJob[]
|
||||
isLoading?: boolean
|
||||
isSmall: boolean
|
||||
teaserPlayback: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
hoverTeaserKey?: string | null
|
||||
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
teaserKey: string | null
|
||||
inlinePlay: InlinePlayState
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
|
||||
deletingKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
@ -524,12 +526,9 @@ export default function FinishedDownloadsCardsView({
|
||||
rows,
|
||||
isSmall,
|
||||
isLoading,
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
blurPreviews,
|
||||
durations,
|
||||
teaserKey,
|
||||
teaserState,
|
||||
inlinePlay,
|
||||
deletingKeys,
|
||||
keepingKeys,
|
||||
@ -704,10 +703,12 @@ export default function FinishedDownloadsCardsView({
|
||||
const realInlineActive = inlinePlay?.key === k
|
||||
const inlineActive = opts?.disableInline ? false : realInlineActive
|
||||
|
||||
const allowSound =
|
||||
!opts?.forceStill &&
|
||||
Boolean(teaserAudio) &&
|
||||
(inlineActive || hoverTeaserKey === k)
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
inlineActive,
|
||||
disabled: Boolean(opts?.forceStill),
|
||||
})
|
||||
|
||||
const previewMuted = !allowSound
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
@ -717,15 +718,13 @@ export default function FinishedDownloadsCardsView({
|
||||
// ✅ Im Mobile-Stack soll nur die Top-Card Teaser-Video bekommen.
|
||||
// Untere Karten zeigen immer nur Bild (Still), selbst wenn teaserKey mal matcht.
|
||||
const allowTeaserAnimation =
|
||||
opts?.forceStill
|
||||
? false
|
||||
: opts?.mobileStackTopOnlyVideo
|
||||
? (teaserPlayback === 'still' ? false : true)
|
||||
: (teaserPlayback === 'all'
|
||||
? true
|
||||
: teaserPlayback === 'hover'
|
||||
? teaserKey === k
|
||||
: false)
|
||||
opts?.mobileStackTopOnlyVideo
|
||||
? teaserState.mode !== 'still'
|
||||
: shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
disabled: Boolean(opts?.forceStill),
|
||||
})
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
|
||||
|
||||
@ -14,6 +14,12 @@ 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'
|
||||
|
||||
type ModelFlags = {
|
||||
favorite?: boolean
|
||||
@ -35,10 +41,7 @@ type Props = {
|
||||
isLoading?: boolean
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
teaserPlayback: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
hoverTeaserKey?: string | null
|
||||
teaserKey: string | null
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
|
||||
@ -150,10 +153,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
durations,
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
teaserKey,
|
||||
teaserState,
|
||||
|
||||
handleDuration,
|
||||
handleScrubberClickIndex,
|
||||
@ -189,18 +189,18 @@ export default function FinishedDownloadsGalleryView({
|
||||
enqueueToggleHot,
|
||||
}: Props) {
|
||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||||
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
|
||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||
|
||||
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
|
||||
const registerTeaserHostIfNeeded = React.useCallback(
|
||||
(key: string) => (el: HTMLDivElement | null) => {
|
||||
if (!shouldObserveTeasers) {
|
||||
if (!observeTeasers) {
|
||||
registerTeaserHost(key)(null)
|
||||
return
|
||||
}
|
||||
registerTeaserHost(key)(el)
|
||||
},
|
||||
[registerTeaserHost, shouldObserveTeasers]
|
||||
[registerTeaserHost, observeTeasers]
|
||||
)
|
||||
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
@ -276,7 +276,10 @@ export default function FinishedDownloadsGalleryView({
|
||||
const k = keyFor(j)
|
||||
|
||||
// Sound nur bei Hover auf genau diesem Teaser
|
||||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
})
|
||||
const previewMuted = !allowSound
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
@ -490,15 +493,11 @@ export default function FinishedDownloadsGalleryView({
|
||||
variant="fill"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated={
|
||||
hideTeaserUnderOverlay
|
||||
? false
|
||||
: teaserPlayback === 'all'
|
||||
? true
|
||||
: teaserPlayback === 'hover'
|
||||
? teaserKey === k
|
||||
: false
|
||||
}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
disabled: hideTeaserUnderOverlay,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
muted={previewMuted}
|
||||
|
||||
@ -5,12 +5,16 @@
|
||||
import * as React from 'react'
|
||||
import Table, { type Column, type SortState } from './Table'
|
||||
import type { RecordJob } from '../../types'
|
||||
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
import { formatResolution } from './formatters'
|
||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||
import {
|
||||
shouldAnimateTeaser,
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
|
||||
type SortMode =
|
||||
| 'completed_desc'
|
||||
@ -39,11 +43,8 @@ type Props = {
|
||||
|
||||
// teaser/preview
|
||||
canHover: boolean
|
||||
teaserAudio?: boolean
|
||||
hoverTeaserKey: string | null
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>>
|
||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||
teaserKey: string | null
|
||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||
@ -95,11 +96,8 @@ export default function FinishedDownloadsTableView({
|
||||
durations,
|
||||
|
||||
canHover,
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
teaserState,
|
||||
setHoverTeaserKey,
|
||||
teaserPlayback = 'hover',
|
||||
teaserKey,
|
||||
registerTeaserHost,
|
||||
handleDuration,
|
||||
handleResolution,
|
||||
@ -225,7 +223,10 @@ export default function FinishedDownloadsTableView({
|
||||
widthClassName: 'w-[140px]',
|
||||
cell: (j) => {
|
||||
const k = keyFor(j)
|
||||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
})
|
||||
const previewMuted = !allowSound
|
||||
const spriteInfo = previewSpriteInfoOf(j)
|
||||
const scrubActiveIndex = scrubActiveByKey[k]
|
||||
@ -268,7 +269,10 @@ export default function FinishedDownloadsTableView({
|
||||
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
assetNonce={assetNonce}
|
||||
@ -474,16 +478,13 @@ export default function FinishedDownloadsTableView({
|
||||
keyFor,
|
||||
baseName,
|
||||
durations,
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
registerTeaserHost,
|
||||
canHover,
|
||||
setHoverTeaserKey,
|
||||
handleDuration,
|
||||
handleResolution,
|
||||
blurPreviews,
|
||||
teaserPlayback,
|
||||
teaserKey,
|
||||
teaserState,
|
||||
assetNonce,
|
||||
lower,
|
||||
modelNameFromOutput,
|
||||
|
||||
69
frontend/src/components/ui/teaserPlayback.ts
Normal file
69
frontend/src/components/ui/teaserPlayback.ts
Normal file
@ -0,0 +1,69 @@
|
||||
// frontend\src\components\ui\teaserPlayback.ts
|
||||
|
||||
export type TeaserPlaybackMode = 'still' | 'hover' | 'all'
|
||||
|
||||
export type FinishedDownloadsTeaserState = {
|
||||
mode: TeaserPlaybackMode
|
||||
activeKey: string | null
|
||||
hoverKey: string | null
|
||||
audioEnabled: boolean
|
||||
}
|
||||
|
||||
type TeaserAnimationOptions = {
|
||||
state: FinishedDownloadsTeaserState
|
||||
itemKey: string
|
||||
disabled?: boolean
|
||||
forceAll?: boolean
|
||||
}
|
||||
|
||||
type TeaserAudioOptions = {
|
||||
state: FinishedDownloadsTeaserState
|
||||
itemKey: string
|
||||
inlineActive?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function shouldObserveTeasers(mode: TeaserPlaybackMode): boolean {
|
||||
return mode === 'hover' || mode === 'all'
|
||||
}
|
||||
|
||||
export function shouldAnimateTeaser({
|
||||
state,
|
||||
itemKey,
|
||||
disabled = false,
|
||||
forceAll = false,
|
||||
}: TeaserAnimationOptions): boolean {
|
||||
if (disabled) return false
|
||||
if (forceAll) return true
|
||||
|
||||
switch (state.mode) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'hover':
|
||||
return state.hoverKey === itemKey
|
||||
case 'still':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldEnableTeaserAudio({
|
||||
state,
|
||||
itemKey,
|
||||
inlineActive = false,
|
||||
disabled = false,
|
||||
}: TeaserAudioOptions): boolean {
|
||||
if (disabled) return false
|
||||
if (!state.audioEnabled) return false
|
||||
|
||||
// Inline immer bevorzugen
|
||||
if (inlineActive) return true
|
||||
|
||||
// Desktop-Hover
|
||||
if (state.hoverKey === itemKey) return true
|
||||
|
||||
// Mobile / aktiver Teaser im Viewport
|
||||
if (state.activeKey === itemKey) return true
|
||||
|
||||
return false
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user