updated - BACKUP -
This commit is contained in:
parent
590b113190
commit
bd2dc1f965
@ -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
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"useMyFreeCamsWatcher": true,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"teaserPlayback": "all",
|
||||
|
||||
@ -5084,6 +5084,8 @@ export default function FinishedDownloads({
|
||||
assetNonce={assetNonce ?? 0}
|
||||
renderIntegrityBadge={renderIntegrityBadge}
|
||||
renderRatingOverlay={renderRatingOverlay}
|
||||
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
|
||||
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -683,9 +683,17 @@ function FinishedDownloadsCardsView({
|
||||
>
|
||||
{/* media */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
<PreviewRatingOverlay hidden={inlineActive}>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
{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}
|
||||
|
||||
@ -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>
|
||||
|
||||
<PreviewRatingOverlay>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
{/* 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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Player-Fenster verschieben"
|
||||
title="Ziehen zum Verschieben"
|
||||
onPointerDown={beginDrag}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="shrink-0 flex items-center gap-1 pointer-events-auto">
|
||||
{miniDesktop && !isLive && hasRatingSegments ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
overlayBtn,
|
||||
segmentsPanelOpen && 'shadow-sm'
|
||||
)}
|
||||
style={
|
||||
segmentsPanelOpen
|
||||
? {
|
||||
backgroundColor: '#4f46e5',
|
||||
color: '#ffffff',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSegmentsPanelOpen((v) => !v)
|
||||
}}
|
||||
aria-pressed={segmentsPanelOpen}
|
||||
aria-label={segmentsPanelOpen ? 'Segmente ausblenden' : 'Segmente einblenden'}
|
||||
title={segmentsPanelOpen ? 'Segmente ausblenden' : 'Segmente einblenden'}
|
||||
>
|
||||
<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}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={overlayBtn}
|
||||
@ -1952,10 +2097,12 @@ export default function Player({
|
||||
</div>
|
||||
</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')} />
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user