From bd2dc1f9650d68c87c9c1b0e624a0b99231779f9 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:14:05 +0200 Subject: [PATCH] updated - BACKUP - --- backend/rating.go | 4 +- backend/recorder_settings.json | 1 + .../src/components/ui/FinishedDownloads.tsx | 2 + .../ui/FinishedDownloadsCardsView.tsx | 14 +- .../ui/FinishedDownloadsGalleryCard.tsx | 80 +++- .../ui/FinishedDownloadsGalleryView.tsx | 6 + frontend/src/components/ui/Player.tsx | 386 +++++++++++++++--- frontend/src/components/ui/RatingOverlay.tsx | 178 +++++++- .../src/components/ui/RecordJobActions.tsx | 31 +- 9 files changed, 619 insertions(+), 83 deletions(-) diff --git a/backend/rating.go b/backend/rating.go index 9e7100e..9ce67a7 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -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 diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 06c03bb..b62ac00 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -11,6 +11,7 @@ "useMyFreeCamsWatcher": true, "autoDeleteSmallDownloads": true, "autoDeleteSmallDownloadsBelowMB": 300, + "autoDeleteSmallDownloadsKeepFavorites": true, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "all", diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 20f723e..ed90162 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -5084,6 +5084,8 @@ export default function FinishedDownloads({ assetNonce={assetNonce ?? 0} renderIntegrityBadge={renderIntegrityBadge} renderRatingOverlay={renderRatingOverlay} + mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked} + onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)} /> )} diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index e8b0d6b..f49f275 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -683,9 +683,17 @@ function FinishedDownloadsCardsView({ > {/* media */}
- + {isSmall ? ( + !inlineActive && renderRatingOverlay ? ( +
+ {renderRatingOverlay(j)} +
+ ) : null + ) : ( + + )}
- - {renderRatingOverlay ? renderRatingOverlay(j) : null} - + {/* Desktop/Tablet: wie bisher */} +
+ + {renderRatingOverlay ? renderRatingOverlay(j) : null} + +
+ + {/* Mobile: Stern unten links */} + {renderRatingOverlay ? ( +
+ {renderRatingOverlay(j)} +
+ ) : null} + + {showMobileAudioButton ? ( + + ) : null} 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} /> ) diff --git a/frontend/src/components/ui/Player.tsx b/frontend/src/components/ui/Player.tsx index c193807..9d19ae8 100644 --- a/frontend/src/components/ui/Player.tsx +++ b/frontend/src/components/ui/Player.tsx @@ -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) { 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(() => + pickPlayerMetaRaw(job) + ) + + const PLAYER_SEGMENTS_OPEN_KEY = 'player_segments_open_v1' + + const [segmentsPanelOpen, setSegmentsPanelOpen] = useState(() => { + 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(() => { 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) + const HOLD_TO_DRAG_MS = 220 + const HOLD_MOVE_TOLERANCE = 6 + + const holdDragTimerRef = useRef(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) => { - 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) => { + 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() }} >
-
+
{showSideInfo ? null : footerRight}
- {miniDesktop ? ( - - ) : null} -
+ {miniDesktop && !isLive && hasRatingSegments ? ( + + ) : null} +
) + + const { w: vw, h: vh, ox, oy, bottomInset } = getViewport() const sidePanel = (
-
+
{/* Snapshot-Frame bevorzugen, sonst Preview-Fallback */} @@ -2094,6 +2241,17 @@ export default function Player({ )}
+ + {!isLive && hasRatingSegments ? ( +
+ +
+ ) : null}
) @@ -2132,13 +2290,6 @@ export default function Player({
- {/* Mini "drag grip" Andeutung */} -
- - - -
- {/* Bottom meta bar (angelehnt an echten Player) */}
@@ -2186,7 +2337,65 @@ export default function Player({ ) - 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 ? ( + + ) : 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; + } `} {expanded || miniDesktop ? ( @@ -2297,6 +2553,8 @@ export default function Player({ {cardEl} + {segmentsPanelEl} + {miniDesktop ? (
diff --git a/frontend/src/components/ui/RatingOverlay.tsx b/frontend/src/components/ui/RatingOverlay.tsx index db0deaf..2afc4f5 100644 --- a/frontend/src/components/ui/RatingOverlay.tsx +++ b/frontend/src/components/ui/RatingOverlay.tsx @@ -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 ( +
+
+
+
+
+ Segmente +
+
+ AI Rating · {segments.length} +
+
+
+
+ +
+
+ {segments.map((segment, index) => ( + + ))} +
+
+
+ ) +} + function ratingTone(stars: number) { switch (stars) { case 1: diff --git a/frontend/src/components/ui/RecordJobActions.tsx b/frontend/src/components/ui/RecordJobActions.tsx index 5edbba5..dfbbfbf 100644 --- a/frontend/src/components/ui/RecordJobActions.tsx +++ b/frontend/src/components/ui/RecordJobActions.tsx @@ -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(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({ - {menuOpen && menuStyle && typeof document !== 'undefined' + {menuOpen && menuStyle && menuPortalTarget ? createPortal(
e.stopPropagation()} > {menuKeys.map(menuItem)}
, - document.body + menuPortalTarget ) : null}