A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
|
|
interface StreamStatus {
|
|
is_streaming: boolean
|
|
frame_count: number
|
|
width: number
|
|
height: number
|
|
quality: number
|
|
uptime_seconds: string
|
|
}
|
|
|
|
interface StreamFrameResponse {
|
|
success: boolean
|
|
frame_count?: number
|
|
width?: number
|
|
height?: number
|
|
data?: string // Base64 encoded JPEG
|
|
message?: string
|
|
}
|
|
|
|
interface GameViewProps {
|
|
isUnityOnline: boolean
|
|
isPlaying?: boolean
|
|
}
|
|
|
|
export default function GameView({ isUnityOnline, isPlaying }: GameViewProps) {
|
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null)
|
|
const [frameData, setFrameData] = useState<string | null>(null)
|
|
const [frameCount, setFrameCount] = useState(0)
|
|
const [fps, setFps] = useState(0)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
const lastFrameTimeRef = useRef<number>(Date.now())
|
|
const frameCountRef = useRef<number>(0)
|
|
const fpsIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
const streamIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
// Fetch stream status
|
|
const fetchStreamStatus = useCallback(async () => {
|
|
if (!isUnityOnline) return
|
|
try {
|
|
const res = await fetch('/api/admin/unity-bridge?action=stream-status')
|
|
if (res.ok) {
|
|
const data: StreamStatus = await res.json()
|
|
setStreamStatus(data)
|
|
setIsStreaming(data.is_streaming)
|
|
}
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}, [isUnityOnline])
|
|
|
|
// Capture single screenshot
|
|
const captureScreenshot = async () => {
|
|
if (!isUnityOnline) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch('/api/admin/unity-bridge?action=screenshot')
|
|
if (res.ok) {
|
|
const blob = await res.blob()
|
|
const url = URL.createObjectURL(blob)
|
|
setFrameData(url)
|
|
setFrameCount(prev => prev + 1)
|
|
} else {
|
|
const errorData = await res.json()
|
|
setError(errorData.error || 'Screenshot fehlgeschlagen')
|
|
}
|
|
} catch (err) {
|
|
setError('Verbindung fehlgeschlagen')
|
|
}
|
|
setIsLoading(false)
|
|
}
|
|
|
|
// Start streaming
|
|
const startStreaming = async () => {
|
|
if (!isUnityOnline) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch('/api/admin/unity-bridge?action=stream-start')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
if (data.success) {
|
|
setIsStreaming(true)
|
|
frameCountRef.current = 0
|
|
} else {
|
|
setError(data.message || 'Streaming konnte nicht gestartet werden')
|
|
}
|
|
}
|
|
} catch {
|
|
setError('Verbindung fehlgeschlagen')
|
|
}
|
|
setIsLoading(false)
|
|
}
|
|
|
|
// Stop streaming
|
|
const stopStreaming = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const res = await fetch('/api/admin/unity-bridge?action=stream-stop')
|
|
if (res.ok) {
|
|
setIsStreaming(false)
|
|
}
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
setIsLoading(false)
|
|
}
|
|
|
|
// Fetch frame during streaming
|
|
const fetchFrame = useCallback(async () => {
|
|
if (!isStreaming || !isUnityOnline) return
|
|
try {
|
|
const res = await fetch('/api/admin/unity-bridge?action=stream-frame')
|
|
if (res.ok) {
|
|
const data: StreamFrameResponse = await res.json()
|
|
if (data.success && data.data) {
|
|
setFrameData(`data:image/jpeg;base64,${data.data}`)
|
|
setFrameCount(data.frame_count || 0)
|
|
frameCountRef.current++
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore single frame failures
|
|
}
|
|
}, [isStreaming, isUnityOnline])
|
|
|
|
// Calculate FPS every second
|
|
useEffect(() => {
|
|
if (isStreaming) {
|
|
fpsIntervalRef.current = setInterval(() => {
|
|
setFps(frameCountRef.current)
|
|
frameCountRef.current = 0
|
|
}, 1000)
|
|
} else {
|
|
if (fpsIntervalRef.current) {
|
|
clearInterval(fpsIntervalRef.current)
|
|
fpsIntervalRef.current = null
|
|
}
|
|
setFps(0)
|
|
}
|
|
return () => {
|
|
if (fpsIntervalRef.current) {
|
|
clearInterval(fpsIntervalRef.current)
|
|
}
|
|
}
|
|
}, [isStreaming])
|
|
|
|
// Streaming loop - fetch frames at ~10 FPS
|
|
useEffect(() => {
|
|
if (isStreaming) {
|
|
// Initial fetch
|
|
fetchFrame()
|
|
// Setup interval for continuous fetching
|
|
streamIntervalRef.current = setInterval(fetchFrame, 100) // 10 FPS
|
|
} else {
|
|
if (streamIntervalRef.current) {
|
|
clearInterval(streamIntervalRef.current)
|
|
streamIntervalRef.current = null
|
|
}
|
|
}
|
|
return () => {
|
|
if (streamIntervalRef.current) {
|
|
clearInterval(streamIntervalRef.current)
|
|
}
|
|
}
|
|
}, [isStreaming, fetchFrame])
|
|
|
|
// Initial status fetch
|
|
useEffect(() => {
|
|
fetchStreamStatus()
|
|
}, [fetchStreamStatus])
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="font-semibold text-gray-900">Game View</h3>
|
|
{isStreaming && (
|
|
<span className="flex items-center gap-1.5 px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-medium">
|
|
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
|
LIVE
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Screenshot Button */}
|
|
<button
|
|
onClick={captureScreenshot}
|
|
disabled={!isUnityOnline || isLoading || isStreaming}
|
|
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
|
title="Einzelner Screenshot"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
Screenshot
|
|
</button>
|
|
|
|
{/* Stream Toggle */}
|
|
{isStreaming ? (
|
|
<button
|
|
onClick={stopStreaming}
|
|
disabled={isLoading}
|
|
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 transition-colors flex items-center gap-1.5"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<rect x="6" y="6" width="12" height="12" />
|
|
</svg>
|
|
Stop
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={startStreaming}
|
|
disabled={!isUnityOnline || isLoading}
|
|
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
Stream
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Game View Area */}
|
|
<div className="relative bg-gray-900 aspect-video">
|
|
{error ? (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-center text-white">
|
|
<svg className="w-12 h-12 mx-auto mb-3 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<p className="text-red-300">{error}</p>
|
|
</div>
|
|
</div>
|
|
) : !isUnityOnline ? (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-center text-gray-400">
|
|
<svg className="w-16 h-16 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
<p>Unity Bridge offline</p>
|
|
<p className="text-sm mt-1">Starte den Server in Unity</p>
|
|
</div>
|
|
</div>
|
|
) : !frameData ? (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-center text-gray-400">
|
|
<svg className="w-16 h-16 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
<p>Kein Bild verfügbar</p>
|
|
<p className="text-sm mt-1">
|
|
{isPlaying
|
|
? 'Klicke "Stream" um die Spielansicht zu sehen'
|
|
: 'Starte Play Mode und dann den Stream'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={frameData}
|
|
alt="Unity Game View"
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
)}
|
|
|
|
{/* Loading Overlay */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Bar */}
|
|
<div className="px-4 py-2 bg-gray-50 border-t border-gray-200 flex items-center justify-between text-xs text-gray-600">
|
|
<div className="flex items-center gap-4">
|
|
<span>
|
|
<span className="font-medium">Resolution:</span>{' '}
|
|
{streamStatus ? `${streamStatus.width}x${streamStatus.height}` : '1280x720'}
|
|
</span>
|
|
<span>
|
|
<span className="font-medium">Quality:</span>{' '}
|
|
{streamStatus ? `${streamStatus.quality}%` : '75%'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
{isStreaming && (
|
|
<>
|
|
<span>
|
|
<span className="font-medium">FPS:</span> {fps}
|
|
</span>
|
|
<span>
|
|
<span className="font-medium">Frames:</span> {frameCount}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className={isUnityOnline ? 'text-green-600' : 'text-red-600'}>
|
|
{isUnityOnline ? 'Connected' : 'Disconnected'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|