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)) {
|
switch strings.ToLower(strings.TrimSpace(label)) {
|
||||||
case "female_genitalia_exposed":
|
case "female_genitalia_exposed":
|
||||||
return 1.00
|
return 1.00
|
||||||
case "anus_exposed":
|
|
||||||
return 0.95
|
|
||||||
case "female_breast_exposed":
|
case "female_breast_exposed":
|
||||||
|
return 0.95
|
||||||
|
case "anus_exposed":
|
||||||
return 0.85
|
return 0.85
|
||||||
case "male_genitalia_exposed":
|
case "male_genitalia_exposed":
|
||||||
return 0.80
|
return 0.80
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": true,
|
"autoDeleteSmallDownloads": true,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||||
|
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "all",
|
"teaserPlayback": "all",
|
||||||
|
|||||||
@ -5084,6 +5084,8 @@ export default function FinishedDownloads({
|
|||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
renderIntegrityBadge={renderIntegrityBadge}
|
renderIntegrityBadge={renderIntegrityBadge}
|
||||||
renderRatingOverlay={renderRatingOverlay}
|
renderRatingOverlay={renderRatingOverlay}
|
||||||
|
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
|
||||||
|
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -683,9 +683,17 @@ function FinishedDownloadsCardsView({
|
|||||||
>
|
>
|
||||||
{/* media */}
|
{/* media */}
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
<PreviewRatingOverlay hidden={inlineActive}>
|
{isSmall ? (
|
||||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
!inlineActive && renderRatingOverlay ? (
|
||||||
</PreviewRatingOverlay>
|
<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
|
<PreviewMetaOverlay
|
||||||
hidden={inlineActive}
|
hidden={inlineActive}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'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 {
|
import type {
|
||||||
RecordJob,
|
RecordJob,
|
||||||
GalleryModelFlags,
|
GalleryModelFlags,
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
shouldEnableTeaserAudio,
|
shouldEnableTeaserAudio,
|
||||||
} from './teaserPlayback'
|
} from './teaserPlayback'
|
||||||
import Checkbox from './Checkbox'
|
import Checkbox from './Checkbox'
|
||||||
|
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
|
||||||
import {
|
import {
|
||||||
buildSpriteFrameStyle,
|
buildSpriteFrameStyle,
|
||||||
parseJobMeta,
|
parseJobMeta,
|
||||||
@ -98,6 +99,9 @@ type Props = {
|
|||||||
titleClass: string
|
titleClass: string
|
||||||
sublineClass: string
|
sublineClass: string
|
||||||
assetNonce?: number
|
assetNonce?: number
|
||||||
|
|
||||||
|
mobileTeaserAudioUnlocked?: boolean
|
||||||
|
onUnlockMobileTeaserAudio?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function FinishedDownloadsGalleryCardInner({
|
function FinishedDownloadsGalleryCardInner({
|
||||||
@ -144,6 +148,8 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
jobForDetails,
|
jobForDetails,
|
||||||
renderIntegrityBadge,
|
renderIntegrityBadge,
|
||||||
renderRatingOverlay,
|
renderRatingOverlay,
|
||||||
|
mobileTeaserAudioUnlocked,
|
||||||
|
onUnlockMobileTeaserAudio,
|
||||||
contentPaddingClass,
|
contentPaddingClass,
|
||||||
footerMinHeightClass,
|
footerMinHeightClass,
|
||||||
titleClass,
|
titleClass,
|
||||||
@ -156,6 +162,19 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
||||||
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
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(
|
const handlePrimaryClick = useCallback(
|
||||||
(e?: SyntheticEvent) => {
|
(e?: SyntheticEvent) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
@ -192,7 +211,15 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
state: teaserState,
|
state: teaserState,
|
||||||
itemKey: k,
|
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)
|
const model = modelNameFromOutput(j.output)
|
||||||
|
|
||||||
@ -382,12 +409,55 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'}
|
preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'}
|
||||||
forceActive={isPreviewActive}
|
forceActive={isPreviewActive}
|
||||||
teaserPreloadEnabled
|
teaserPreloadEnabled
|
||||||
|
registerTeaserPlay={registerTeaserPlayForKey(k)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PreviewRatingOverlay>
|
{/* Desktop/Tablet: wie bisher */}
|
||||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
<div className="hidden sm:block">
|
||||||
</PreviewRatingOverlay>
|
<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
|
<PreviewMetaOverlay
|
||||||
dur={dur}
|
dur={dur}
|
||||||
|
|||||||
@ -70,6 +70,8 @@ type Props = {
|
|||||||
onRecommendedPageSizeChange?: (size: number) => void
|
onRecommendedPageSizeChange?: (size: number) => void
|
||||||
|
|
||||||
forcePreviewMuted?: boolean
|
forcePreviewMuted?: boolean
|
||||||
|
mobileTeaserAudioUnlocked?: boolean
|
||||||
|
onUnlockMobileTeaserAudio?: () => void
|
||||||
assetNonce?: number
|
assetNonce?: number
|
||||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||||
@ -131,6 +133,8 @@ function FinishedDownloadsGalleryView({
|
|||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
onRecommendedPageSizeChange,
|
onRecommendedPageSizeChange,
|
||||||
forcePreviewMuted,
|
forcePreviewMuted,
|
||||||
|
mobileTeaserAudioUnlocked,
|
||||||
|
onUnlockMobileTeaserAudio,
|
||||||
assetNonce,
|
assetNonce,
|
||||||
renderIntegrityBadge,
|
renderIntegrityBadge,
|
||||||
renderRatingOverlay,
|
renderRatingOverlay,
|
||||||
@ -320,6 +324,8 @@ function FinishedDownloadsGalleryView({
|
|||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
renderIntegrityBadge={renderIntegrityBadge}
|
renderIntegrityBadge={renderIntegrityBadge}
|
||||||
renderRatingOverlay={renderRatingOverlay}
|
renderRatingOverlay={renderRatingOverlay}
|
||||||
|
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
|
||||||
|
onUnlockMobileTeaserAudio={onUnlockMobileTeaserAudio}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -22,7 +22,12 @@ import {
|
|||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
SpeakerXMarkIcon,
|
SpeakerXMarkIcon,
|
||||||
SpeakerWaveIcon,
|
SpeakerWaveIcon,
|
||||||
|
ListBulletIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
|
import {
|
||||||
|
RatingSegmentsPanel,
|
||||||
|
readRatingSegments,
|
||||||
|
} from './RatingOverlay'
|
||||||
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
|
import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@ -174,6 +179,28 @@ function cn(...parts: Array<string | false | null | undefined>) {
|
|||||||
return parts.filter(Boolean).join(' ')
|
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) {
|
function useMediaQuery(query: string) {
|
||||||
const [matches, setMatches] = useState(false)
|
const [matches, setMatches] = useState(false)
|
||||||
|
|
||||||
@ -316,6 +343,26 @@ export default function Player({
|
|||||||
const sizeLabel = useMemo(() => formatBytes(sizeBytesOf(job)), [job])
|
const sizeLabel = useMemo(() => formatBytes(sizeBytesOf(job)), [job])
|
||||||
const anyJob = job as any
|
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>(() => {
|
const [fullDurationSec, setFullDurationSec] = useState<number>(() => {
|
||||||
return (
|
return (
|
||||||
Number((job as any)?.meta?.durationSeconds) ||
|
Number((job as any)?.meta?.durationSeconds) ||
|
||||||
@ -372,6 +419,8 @@ export default function Player({
|
|||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
const j = await res.json()
|
const j = await res.json()
|
||||||
|
setPlayerMetaRaw((prev: unknown | null) => pickPlayerMetaRaw(job, j) ?? prev)
|
||||||
|
|
||||||
const dur = Number(j?.durationSeconds || j?.meta?.durationSeconds || 0) || 0
|
const dur = Number(j?.durationSeconds || j?.meta?.durationSeconds || 0) || 0
|
||||||
if (!alive || dur <= 0) return
|
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(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (!isLive && !metaReady) {
|
if (!isLive && !metaReady) {
|
||||||
@ -1412,6 +1468,37 @@ export default function Player({
|
|||||||
const pendingPosRef = useRef<{ x: number; y: number } | null>(null)
|
const pendingPosRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(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 applySnap = useCallback((r: WinRect): WinRect => {
|
||||||
const { w: vw, h: vh } = getViewport()
|
const { w: vw, h: vh } = getViewport()
|
||||||
const leftEdge = MARGIN
|
const leftEdge = MARGIN
|
||||||
@ -1471,30 +1558,86 @@ export default function Player({
|
|||||||
})
|
})
|
||||||
}, [onDragMove, applySnap, clampMiniRect, saveRect])
|
}, [onDragMove, applySnap, clampMiniRect, saveRect])
|
||||||
|
|
||||||
const beginDrag = useCallback(
|
const startDragAt = useCallback(
|
||||||
(e: ReactPointerEvent<HTMLElement>) => {
|
(clientX: number, clientY: number) => {
|
||||||
if (!miniDesktop) return
|
if (!miniDesktop) return false
|
||||||
if (isResizing) return
|
if (isResizing) return false
|
||||||
if (e.button !== 0) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const start = winRef.current
|
const start = winRef.current
|
||||||
draggingRef.current = { sx: e.clientX, sy: e.clientY, start }
|
|
||||||
|
draggingRef.current = { sx: clientX, sy: clientY, start }
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
|
||||||
// ✅ möglichst echten aktuellen Frame für Ghost verwenden (Fallback bleibt previewSrc)
|
|
||||||
const frame = captureGhostFrame()
|
const frame = captureGhostFrame()
|
||||||
setGhostFrameSrc(frame)
|
setGhostFrameSrc(frame)
|
||||||
|
|
||||||
// ✅ sofortige Vorschau anzeigen
|
|
||||||
setSnapPreviewRect(applySnap(start))
|
setSnapPreviewRect(applySnap(start))
|
||||||
|
|
||||||
window.addEventListener('pointermove', onDragMove)
|
window.addEventListener('pointermove', onDragMove)
|
||||||
window.addEventListener('pointerup', endDrag)
|
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
|
// pointermove kommt extrem oft -> wir committen max. 1x pro Frame
|
||||||
@ -1632,18 +1775,6 @@ export default function Player({
|
|||||||
[miniDesktop, onResizeMove, endResize]
|
[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)
|
const [stopPending, setStopPending] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1779,13 +1910,13 @@ export default function Player({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-visible flex-1 min-h-0'
|
'relative overflow-visible flex-1 min-h-0'
|
||||||
)}
|
)}
|
||||||
onMouseEnter={() => {
|
onPointerDownCapture={beginVideoHoldDrag}
|
||||||
if (!miniDesktop || !canHover) return
|
onClickCapture={(e) => {
|
||||||
setChromeHover(true)
|
if (Date.now() >= suppressClickUntilRef.current) return
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
suppressClickUntilRef.current = 0
|
||||||
if (!miniDesktop || !canHover) return
|
e.preventDefault()
|
||||||
setChromeHover(false)
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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="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={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>
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={overlayBtn}
|
className={overlayBtn}
|
||||||
@ -1952,10 +2097,12 @@ export default function Player({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { w: vw, h: vh, ox, oy, bottomInset } = getViewport()
|
||||||
|
|
||||||
const sidePanel = (
|
const sidePanel = (
|
||||||
<div className="w-[360px] shrink-0 border-r border-white/10 bg-black/40 text-white">
|
<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="rounded-lg overflow-hidden ring-1 ring-white/10 bg-black/30">
|
||||||
<div className="relative aspect-video">
|
<div className="relative aspect-video">
|
||||||
{/* Snapshot-Frame bevorzugen, sonst Preview-Fallback */}
|
{/* Snapshot-Frame bevorzugen, sonst Preview-Fallback */}
|
||||||
@ -2094,6 +2241,17 @@ export default function Player({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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 className="h-8 w-8 rounded-md bg-white/15 ring-1 ring-white/20" />
|
||||||
</div>
|
</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) */}
|
{/* Bottom meta bar (angelehnt an echten Player) */}
|
||||||
<div className="absolute inset-x-2 bottom-2 flex items-end justify-between gap-2">
|
<div className="absolute inset-x-2 bottom-2 flex items-end justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -2186,7 +2337,65 @@ export default function Player({
|
|||||||
</Card>
|
</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 = {
|
const expandedRect = {
|
||||||
left: ox + 16,
|
left: ox + 16,
|
||||||
@ -2280,6 +2489,53 @@ export default function Player({
|
|||||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
|
||||||
pointer-events: none;
|
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>
|
`}</style>
|
||||||
|
|
||||||
{expanded || miniDesktop ? (
|
{expanded || miniDesktop ? (
|
||||||
@ -2297,6 +2553,8 @@ export default function Player({
|
|||||||
|
|
||||||
{cardEl}
|
{cardEl}
|
||||||
|
|
||||||
|
{segmentsPanelEl}
|
||||||
|
|
||||||
{miniDesktop ? (
|
{miniDesktop ? (
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<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')} />
|
<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'
|
tone?: 'normal' | 'strong' | 'muted'
|
||||||
}
|
}
|
||||||
|
|
||||||
type SegmentSpritePreview = {
|
export type SegmentSpritePreview = {
|
||||||
src: string
|
src: string
|
||||||
frameStyle: CSSProperties
|
frameStyle: CSSProperties
|
||||||
imageStyle: CSSProperties
|
imageStyle: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
type RatingSegment = {
|
export type RatingSegment = {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
timeLabel?: string
|
timeLabel?: string
|
||||||
@ -825,7 +825,7 @@ function readSegmentArray(metaRaw: unknown): unknown[] {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRatingSegments(metaRaw: unknown): RatingSegment[] {
|
export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
|
||||||
const rawSegments = readSegmentArray(metaRaw)
|
const rawSegments = readSegmentArray(metaRaw)
|
||||||
const spriteMeta = readPreviewSpriteMeta(metaRaw)
|
const spriteMeta = readPreviewSpriteMeta(metaRaw)
|
||||||
const durationSeconds = readMetaDurationSeconds(metaRaw)
|
const durationSeconds = readMetaDurationSeconds(metaRaw)
|
||||||
@ -874,6 +874,178 @@ function readRatingSegments(metaRaw: unknown): RatingSegment[] {
|
|||||||
.slice(0, 10) as 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) {
|
function ratingTone(stars: number) {
|
||||||
switch (stars) {
|
switch (stars) {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@ -549,6 +549,7 @@ export default function RecordJobActions({
|
|||||||
const MARGIN = 8
|
const MARGIN = 8
|
||||||
|
|
||||||
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxH: number } | null>(null)
|
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxH: number } | null>(null)
|
||||||
|
const [menuPortalTarget, setMenuPortalTarget] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
@ -578,9 +579,16 @@ export default function RecordJobActions({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!menuOpen) {
|
if (!menuOpen) {
|
||||||
setMenuStyle(null)
|
setMenuStyle(null)
|
||||||
|
setMenuPortalTarget(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const portalTarget =
|
||||||
|
(menuBtnRef.current?.closest('dialog[open]') as HTMLElement | null) ??
|
||||||
|
document.body
|
||||||
|
|
||||||
|
setMenuPortalTarget(portalTarget)
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const btn = menuBtnRef.current
|
const btn = menuBtnRef.current
|
||||||
if (!btn) return
|
if (!btn) return
|
||||||
@ -589,21 +597,31 @@ export default function RecordJobActions({
|
|||||||
const vw = window.innerWidth
|
const vw = window.innerWidth
|
||||||
const vh = window.innerHeight
|
const vh = window.innerHeight
|
||||||
|
|
||||||
// rechts am Button ausrichten
|
|
||||||
let left = r.right - MENU_W
|
let left = r.right - MENU_W
|
||||||
left = Math.max(MARGIN, Math.min(left, vw - MENU_W - MARGIN))
|
left = Math.max(MARGIN, Math.min(left, vw - MENU_W - MARGIN))
|
||||||
|
|
||||||
let top = r.bottom + GAP
|
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)
|
const maxH = Math.max(120, vh - top - MARGIN)
|
||||||
|
|
||||||
setMenuStyle({ top, left, maxH })
|
setMenuStyle({ top, left, maxH })
|
||||||
}
|
}
|
||||||
|
|
||||||
update()
|
update()
|
||||||
|
|
||||||
window.addEventListener('resize', update)
|
window.addEventListener('resize', update)
|
||||||
// capture=true, damit auch Scroll in Container-DIVs triggert
|
|
||||||
window.addEventListener('scroll', update, true)
|
window.addEventListener('scroll', update, true)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -847,24 +865,25 @@ export default function RecordJobActions({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && menuStyle && typeof document !== 'undefined'
|
{menuOpen && menuStyle && menuPortalTarget
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuPopupRef}
|
ref={menuPopupRef}
|
||||||
role="menu"
|
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={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: menuStyle.top,
|
top: menuStyle.top,
|
||||||
left: menuStyle.left,
|
left: menuStyle.left,
|
||||||
width: MENU_W,
|
width: MENU_W,
|
||||||
maxHeight: menuStyle.maxH,
|
maxHeight: menuStyle.maxH,
|
||||||
|
zIndex: 2147483647,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{menuKeys.map(menuItem)}
|
{menuKeys.map(menuItem)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
menuPortalTarget
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user