219 lines
9.7 KiB
TypeScript
219 lines
9.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
|
||
export default function RobotDashboard() {
|
||
const PI_IP = '172.17.22.250';
|
||
const streamUrl = `http://${PI_IP}:8080/stream`;
|
||
|
||
const [controlMode, setControlMode] = useState('gamepad');
|
||
const [gamepadStatus, setGamepadStatus] = useState("Menunggu Gamepad...");
|
||
const [steering, setSteering] = useState({ x: 0, y: 0 });
|
||
const [obstacleWarning, setObstacleWarning] = useState(false);
|
||
|
||
const wsRef = useRef<any>(null);
|
||
const keysRef = useRef({ up: false, down: false, left: false, right: false });
|
||
|
||
const forceScanGamepad = () => {
|
||
if (typeof navigator !== "undefined" && navigator.getGamepads) {
|
||
const gamepads = navigator.getGamepads();
|
||
let activeGp = null;
|
||
for (let i = 0; i < gamepads.length; i++) {
|
||
if (gamepads[i] !== null) {
|
||
activeGp = gamepads[i];
|
||
break;
|
||
}
|
||
}
|
||
if (activeGp) setGamepadStatus(`🟢 TERHUBUNG: ${activeGp.id.substring(0, 15)}...`);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const ws = new WebSocket(`ws://${PI_IP}:8765`);
|
||
ws.onopen = () => console.log("WebSocket Terhubung ke Pi!");
|
||
ws.onclose = () => console.log("WebSocket Terputus.");
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
// Jika data yang dikirim adalah jarak, dan nilainya menembus threshold (misal 150)
|
||
if (data.type === 'distance') {
|
||
setObstacleWarning(data.value > 150);
|
||
}
|
||
} catch (e) {
|
||
console.error("Gagal membaca pesan dari Pi:", e);
|
||
}
|
||
};
|
||
wsRef.current = ws;
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (['ArrowUp', 'w', 'W'].includes(e.key)) keysRef.current.up = true;
|
||
if (['ArrowDown', 's', 'S'].includes(e.key)) keysRef.current.down = true;
|
||
if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = true;
|
||
if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = true;
|
||
};
|
||
|
||
const handleKeyUp = (e: KeyboardEvent) => {
|
||
if (['ArrowUp', 'w', 'W'].includes(e.key)) keysRef.current.up = false;
|
||
if (['ArrowDown', 's', 'S'].includes(e.key)) keysRef.current.down = false;
|
||
if (['ArrowLeft', 'a', 'A'].includes(e.key)) keysRef.current.left = false;
|
||
if (['ArrowRight', 'd', 'D'].includes(e.key)) keysRef.current.right = false;
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
window.addEventListener('keyup', handleKeyUp);
|
||
|
||
const interval = setInterval(() => {
|
||
let currentX = 0;
|
||
let currentY = 0;
|
||
|
||
if (controlMode === 'gamepad') {
|
||
if (typeof navigator !== "undefined" && navigator.getGamepads) {
|
||
const gamepads = navigator.getGamepads();
|
||
let activeGp = null;
|
||
for (let i = 0; i < gamepads.length; i++) {
|
||
if (gamepads[i] !== null) {
|
||
activeGp = gamepads[i];
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (activeGp) {
|
||
const rawX = activeGp.axes[0];
|
||
|
||
// SESUAI PERMINTAAN: RT(7) Maju, LT(6) Mundur
|
||
const maju = activeGp.buttons[7].value;
|
||
const mundur = activeGp.buttons[6].value;
|
||
|
||
// PERBAIKAN 1: Karena Pi minta nilai minus untuk maju, pengurangannya dibalik (mundur - maju)
|
||
const throttle = mundur - maju;
|
||
|
||
currentX = Math.abs(rawX) > 0.15 ? Number(rawX.toFixed(2)) : 0;
|
||
currentY = Number(throttle.toFixed(2));
|
||
}
|
||
}
|
||
} else if (controlMode === 'keyboard') {
|
||
// PERBAIKAN 2: Keyboard disamakan. W/Up (maju) menghasilkan minus (-1), S/Down menghasilkan plus (+1)
|
||
currentY = (keysRef.current.down ? 1 : 0) - (keysRef.current.up ? 1 : 0);
|
||
currentX = (keysRef.current.right ? 1 : 0) - (keysRef.current.left ? 1 : 0);
|
||
}
|
||
|
||
setSteering({ x: currentX, y: currentY });
|
||
|
||
if (wsRef.current && wsRef.current.readyState === 1) {
|
||
wsRef.current.send(JSON.stringify({ x: currentX, y: currentY }));
|
||
}
|
||
}, 100);
|
||
|
||
return () => {
|
||
clearInterval(interval);
|
||
window.removeEventListener('keydown', handleKeyDown);
|
||
window.removeEventListener('keyup', handleKeyUp);
|
||
if (wsRef.current) wsRef.current.close();
|
||
};
|
||
}, [controlMode, PI_IP]);
|
||
|
||
// --- PERBAIKAN 3: VISUALIZER ---
|
||
const maxRadius = 36;
|
||
const dotX = steering.x * maxRadius;
|
||
|
||
// Karena 'maju' nilainya sudah minus (-), dan di CSS nilai minus (-) membuat titik NAIK KE ATAS,
|
||
// maka kita HAPUS perkalian (* -1) yang sebelumnya. Sekarang titiknya akan maju sempurna!
|
||
const dotY = steering.y * maxRadius;
|
||
|
||
return (
|
||
<main className="min-h-screen bg-gray-900 text-white p-8 font-sans">
|
||
<div className="max-w-5xl mx-auto">
|
||
<header className="mb-8 border-b border-gray-700 pb-4 flex flex-col md:flex-row justify-between items-center gap-4">
|
||
<h1 className="text-3xl font-bold text-emerald-400">Robot Vision Control</h1>
|
||
|
||
<div className="flex gap-2 bg-gray-800 p-1 rounded-lg border border-gray-700">
|
||
<button
|
||
onClick={() => setControlMode('gamepad')}
|
||
className={`py-2 px-4 rounded font-semibold transition-colors ${controlMode === 'gamepad' ? 'bg-emerald-500 text-gray-900' : 'text-gray-400 hover:text-white'}`}
|
||
>
|
||
🎮 Gamepad
|
||
</button>
|
||
<button
|
||
onClick={() => setControlMode('keyboard')}
|
||
className={`py-2 px-4 rounded font-semibold transition-colors ${controlMode === 'keyboard' ? 'bg-emerald-500 text-gray-900' : 'text-gray-400 hover:text-white'}`}
|
||
>
|
||
⌨️ Keyboard
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div className="lg:col-span-2 bg-gray-800 rounded-xl p-4 border border-gray-700 shadow-xl">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 mb-4">
|
||
<span className="w-3 h-3 rounded-full bg-red-500 animate-pulse"></span>
|
||
Live Feed
|
||
</h2>
|
||
|
||
{/* --- CONTAINER KAMERA YANG SUDAH DIPERBAIKI --- */}
|
||
<div className="aspect-4/3 md:aspect-video w-full bg-black rounded-lg overflow-hidden border border-gray-900 relative">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
{/* --- OVERLAY PERINGATAN (Akan muncul hanya jika obstacleWarning bernilai true) --- */}
|
||
{obstacleWarning && (
|
||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-red-600/30 backdrop-blur-sm transition-all duration-300">
|
||
<div className="bg-red-600 text-white px-6 py-3 rounded-xl font-bold text-xl md:text-3xl animate-bounce shadow-[0_0_30px_rgba(220,38,38,0.9)] border-4 border-red-300 flex items-center gap-3">
|
||
<span>⚠️</span>
|
||
AWAS! OBJEK TERLALU DEKAT
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* ----------------------------------------------------------------------------- */}
|
||
<img
|
||
src={streamUrl}
|
||
alt="Robot view"
|
||
className="absolute top-0 left-0 h-full max-w-none object-cover"
|
||
style={{
|
||
width: '200%',
|
||
objectPosition: 'left center'
|
||
}}
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = 'none';
|
||
e.currentTarget.parentElement!.innerHTML = '<div class="text-red-400 text-center p-4">Kamera gagal dimuat.<br/>Pastikan Pi dan HP berada dalam jaringan Tailscale/Wi-Fi yang sama.</div>';
|
||
}}
|
||
/>
|
||
</div>
|
||
{/* ---------------------------------------------- */}
|
||
|
||
</div>
|
||
|
||
<div className="bg-gray-800 rounded-xl p-4 border border-gray-700 shadow-xl flex flex-col">
|
||
<h2 className="text-lg font-semibold mb-4 border-b border-gray-700 pb-2 flex justify-between items-center">
|
||
<span>Status Kontrol</span>
|
||
{controlMode === 'gamepad' && (
|
||
<button onClick={forceScanGamepad} className="text-xs bg-gray-700 hover:bg-gray-600 py-1 px-2 rounded">
|
||
Pindai Ulang
|
||
</button>
|
||
)}
|
||
</h2>
|
||
|
||
<div className="flex-1 flex flex-col justify-center gap-6">
|
||
<div className="bg-gray-900 p-3 rounded border border-gray-700 text-sm font-mono text-center">
|
||
{controlMode === 'keyboard' ? '⌨️ AKTIF: Gunakan W-A-S-D atau Panah' : gamepadStatus}
|
||
</div>
|
||
|
||
<div className="bg-gray-900 p-4 rounded-lg flex flex-col items-center gap-4 border border-gray-700">
|
||
<div className="text-xs text-gray-400 text-center mb-2">Visualizer (X / Y)</div>
|
||
<div className="w-24 h-24 bg-gray-800 rounded-full border-2 border-gray-600 relative overflow-hidden flex items-center justify-center">
|
||
<div className="absolute w-full h-1px bg-gray-600"></div>
|
||
<div className="absolute h-full w-1px bg-gray-600"></div>
|
||
<div
|
||
className="w-6 h-6 bg-emerald-400 rounded-full absolute shadow-[0_0_10px_rgba(52,211,153,0.5)] transition-all duration-75"
|
||
style={{ transform: `translate(${dotX}px, ${dotY}px)` }}
|
||
></div>
|
||
</div>
|
||
<div className="font-mono text-sm mt-2 flex flex-col items-center text-emerald-400">
|
||
<span>X: {steering.x}</span>
|
||
<span>Y: {steering.y}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
} |