updated - BACKUP -

This commit is contained in:
Linrador 2026-04-29 07:14:05 +02:00
parent 590b113190
commit bd2dc1f965
9 changed files with 619 additions and 83 deletions

View File

@ -62,9 +62,9 @@ func nsfwSegmentSeverityWeight(label string) float64 {
switch strings.ToLower(strings.TrimSpace(label)) {
case "female_genitalia_exposed":
return 1.00
case "anus_exposed":
return 0.95
case "female_breast_exposed":
return 0.95
case "anus_exposed":
return 0.85
case "male_genitalia_exposed":
return 0.80

View File

@ -11,6 +11,7 @@
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 300,
"autoDeleteSmallDownloadsKeepFavorites": true,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "all",

View File

@ -5084,6 +5084,8 @@ export default function FinishedDownloads({
assetNonce={assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
/>
)}
</div>

View File

@ -683,9 +683,17 @@ function FinishedDownloadsCardsView({
>
{/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
{isSmall ? (
!inlineActive && renderRatingOverlay ? (
<div className="pointer-events-none absolute bottom-2 left-2 z-[34]">
{renderRatingOverlay(j)}
</div>
) : null
) : (
<PreviewRatingOverlay hidden={inlineActive}>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
)}
<PreviewMetaOverlay
hidden={inlineActive}

View File

@ -2,7 +2,7 @@
'use client'
import { useState, useMemo, useCallback, memo, type ReactNode, type SyntheticEvent } from 'react'
import { useState, useMemo, useCallback, useRef, memo, type ReactNode, type SyntheticEvent } from 'react'
import type {
RecordJob,
GalleryModelFlags,
@ -19,6 +19,7 @@ import {
shouldEnableTeaserAudio,
} from './teaserPlayback'
import Checkbox from './Checkbox'
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
import {
buildSpriteFrameStyle,
parseJobMeta,
@ -98,6 +99,9 @@ type Props = {
titleClass: string
sublineClass: string
assetNonce?: number
mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void
}
function FinishedDownloadsGalleryCardInner({
@ -144,6 +148,8 @@ function FinishedDownloadsGalleryCardInner({
jobForDetails,
renderIntegrityBadge,
renderRatingOverlay,
mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio,
contentPaddingClass,
footerMinHeightClass,
titleClass,
@ -156,6 +162,19 @@ function FinishedDownloadsGalleryCardInner({
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
const teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({})
const [audioButtonDismissed, setAudioButtonDismissed] = useState(false)
const [teaserAudioEnabled, setTeaserAudioEnabled] = useState(false)
const registerTeaserPlayForKey = useCallback(
(key: string) => (fn: (() => void) | null) => {
if (fn) teaserPlayFnsRef.current[key] = fn
else delete teaserPlayFnsRef.current[key]
},
[]
)
const handlePrimaryClick = useCallback(
(e?: SyntheticEvent) => {
e?.preventDefault()
@ -192,7 +211,15 @@ function FinishedDownloadsGalleryCardInner({
state: teaserState,
itemKey: k,
})
const previewMuted = forcePreviewMuted ? true : !allowSound
const previewMuted = forcePreviewMuted
? true
: !(allowSound || mobileTeaserAudioUnlocked || teaserAudioEnabled)
const showMobileAudioButton =
Boolean(onUnlockMobileTeaserAudio) &&
!mobileTeaserAudioUnlocked &&
!teaserAudioEnabled &&
!audioButtonDismissed
const model = modelNameFromOutput(j.output)
@ -382,12 +409,55 @@ function FinishedDownloadsGalleryCardInner({
preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'}
forceActive={isPreviewActive}
teaserPreloadEnabled
registerTeaserPlay={registerTeaserPlayForKey(k)}
/>
</div>
{/* Desktop/Tablet: wie bisher */}
<div className="hidden sm:block">
<PreviewRatingOverlay>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
</div>
{/* Mobile: Stern unten links */}
{renderRatingOverlay ? (
<div className="pointer-events-none absolute bottom-2 left-2 z-[34] sm:hidden">
{renderRatingOverlay(j)}
</div>
) : null}
{showMobileAudioButton ? (
<button
type="button"
className="
absolute top-2 right-2 z-[35] inline-flex items-center gap-1.5 rounded-full
px-2.5 py-1.5 text-[11px] font-medium backdrop-blur transition sm:hidden
bg-white/95 text-gray-900 ring-1 ring-gray-200 shadow-sm hover:bg-white
dark:bg-black/70 dark:text-white dark:ring-white/10 dark:hover:bg-black/80
focus:outline-none focus:ring-2 focus:ring-indigo-500
"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setAudioButtonDismissed(true)
setTeaserAudioEnabled(true)
onUnlockMobileTeaserAudio?.()
requestAnimationFrame(() => {
teaserPlayFnsRef.current[k]?.()
})
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
aria-label="Teaser mit Ton abspielen"
title="Ton aktivieren"
>
<SpeakerWaveIcon className="size-3.5 shrink-0" />
<span>Ton aktivieren</span>
</button>
) : null}
<PreviewMetaOverlay
dur={dur}

View File

@ -70,6 +70,8 @@ type Props = {
onRecommendedPageSizeChange?: (size: number) => void
forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void
assetNonce?: number
renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
@ -131,6 +133,8 @@ function FinishedDownloadsGalleryView({
onAddToDownloads,
onRecommendedPageSizeChange,
forcePreviewMuted,
mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio,
assetNonce,
renderIntegrityBadge,
renderRatingOverlay,
@ -320,6 +324,8 @@ function FinishedDownloadsGalleryView({
assetNonce={assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={onUnlockMobileTeaserAudio}
/>
</div>
)

View File

@ -22,7 +22,12 @@ import {
XMarkIcon,
SpeakerXMarkIcon,
SpeakerWaveIcon,
ListBulletIcon,
} from '@heroicons/react/24/outline'
import {
RatingSegmentsPanel,
readRatingSegments,
} from './RatingOverlay'
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
import RecordJobActions from './RecordJobActions'
import Button from './Button'
@ -174,6 +179,28 @@ function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
function pickPlayerMetaRaw(job: RecordJob, fetched?: unknown): unknown {
const j = job as any
const f = fetched as any
const candidates = [
f?.metaRaw,
f?.meta,
f?.analysis || f?.ai || f?.preview || f?.previewSprite || f?.preview_sprite ? f : null,
j?.metaRaw,
j?.meta,
j?.analysis || j?.ai || j?.preview || j?.previewSprite || j?.preview_sprite ? j : null,
]
for (const v of candidates) {
if (typeof v === 'string' && v.trim()) return v
if (v && typeof v === 'object') return v
}
return null
}
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false)
@ -316,6 +343,26 @@ export default function Player({
const sizeLabel = useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any
const [playerMetaRaw, setPlayerMetaRaw] = useState<unknown | null>(() =>
pickPlayerMetaRaw(job)
)
const PLAYER_SEGMENTS_OPEN_KEY = 'player_segments_open_v1'
const [segmentsPanelOpen, setSegmentsPanelOpen] = useState<boolean>(() => {
if (typeof window === 'undefined') return true
return window.localStorage.getItem(PLAYER_SEGMENTS_OPEN_KEY) !== '0'
})
useEffect(() => {
try {
window.localStorage.setItem(
PLAYER_SEGMENTS_OPEN_KEY,
segmentsPanelOpen ? '1' : '0'
)
} catch {}
}, [segmentsPanelOpen])
const [fullDurationSec, setFullDurationSec] = useState<number>(() => {
return (
Number((job as any)?.meta?.durationSeconds) ||
@ -372,6 +419,8 @@ export default function Player({
if (!res.ok) return
const j = await res.json()
setPlayerMetaRaw((prev: unknown | null) => pickPlayerMetaRaw(job, j) ?? prev)
const dur = Number(j?.durationSeconds || j?.meta?.durationSeconds || 0) || 0
if (!alive || dur <= 0) return
@ -979,6 +1028,13 @@ export default function Player({
}
}, [])
const ratingSegments = useMemo(
() => readRatingSegments(playerMetaRaw),
[playerMetaRaw]
)
const hasRatingSegments = ratingSegments.length > 0
useEffect(() => {
if (!mounted) return
if (!isLive && !metaReady) {
@ -1412,6 +1468,37 @@ export default function Player({
const pendingPosRef = useRef<{ x: number; y: number } | null>(null)
const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(null)
const HOLD_TO_DRAG_MS = 220
const HOLD_MOVE_TOLERANCE = 6
const holdDragTimerRef = useRef<number | null>(null)
const suppressClickUntilRef = useRef(0)
const isNoDragTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false
return Boolean(
target.closest(
[
'button',
'a',
'input',
'textarea',
'select',
'label',
'[role="button"]',
'[role="menu"]',
'[data-no-player-drag]',
'.vjs-control-bar',
'.vjs-control',
'.vjs-menu',
'.vjs-modal-dialog',
'.vjs-big-play-button',
].join(',')
)
)
}
const applySnap = useCallback((r: WinRect): WinRect => {
const { w: vw, h: vh } = getViewport()
const leftEdge = MARGIN
@ -1471,30 +1558,86 @@ export default function Player({
})
}, [onDragMove, applySnap, clampMiniRect, saveRect])
const beginDrag = useCallback(
(e: ReactPointerEvent<HTMLElement>) => {
if (!miniDesktop) return
if (isResizing) return
if (e.button !== 0) return
e.preventDefault()
e.stopPropagation()
const startDragAt = useCallback(
(clientX: number, clientY: number) => {
if (!miniDesktop) return false
if (isResizing) return false
const start = winRef.current
draggingRef.current = { sx: e.clientX, sy: e.clientY, start }
draggingRef.current = { sx: clientX, sy: clientY, start }
setIsDragging(true)
// ✅ möglichst echten aktuellen Frame für Ghost verwenden (Fallback bleibt previewSrc)
const frame = captureGhostFrame()
setGhostFrameSrc(frame)
// ✅ sofortige Vorschau anzeigen
setSnapPreviewRect(applySnap(start))
window.addEventListener('pointermove', onDragMove)
window.addEventListener('pointerup', endDrag)
return true
},
[miniDesktop, isResizing, onDragMove, endDrag, applySnap, captureGhostFrame]
[
miniDesktop,
isResizing,
captureGhostFrame,
applySnap,
onDragMove,
endDrag,
]
)
const beginVideoHoldDrag = useCallback(
(e: ReactPointerEvent<HTMLElement>) => {
if (!miniDesktop) return
if (isResizing) return
if (e.button !== 0) return
if (e.pointerType !== 'mouse') return
if (isNoDragTarget(e.target)) return
const startX = e.clientX
const startY = e.clientY
let cleanup = () => {}
const onMoveBeforeHold = (ev: globalThis.PointerEvent) => {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (Math.hypot(dx, dy) > HOLD_MOVE_TOLERANCE) {
cleanup()
}
}
const onUpBeforeHold = () => {
cleanup()
}
cleanup = () => {
if (holdDragTimerRef.current != null) {
window.clearTimeout(holdDragTimerRef.current)
holdDragTimerRef.current = null
}
window.removeEventListener('pointermove', onMoveBeforeHold)
window.removeEventListener('pointerup', onUpBeforeHold)
}
window.addEventListener('pointermove', onMoveBeforeHold)
window.addEventListener('pointerup', onUpBeforeHold)
holdDragTimerRef.current = window.setTimeout(() => {
cleanup()
const started = startDragAt(startX, startY)
if (started) {
// verhindert Play/Pause-Klick nach dem Loslassen
suppressClickUntilRef.current = Date.now() + 1000
}
}, HOLD_TO_DRAG_MS)
},
[miniDesktop, isResizing, startDragAt]
)
// pointermove kommt extrem oft -> wir committen max. 1x pro Frame
@ -1632,18 +1775,6 @@ export default function Player({
[miniDesktop, onResizeMove, endResize]
)
const [canHover, setCanHover] = useState(false)
const [chromeHover, setChromeHover] = useState(false)
useEffect(() => {
const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)')
const update = () => setCanHover(Boolean(mq?.matches))
update()
mq?.addEventListener?.('change', update)
return () => mq?.removeEventListener?.('change', update)
}, [])
const dragUiActive = miniDesktop && (chromeHover || isDragging || isResizing)
const [stopPending, setStopPending] = useState(false)
useEffect(() => {
@ -1779,13 +1910,13 @@ export default function Player({
className={cn(
'relative overflow-visible flex-1 min-h-0'
)}
onMouseEnter={() => {
if (!miniDesktop || !canHover) return
setChromeHover(true)
}}
onMouseLeave={() => {
if (!miniDesktop || !canHover) return
setChromeHover(false)
onPointerDownCapture={beginVideoHoldDrag}
onClickCapture={(e) => {
if (Date.now() >= suppressClickUntilRef.current) return
suppressClickUntilRef.current = 0
e.preventDefault()
e.stopPropagation()
}}
>
<div
@ -1863,33 +1994,47 @@ export default function Player({
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/35 to-transparent" />
<div className={cn('absolute inset-x-2', topOverlayTop)}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-start gap-2">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2">
<div className="min-w-0 pointer-events-auto overflow-visible">{showSideInfo ? null : footerRight}</div>
{miniDesktop ? (
<div className="shrink-0 flex items-center gap-1 pointer-events-auto">
{miniDesktop && !isLive && hasRatingSegments ? (
<button
type="button"
aria-label="Player-Fenster verschieben"
title="Ziehen zum Verschieben"
onPointerDown={beginDrag}
className={cn(
overlayBtn,
segmentsPanelOpen && 'shadow-sm'
)}
style={
segmentsPanelOpen
? {
backgroundColor: '#4f46e5',
color: '#ffffff',
}
: undefined
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setSegmentsPanelOpen((v) => !v)
}}
className={cn(
overlayBtn,
'px-3 gap-1 cursor-grab active:cursor-grabbing select-none',
dragUiActive ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none -translate-y-1',
isDragging && 'scale-[0.98] opacity-90'
)}
aria-pressed={segmentsPanelOpen}
aria-label={segmentsPanelOpen ? 'Segmente ausblenden' : 'Segmente einblenden'}
title={segmentsPanelOpen ? 'Segmente ausblenden' : 'Segmente einblenden'}
>
<span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
<span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
<span className="h-1 w-1 rounded-full bg-black/35 dark:bg-white/35" />
<ListBulletIcon
className={cn(
'h-5 w-5 transition-transform duration-200 ease-out',
segmentsPanelOpen ? 'scale-110' : ''
)}
style={{
color: segmentsPanelOpen ? '#ffffff' : undefined,
stroke: 'currentColor',
}}
/>
</button>
) : null}
<div className="shrink-0 flex items-center gap-1 pointer-events-auto">
<button
type="button"
className={overlayBtn}
@ -1953,9 +2098,11 @@ export default function Player({
</div>
)
const { w: vw, h: vh, ox, oy, bottomInset } = getViewport()
const sidePanel = (
<div className="w-[360px] shrink-0 border-r border-white/10 bg-black/40 text-white">
<div className="h-full p-4 flex flex-col gap-3 overflow-y-auto">
<div className="h-full min-h-0 p-4 flex flex-col gap-3 overflow-y-auto">
<div className="rounded-lg overflow-hidden ring-1 ring-white/10 bg-black/30">
<div className="relative aspect-video">
{/* Snapshot-Frame bevorzugen, sonst Preview-Fallback */}
@ -2094,6 +2241,17 @@ export default function Player({
)}
</div>
</div>
{!isLive && hasRatingSegments ? (
<div className="player-sidebar-segments min-h-[280px] flex-1 overflow-hidden rounded-lg bg-black/30 text-white shadow-sm ring-1 ring-black/20">
<RatingSegmentsPanel
segments={ratingSegments}
onOpenAt={seekPlayerToAbsolute}
maxHeight={Math.max(280, vh - 260)}
className="h-full max-h-full w-full !bg-transparent !text-white !border-0 !ring-0"
/>
</div>
) : null}
</div>
</div>
)
@ -2132,13 +2290,6 @@ export default function Player({
<div className="h-8 w-8 rounded-md bg-white/15 ring-1 ring-white/20" />
</div>
{/* Mini "drag grip" Andeutung */}
<div className="absolute top-2 left-2 h-8 rounded-md bg-white/12 px-2 flex items-center gap-1 ring-1 ring-white/15">
<span className="h-1 w-1 rounded-full bg-white/50" />
<span className="h-1 w-1 rounded-full bg-white/50" />
<span className="h-1 w-1 rounded-full bg-white/50" />
</div>
{/* Bottom meta bar (angelehnt an echten Player) */}
<div className="absolute inset-x-2 bottom-2 flex items-end justify-between gap-2">
<div className="min-w-0">
@ -2186,7 +2337,65 @@ export default function Player({
</Card>
)
const { w: vw, h: vh, ox, oy, bottomInset } = getViewport()
const SEGMENTS_PANEL_GAP = 10
const SEGMENTS_PANEL_MIN_W = 260
const SEGMENTS_PANEL_MAX_W = 340
const playerOnLeft = miniDesktop
? win.x + win.w / 2 < vw / 2
: false
const segmentPanelAvailableW = playerOnLeft
? vw - (win.x + win.w) - MARGIN - SEGMENTS_PANEL_GAP
: win.x - MARGIN - SEGMENTS_PANEL_GAP
const segmentPanelW = Math.min(
SEGMENTS_PANEL_MAX_W,
Math.max(SEGMENTS_PANEL_MIN_W, segmentPanelAvailableW)
)
const canRenderSegmentsPanel =
miniDesktop &&
!isLive &&
hasRatingSegments &&
!isDragging &&
!isResizing &&
segmentPanelAvailableW >= SEGMENTS_PANEL_MIN_W
const segmentsPanelEl = canRenderSegmentsPanel ? (
<div
aria-hidden={!segmentsPanelOpen}
className={cn(
'player-segments-panel absolute top-0 bottom-0 z-20 hidden overflow-hidden sm:block',
'transition-[opacity,transform,filter] duration-200 ease-[cubic-bezier(.2,.9,.2,1)] will-change-transform',
playerOnLeft ? 'origin-left' : 'origin-right',
segmentsPanelOpen
? 'pointer-events-auto translate-x-0 scale-100 opacity-100 blur-0'
: cn(
'pointer-events-none scale-[0.985] opacity-0 blur-[1px]',
playerOnLeft ? 'translate-x-3' : '-translate-x-3'
)
)}
style={{
width: segmentPanelW,
height: win.h,
maxHeight: win.h,
left: playerOnLeft
? `calc(100% + ${SEGMENTS_PANEL_GAP}px)`
: undefined,
right: !playerOnLeft
? `calc(100% + ${SEGMENTS_PANEL_GAP}px)`
: undefined,
}}
>
<RatingSegmentsPanel
segments={ratingSegments}
onOpenAt={segmentsPanelOpen ? seekPlayerToAbsolute : undefined}
maxHeight={win.h}
className="h-full max-h-full w-full"
/>
</div>
) : null
const expandedRect = {
left: ox + 16,
@ -2280,6 +2489,53 @@ export default function Player({
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
pointer-events: none;
}
.player-sidebar-segments {
background: rgba(0, 0, 0, 0.3) !important;
color: white !important;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.22);
}
.player-sidebar-segments > * {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.08) !important;
box-shadow: none !important;
}
.player-sidebar-segments [class*="ring-white"],
.player-sidebar-segments [class*="border-white"] {
--tw-ring-color: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(255, 255, 255, 0.08) !important;
}
.player-sidebar-segments [class*="bg-white"] {
background-color: rgba(255, 255, 255, 0.06) !important;
}
.player-sidebar-segments [class*="text-gray-"],
.player-sidebar-segments [class*="text-slate-"],
.player-sidebar-segments [class*="text-zinc-"],
.player-sidebar-segments [class*="text-neutral-"],
.player-sidebar-segments [class*="text-stone-"],
.player-sidebar-segments [class*="text-black"] {
color: rgba(255, 255, 255, 0.92) !important;
}
.player-sidebar-segments [class*="text-gray-4"],
.player-sidebar-segments [class*="text-gray-5"],
.player-sidebar-segments [class*="text-gray-6"],
.player-sidebar-segments [class*="text-slate-4"],
.player-sidebar-segments [class*="text-slate-5"],
.player-sidebar-segments [class*="text-slate-6"],
.player-sidebar-segments [class*="text-zinc-4"],
.player-sidebar-segments [class*="text-zinc-5"],
.player-sidebar-segments [class*="text-zinc-6"] {
color: rgba(255, 255, 255, 0.68) !important;
}
.player-sidebar-segments svg {
color: rgba(255, 255, 255, 0.85) !important;
}
`}</style>
{expanded || miniDesktop ? (
@ -2297,6 +2553,8 @@ export default function Player({
{cardEl}
{segmentsPanelEl}
{miniDesktop ? (
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />

View File

@ -21,13 +21,13 @@ type RatingEntry = {
tone?: 'normal' | 'strong' | 'muted'
}
type SegmentSpritePreview = {
export type SegmentSpritePreview = {
src: string
frameStyle: CSSProperties
imageStyle: CSSProperties
}
type RatingSegment = {
export type RatingSegment = {
key: string
title: string
timeLabel?: string
@ -825,7 +825,7 @@ function readSegmentArray(metaRaw: unknown): unknown[] {
return []
}
function readRatingSegments(metaRaw: unknown): RatingSegment[] {
export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
const rawSegments = readSegmentArray(metaRaw)
const spriteMeta = readPreviewSpriteMeta(metaRaw)
const durationSeconds = readMetaDurationSeconds(metaRaw)
@ -874,6 +874,178 @@ function readRatingSegments(metaRaw: unknown): RatingSegment[] {
.slice(0, 10) as RatingSegment[]
}
export function RatingSegmentsPanel({
metaRaw,
segments: providedSegments,
onOpenAt,
className = '',
maxHeight = 420,
}: {
metaRaw?: unknown
segments?: RatingSegment[]
onOpenAt?: (seconds: number) => void
className?: string
maxHeight?: number | string
}) {
const segments = providedSegments ?? readRatingSegments(metaRaw)
if (segments.length === 0) return null
const panelMaxHeight =
typeof maxHeight === 'number'
? `${maxHeight}px`
: maxHeight
return (
<div
className={[
'flex min-h-0 w-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl ring-1 ring-black/5',
'dark:border-white/10 dark:bg-gray-950 dark:ring-white/10',
className,
].join(' ')}
style={{
maxHeight: panelMaxHeight,
}}
>
<div className="shrink-0 border-b border-gray-200 px-3 py-2 dark:border-white/10">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Segmente
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
AI Rating · {segments.length}
</div>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-2 pb-4">
<div className="space-y-2 pb-2">
{segments.map((segment, index) => (
<button
key={`${segment.key}-${index}`}
type="button"
disabled={!onOpenAt || typeof segment.openAtSec !== 'number'}
title={
onOpenAt && typeof segment.openAtSec === 'number'
? `Video bei ${formatSeconds(segment.openAtSec)} öffnen`
: undefined
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!onOpenAt || typeof segment.openAtSec !== 'number') return
onOpenAt(segment.openAtSec)
}}
className={[
'relative block w-full overflow-hidden rounded-md text-left ring-1 transition',
onOpenAt && typeof segment.openAtSec === 'number'
? 'cursor-pointer hover:scale-[1.01] hover:ring-indigo-400/60 focus:outline-none focus:ring-2 focus:ring-indigo-500'
: 'cursor-default',
segment.preview
? 'bg-black ring-white/10'
: 'bg-gray-50 ring-gray-200 dark:bg-white/5 dark:ring-white/10',
].join(' ')}
>
{segment.preview ? (
<div className="absolute inset-0 bg-black" aria-hidden="true">
<div style={segment.preview.frameStyle}>
<img
src={segment.preview.src}
alt=""
draggable={false}
style={segment.preview.imageStyle}
/>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/45 to-black/70" />
<div className="absolute inset-0 bg-black/20" />
</div>
) : null}
<div className="relative z-10 px-2.5 py-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div
className={[
'truncate text-[12px] font-semibold',
segment.preview
? 'text-white'
: 'text-gray-900 dark:text-white',
].join(' ')}
>
{segment.title}
</div>
{segment.timeLabel ? (
<div
className={[
'mt-0.5 text-[11px] tabular-nums',
segment.preview
? 'text-white/75'
: 'text-gray-500 dark:text-gray-400',
].join(' ')}
>
{segment.timeLabel}
</div>
) : null}
</div>
{segment.valueLabel ? (
<div
className={[
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
segment.preview
? 'bg-black/45 text-white ring-white/20 backdrop-blur-sm'
: 'bg-white text-gray-800 ring-gray-200 dark:bg-black/20 dark:text-gray-100 dark:ring-white/10',
].join(' ')}
>
{segment.valueLabel}
</div>
) : null}
</div>
{segment.detail ? (
<div
className={[
'mt-1.5 text-[11px] leading-snug',
segment.preview
? 'text-white/85'
: 'text-gray-600 dark:text-gray-300',
].join(' ')}
>
{segment.detail}
</div>
) : null}
{segment.tags.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{segment.tags.map((tag) => (
<span
key={tag}
className={[
'rounded-full px-1.5 py-0.5 text-[10px] font-medium ring-1',
segment.preview
? 'bg-black/45 text-white/90 ring-white/20 backdrop-blur-sm'
: 'bg-white text-gray-600 ring-gray-200 dark:bg-black/20 dark:text-gray-300 dark:ring-white/10',
].join(' ')}
>
{tag}
</span>
))}
</div>
) : null}
</div>
</button>
))}
</div>
</div>
</div>
)
}
function ratingTone(stars: number) {
switch (stars) {
case 1:

View File

@ -549,6 +549,7 @@ export default function RecordJobActions({
const MARGIN = 8
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxH: number } | null>(null)
const [menuPortalTarget, setMenuPortalTarget] = useState<HTMLElement | null>(null)
useEffect(() => {
if (!menuOpen) return
@ -578,9 +579,16 @@ export default function RecordJobActions({
useLayoutEffect(() => {
if (!menuOpen) {
setMenuStyle(null)
setMenuPortalTarget(null)
return
}
const portalTarget =
(menuBtnRef.current?.closest('dialog[open]') as HTMLElement | null) ??
document.body
setMenuPortalTarget(portalTarget)
const update = () => {
const btn = menuBtnRef.current
if (!btn) return
@ -589,21 +597,31 @@ export default function RecordJobActions({
const vw = window.innerWidth
const vh = window.innerHeight
// rechts am Button ausrichten
let left = r.right - MENU_W
left = Math.max(MARGIN, Math.min(left, vw - MENU_W - MARGIN))
let top = r.bottom + GAP
top = Math.max(MARGIN, Math.min(top, vh - MARGIN))
const spaceBelow = vh - top - MARGIN
const spaceAbove = r.top - MARGIN
// Falls unten zu wenig Platz ist, über dem Button öffnen
if (spaceBelow < 120 && spaceAbove > spaceBelow) {
const maxH = Math.max(120, spaceAbove)
top = Math.max(MARGIN, r.top - GAP - Math.min(maxH, 320))
setMenuStyle({ top, left, maxH })
return
}
top = Math.max(MARGIN, Math.min(top, vh - MARGIN))
const maxH = Math.max(120, vh - top - MARGIN)
setMenuStyle({ top, left, maxH })
}
update()
window.addEventListener('resize', update)
// capture=true, damit auch Scroll in Container-DIVs triggert
window.addEventListener('scroll', update, true)
return () => {
@ -847,24 +865,25 @@ export default function RecordJobActions({
</span>
</button>
{menuOpen && menuStyle && typeof document !== 'undefined'
{menuOpen && menuStyle && menuPortalTarget
? createPortal(
<div
ref={menuPopupRef}
role="menu"
className="rounded-md bg-white/95 dark:bg-gray-900/95 shadow-lg ring-1 ring-black/10 dark:ring-white/10 p-1 z-[9999] overflow-auto"
className="overflow-auto rounded-md bg-white/95 p-1 text-gray-900 shadow-2xl ring-1 ring-black/10 backdrop-blur dark:bg-gray-900/95 dark:text-gray-100 dark:ring-white/10"
style={{
position: 'fixed',
top: menuStyle.top,
left: menuStyle.left,
width: MENU_W,
maxHeight: menuStyle.maxH,
zIndex: 2147483647,
}}
onClick={(e) => e.stopPropagation()}
>
{menuKeys.map(menuItem)}
</div>,
document.body
menuPortalTarget
)
: null}
</div>