3174 lines
70 KiB
Plaintext
3174 lines
70 KiB
Plaintext
Ich hätte gerne eine kleine Spiele App für Kids auf dem Tablet. Am bestens als Browser Game. Die App soll nach Möglichkeit eingeschrieben sein, dass die Kinder das Spiel nicht verlassen können. Vermutlich ist das schwer bis unmöglich unter Android. Wenn nicht möglich können wir auch eine API bauen. Die Spiele sollen Reaktion, Gefühl für Bedienung und das kindliche wohl hervorheben. Es können einfache Games wie etwas suchen, einfach Rätsel, etwas ausmalen, nachmalen von zahlen und Buchstaben. Wichtig due Anforderungen sollten den alter entsprechen. Aktuell sind die Kinder 2 und 5. Aber sie werden älter und das Talent steigt. Auch Sprachausgabe und Spracherkennung wären toll. Wir können gerne KI Tools über ollama Einbinden. Magst du mir ein erstes Konzept bauen?
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 26 Sekunden
|
||
Hier ist ein umfassendes Konzept für "KinderWelt" – eine skalierbare, browserbasierte Spiele-Plattform mit Kiosk-Modus und KI-Integration.
|
||
|
||
1. Technische Architektur
|
||
Kern-Stack
|
||
Frontend: PWA (Progressive Web App) mit Vanilla JS/TypeScript + Canvas API/WebGL
|
||
UI-Framework: Kein schweres Framework – rein CSS-Animationen für 60fps auf Tablets
|
||
Audio: Web Speech API (TTS) + Web Audio API + optional TensorFlow.js (für lokale Spracherkennung)
|
||
Backend (optional): Node.js/Express als Proxy zu Ollama (localhost)
|
||
Speicher: IndexedDB für Offline-Fortschritte & Cache
|
||
Der Kiosk-Modus (Android-Strategie)
|
||
Da reiner Browser-Lock schwierig ist, hier eine zweistufige Lösung:
|
||
|
||
Option A: PWA + Android Screen Pinning (Einfach)
|
||
|
||
PWA im Fullscreen (display: standalone im Manifest)
|
||
Android "Bildschirm fixieren" (Screen Pinning) aktivieren
|
||
Keine Hardware-Buttons sichtbar, aber Eltern können es mit Tastenkombination entsperren
|
||
Option B: Dedicated Wrapper-App (Empfohlen)
|
||
|
||
Einfache Android-App (Kotlin) mit WebView
|
||
Überschreibt onBackPressed(), onPause(), blockiert Home-Button (mit Systemrechten)
|
||
Startet automatisch den Browser im Kiosk-Modus
|
||
Vorteil: Kind kann wirklich nicht raus, außer Eltern geben Passwort ein
|
||
2. Spielkonzept & Altersstufen
|
||
Modul A: "Entdecker" (2-3 Jahre)
|
||
Ziel: Grobmotorik, Ursache-Wirkung, Freude am Tippen
|
||
|
||
Blubber-Blasen: Bunte Kreise erscheinen, platzen bei Berührung mit "Pop"-Sound
|
||
Tier-Farm: Großes Tier antippen → realistischer Sound + Animation (TTS: "Das ist eine Kuh!")
|
||
Farben-Explosion: Ganzer Bildschirm färbt sich bei Touch, leichte Vibration (haptisches Feedback)
|
||
Fang den Stern: Langsam fallende Sterne einsammeln (kein Game Over, nur positive Verstärkung)
|
||
Modul B: "Kleine Forscher" (4-5 Jahre)
|
||
Ziel: Feinmotorik, Mustererkennung, erste Logik
|
||
|
||
Zahlen-Folgen: Connect-the-Dots mit Audio ("Tippe auf die 3!")
|
||
Memory-Leicht: 3x3 Karten mit Tieren, keine Zeitbegrenzung
|
||
Formen-Sortierer: Drag & Drop (Kreis in runde Löcher, Viereck in eckige)
|
||
Einfaches Malen: "Zauberstift" (automatisch innerhalb von Linien bleiben)
|
||
Modul C: "Smart Kids" (6+ Jahre) [Zukunftssicher]
|
||
Ziel: Kognitive Entwicklung, Kreativität
|
||
|
||
KI-Mitmachgeschichten: Kind sagt ein Wort, Ollama generiert nächsten Satz (lokal, datensicher!)
|
||
Buchstaben-Nachzeichnen: Pfadverfolgung mit Präzisions-Messung
|
||
Logik-Rätsel: "Welches Tier passt nicht dazu?" mit Sprachausgabe der Fragen
|
||
3. KI-Integration (Ollama)
|
||
Da Datenschutz bei Kindern kritisch ist, läuft alles lokal auf dem Tablet/Server:
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
⌄
|
||
// Beispiel: Bildbeschreibung für "Was ist das?" Spiel
|
||
const ollamaRequest = {
|
||
model: "llava-phi3", // Leichtes Vision-Modell
|
||
prompt: "Beschreibe dieses Bild für ein 5-jähriges Kind in 3 einfachen Sätzen.",
|
||
images: [base64Image]
|
||
};
|
||
|
||
// Spracherkennung via Whisper (lokal)
|
||
// TTS via Piper (Ollama Integration) oder Web Speech API
|
||
KI-Spiele-Ideen:
|
||
|
||
"Erzähl mir was": Kind malt etwas, KI errät es (Bild-zu-Text)
|
||
Intelligenter Helfer: Wenn Kind bei einem Rätsel 3x falsch tippt, gibt die KI keineswegs die Lösung, sondern einen kindgerechten Hinweis ("Schau mal, die Form hat runde Ecken...")
|
||
4. UI/UX Design-Prinzipien
|
||
Visuelles Design
|
||
Keine Text-Menüs: Nur Icons + Sprache (multilingual)
|
||
Große Touch-Targets: Mindestens 80x80px (Kindliche Motorik!)
|
||
Hoher Kontrast: Saturierte Farben, aber nicht grell (Material You für Kids)
|
||
Keine Werbung/Popups: Vollständig abgeschottete Umgebung
|
||
Interaktions-Muster
|
||
Immediate Feedback: <100ms Reaktionszeit auf Touch
|
||
Positive Verstärkung: Sternchen-Animationen, Applaus-Sounds (keine negativen Sounds bei Fehlern)
|
||
Parental Gate: Für Einstellungen: "Halte beide Ohren gleichzeitig gedrückt" (physisches Rätsel statt Mathe-Aufgabe)
|
||
5. Implementierungs-Roadmap
|
||
Phase 1: MVP (4-6 Wochen)
|
||
PWA-Grundgerüst mit Fullscreen-API
|
||
Modul A (2 Jahre): Blubber-Blasen + Tier-Farm
|
||
Einfacher Kiosk-Mode via Android Screen Pinning
|
||
Web Speech API (TTS)
|
||
Phase 2: Erweiterung (8-10 Wochen)
|
||
Wrapper-App für echten Lock-Mode
|
||
Modul B (5 Jahre): Zahlen-Folgen + Memory
|
||
Zeichen-Canvas mit simplem "Stay-in-lines" Algorithmus
|
||
Offline-Speicherung der Fortschritte
|
||
Phase 3: KI-Integration (12+ Wochen)
|
||
Ollama-Setup auf lokalem Server/Tablet
|
||
Bilderkennungs-Spiele
|
||
Adaptive Schwierigkeit (Spiel merkt sich, wenn Kind schnell ist)
|
||
6. Schnelle Starter-Datei (Proof of Concept)
|
||
Hier ein erster HTML-Prototyp für den Kiosk-Modus + einfaches Spiel:
|
||
|
||
html
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
|
||
Vorschau
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<title>KinderWelt</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; touch-action: none; user-select: none; }
|
||
body { overflow: hidden; background: #FFE5B4; font-family: 'Comic Sans MS', cursive; }
|
||
#gameCanvas { position: fixed; top: 0; left: 0; }
|
||
.bubble {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.8), rgba(255,255,255,0.2));
|
||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||
transition: transform 0.1s;
|
||
}
|
||
.bubble:active { transform: scale(0.9); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="gameArea"></div>
|
||
<script>
|
||
// Vollbild erzwingen
|
||
document.documentElement.requestFullscreen().catch(e => console.log(e));
|
||
|
||
// Verhindert Zoom/Scroll
|
||
document.addEventListener('touchmove', function(e) { e.preventDefault(); }, { passive: false });
|
||
|
||
// Einfaches Blasen-Spiel für 2-Jährige
|
||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
|
||
const synth = window.speechSynthesis;
|
||
|
||
function createBubble() {
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'bubble';
|
||
const size = Math.random() * 100 + 80; // 80-180px (gut für Kinderfinger)
|
||
bubble.style.width = bubble.style.height = size + 'px';
|
||
bubble.style.left = Math.random() * (window.innerWidth - size) + 'px';
|
||
bubble.style.top = Math.random() * (window.innerHeight - size) + 'px';
|
||
bubble.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
|
||
|
||
bubble.addEventListener('touchstart', (e) => {
|
||
e.preventDefault();
|
||
// Sound + Sprache
|
||
const utter = new SpeechSynthesisUtterance("Pop!");
|
||
utter.rate = 0.8; // Langsamer für Kinder
|
||
utter.pitch = 1.2; // Fröhlicher
|
||
synth.speak(utter);
|
||
|
||
bubble.style.transform = 'scale(1.2)';
|
||
setTimeout(() => bubble.remove(), 100);
|
||
createBubble(); // Neue Blase
|
||
});
|
||
|
||
document.body.appendChild(bubble);
|
||
}
|
||
|
||
// Starte mit 3 Blasen
|
||
for(let i=0; i<3; i++) createBubble();
|
||
|
||
// Verhindert Verlassen via Zurück-Taste (nur im Browser begrenzt wirksam)
|
||
history.pushState(null, null, location.href);
|
||
window.onpopstate = function() {
|
||
history.pushState(null, null, location.href);
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|
||
7. Empfehlung für Android-Tablet
|
||
Für deinen Use Case empfehle ich:
|
||
|
||
Samsung Kids Mode oder Google Family Link als erste Barriere
|
||
Darüber: Die Wrapper-App (APK), die den Browser im Immersive Mode startet
|
||
Ollama läuft im Hintergrund auf dem Tablet (wenn leistungsstark genug, z.B. Samsung Tab S9) oder auf einem Raspberry Pi im Netzwerk
|
||
Nächste Schritte:
|
||
|
||
Soll ich den Code für die Android-Wrapper-App (Kotlin) liefern?
|
||
Ollama-Setup-Anleitung für lokale Sprachverarbeitung?
|
||
Detaillierte Spielmechanik für einen bestimmten Bereich (z.B. das Nachzeichnen)?
|
||
Das System wächst mit den Kindern mit – du kannst einfach neue Module als JavaScript-Dateien hinzufügen, ohne die App neu zu installieren!
|
||
|
||
Lass uns die App als Docker hinter einem recerced Proxy planen. Das ermöglicht die Nutzung von überall, und die ki liegt im gleichen Netzwerk wie das Game selbst. Am besten wir verifizieren die Anmeldung via Authentficator App push. Das spart uns den Umweg Benutzername und Passwort freizugeben. Weitere Idee eine jellyfin Integration. Was hältst du von der Idee. Bestimmte Serien können direkt gestreamt werden.
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 3 Minuten
|
||
Das ist eine ** Enterprise-Grade-Architektur** für eine Kinder-App! Die Kombination aus öffentlicher Erreichbarkeit, KI-Pipeline und Medien-Streaming erfordert sorgfältige Sicherheitsplanung – besonders bei Minderjährigen.
|
||
|
||
Hier ist das erweiterte Cloud-native Konzept mit Docker-Stack:
|
||
|
||
1. Netzwerk-Architektur (Zero-Trust)
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
Internet → Traefik (SSL/Let's Encrypt) → Authelia (Auth/2FA) → Services
|
||
↓
|
||
┌──────────────────────┼──────────────────────┐
|
||
↓ ↓ ↓
|
||
Game-App (Nginx) Ollama (AI) Jellyfin
|
||
(Port 3000) (Port 11434) (Port 8096)
|
||
↑ ↑ ↑
|
||
Shared Docker Network: kids-zone (isoliert, intern)
|
||
Warum diese Trennung?
|
||
Ollama ist nicht direkt exposed (nur intern über http://ollama:11434 erreichbar)
|
||
Jellyfin wird über die Game-UI "verpackt" (kein direkter Zugriff auf Port 8096 von außen)
|
||
Alle externen Requests müssen durch Authelia (Auth-Layer)
|
||
2. Authentifizierungs-Strategie: "Passwordless + 2FA Push"
|
||
Da du "Authenticator App Push" und "kein Passwort freigeben" willst, empfehle ich WebAuthn/Passkeys mit Authentik (nicht Authelia, da Authentik bessere Passwordless-Flows hat):
|
||
|
||
Ablauf für Eltern:
|
||
Registrierung: Scan QR-Code mit Authenticator-App (Authy/Google Authenticator) → TOTP Secret wird gespeichert
|
||
Login:
|
||
Option A: Passkey/FIDO2 (FaceID/Fingerprint auf Handy → Push-Benachrichtigung zum Bestätigen)
|
||
Option B: Magic Link per Email + TOTP-Code aus App
|
||
Option C: Session-Token (einmalig QR-Code scannen für dauerhaftes Tablet im Haus)
|
||
Wichtig: Kinder sollen gar nicht authentifiziert werden – sie landen direkt im Game nach dem Öffnen der URL. Der Auth-Layer schützt nur vor externen Zugriffen.
|
||
|
||
3. Jellyfin-Integration (Kinder-Safe)
|
||
Technische Umsetzung:
|
||
Proxy-Route: /api/media/* → Jellyfin API
|
||
UI-Integration: Custom Video-Player im Game-Canvas
|
||
Content-Filter: Nur "Kids"-Bibliothek sichtbar (Jellyfin User-Management)
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
⌄
|
||
⌄
|
||
// Beispiel: Serie abrufen aus Jellyfin
|
||
const getKidsEpisode = async () => {
|
||
const response = await fetch('/api/jellyfin/Items?ParentId=KIDS_LIBRARY_ID', {
|
||
headers: { 'X-Emby-Token': '${JELLYFIN_API_KEY}' }
|
||
});
|
||
// Direkter Stream-URL für HTML5-Player
|
||
return `/api/jellyfin/Videos/${itemId}/stream.mp4?Static=true`;
|
||
};
|
||
⚠️ Rechtliche & Sicherheits-Warnung
|
||
Jellyfin öffentlich exposen ist hochriskant:
|
||
|
||
Urheberrecht: Streaming eigener gerippter DVDs ist legal im Privathaushalt, aber öffentlich erreichbar? Grauzone.
|
||
Bandbreite: Unverschlüsselte Streams können abgegriffen werden
|
||
Kinder-Inhalte: Öffentliche Verfügbarkeit von Kinder-Medien zieht unerwünschte Aufmerksamkeit an
|
||
Empfehlung:
|
||
|
||
VPN-Tunnel (WireGuard/Tailscale) statt öffentlichem Reverse Proxy für Jellyfin
|
||
Oder: Cloudflare Tunnel mit Access-Policies (nur deine IP-Adressen erlauben)
|
||
4. Docker-Compose Stack
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
70
|
||
71
|
||
72
|
||
73
|
||
74
|
||
75
|
||
76
|
||
77
|
||
78
|
||
79
|
||
80
|
||
81
|
||
82
|
||
83
|
||
84
|
||
85
|
||
86
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
version: '3.8'
|
||
|
||
networks:
|
||
kids-zone:
|
||
driver: bridge
|
||
internal: false # Ollama & Jellyfin nur intern
|
||
|
||
services:
|
||
# Reverse Proxy + SSL
|
||
traefik:
|
||
image: traefik:v2.10
|
||
command:
|
||
- "--api.dashboard=true"
|
||
- "--providers.docker=true"
|
||
- "--entrypoints.websecure.address=:443"
|
||
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
|
||
- "--certificatesresolvers.letsencrypt.acme.email=admin@deinedomain.de"
|
||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||
ports:
|
||
- "443:443"
|
||
volumes:
|
||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||
- ./letsencrypt:/letsencrypt
|
||
networks:
|
||
- kids-zone
|
||
|
||
# Authentik (Identity Provider mit 2FA/Push)
|
||
authentik:
|
||
image: ghcr.io/goauthentik/server:2023.10
|
||
environment:
|
||
- AUTHENTIK_SECRET_KEY=${AUTHENTIK_KEY}
|
||
- AUTHENTIK_ERROR_REPORTING__ENABLED=false
|
||
# PostgreSQL & Redis für Authentik (hier weggelassen für Kürze)
|
||
labels:
|
||
- "traefik.enable=true"
|
||
- "traefik.http.routers.authentik.rule=Host(`auth.deinedomain.de`)"
|
||
- "traefik.http.routers.authentik.tls.certresolver=letsencrypt"
|
||
networks:
|
||
- kids-zone
|
||
|
||
# Kinder-Spiel-App
|
||
kids-game:
|
||
build: ./game-app # Node.js/Nginx Image
|
||
environment:
|
||
- OLLAMA_URL=http://ollama:11434
|
||
- JELLYFIN_URL=http://jellyfin:8096
|
||
- JELLYFIN_API_KEY=${JELLYFIN_KEY}
|
||
labels:
|
||
- "traefik.enable=true"
|
||
- "traefik.http.routers.kids-game.rule=Host(`spiele.deinedomain.de`)"
|
||
- "traefik.http.routers.kids-game.tls.certresolver=letsencrypt"
|
||
# Authelia/Authentik Middleware für Schutz
|
||
- "traefik.http.routers.kids-game.middlewares=authentik@file"
|
||
networks:
|
||
- kids-zone
|
||
depends_on:
|
||
- ollama
|
||
- jellyfin
|
||
|
||
# Ollama (KI) - nur intern erreichbar!
|
||
ollama:
|
||
image: ollama/ollama:latest
|
||
volumes:
|
||
- ./ollama-models:/root/.ollama
|
||
environment:
|
||
- OLLAMA_ORIGINS=*
|
||
networks:
|
||
- kids-zone
|
||
# Keine Ports exposed! Nur über kids-game erreichbar
|
||
deploy:
|
||
resources:
|
||
reservations:
|
||
devices:
|
||
- driver: nvidia
|
||
count: 1
|
||
capabilities: [gpu] # Für GPU-Beschleunigung
|
||
|
||
# Jellyfin (Media) - nur intern!
|
||
jellyfin:
|
||
image: jellyfin/jellyfin:latest
|
||
volumes:
|
||
- ./media:/media:ro
|
||
- ./jellyfin-config:/config
|
||
networks:
|
||
- kids-zone
|
||
# Kein Port-Mapping! Zugriff nur über kids-game Proxy
|
||
5. Sicherheitskonzept für öffentliche Erreichbarkeit
|
||
Da es um Kinder geht und öffentlicher Zugriff besteht:
|
||
|
||
A. Network-Policies
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
# In der compose zusätzlich:
|
||
ollama:
|
||
networks:
|
||
kids-zone:
|
||
aliases:
|
||
- ollama-internal
|
||
# Firewall: Nur kids-game darf zugreifen
|
||
B. Content Security Policy (CSP)
|
||
Im Game-Frontend (Nginx-Header):
|
||
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
Content-SSecurity-Policy: default-src 'self';
|
||
connect-src 'self' https://auth.deinedomain.de;
|
||
media-src 'self' blob:;
|
||
script-src 'self' 'unsafe-inline'; # Für Canvas-Spiele nötig
|
||
C. Rate-Limiting (Traefik)
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
⌄
|
||
labels:
|
||
- "traefik.http.middlewares.ratelimit.ratelimit.average=100"
|
||
- "traefik.http.middlewares.ratelimit.ratelimit.burst=50"
|
||
6. Jellyfin-Integration im Game
|
||
"Medien-Ecke" für die Kinder
|
||
Eltern-UI: /admin → Serien freischalten (Authentik-geschützt)
|
||
Kind-UI: Großer Button "Cartoons schauen" → Lädt Thumbnails von Jellyfin API → HTML5-Player im Vollbild
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// Game-Client Code
|
||
class MediaPlayer {
|
||
async loadEpisodes() {
|
||
// Proxy durch eigenes Backend (sicher, da API-Key nicht im Client)
|
||
const res = await fetch('/api/media/kids');
|
||
const episodes = await res.json();
|
||
// Zeige Cover-Buttons (groß für Touch)
|
||
}
|
||
|
||
play(videoId) {
|
||
const player = document.createElement('video');
|
||
player.src = `/api/media/stream/${videoId}`;
|
||
player.controls = false; // Eigene kindgerechte Controls (große Play-Buttons)
|
||
player.requestFullscreen();
|
||
}
|
||
}
|
||
7. Kritische Empfehlungen
|
||
❌ Was ich nicht empfehle:
|
||
Jellyfin direkt öffentlich: Zu riskant. Nutze stattdessen Cloudflare Access oder Tailscale für "von überall"
|
||
Kamera/Mikrofon für 2-Jährige im Browser: DSGVO-Problem. Speichere keine Daten, verarbeite KI lokal (was du ja planst - gut!)
|
||
Authenticator-Push ohne Backup: Wenn das Handy kaputt ist, kommt niemand rein → Recovery-Codes drucken
|
||
✅ Bessere Alternative zu "öffentlich erreichbar":
|
||
Tailscale/Headscale (Mesh VPN):
|
||
|
||
Tablet ist im VPN-Netzwerk
|
||
Keine öffentlichen Ports nötig
|
||
Du hast "von überall" Zugriff (vom Handy aus)
|
||
Jellyfin bleibt intern
|
||
Kein Authentik nötig, einfacher JWT-Auth reicht
|
||
Zusammenfassung
|
||
Deine Architektur ist machbar, aber over-engineered für eine Kinder-App. Für 2+5 Jahre alte Kinder würde ich empfehlen:
|
||
|
||
Lokale Instanz im Haus (kein öffentlicher Zugriff)
|
||
Tailscale für Eltern-Zugriff von unterwegs
|
||
Einfacher JWT statt Authentik (nur für Eltern-Login)
|
||
Jellyfin separat im VPN, nicht im Game integriert (Kinder sollen spielen, nicht streamen 😉)
|
||
Willst du wirklich den Full-Enterprise-Stack mit öffentlicher Domain? Dann liefere ich dir die vollständige Traefik + Authentik + Compose-Konfiguration. Aber überlege dir die Datenschutz-Implikationen (DSGVO bei öffentlicher Kinder-App).
|
||
|
||
Nächster Schritt: Soll ich die Tailscale-Alternative (sicherer, einfacher) oder den öffentlichen Reverse-Proxy (komplexer, flexibler) detaillieren?
|
||
|
||
Ok, npm ist bereits vorhanden. Die App ist nur für eigenen Gebrauch. Als Sicherheitsbedenken zur Kenntnis genommen. Es geht um Urlaub oder besuch bei Freunden.
|
||
|
||
Freigabe war mir wichtig, das die Kinder die App zwar starten können aber halt nur nach Freigabe durch die Eltern nutzen können. Auch jellyfin ist vorhanden. Und soll einfach mit integriert werden. Im Idealfall über einen Nutzer mit den Rechten des Kindes ( nicht altersgerecht=nicht auswählbar).
|
||
VPN ist eine Möglichkeit ist aber mehr Traffic und fordert mehr Bandbreite als eine einfache ssl Verbindung über Reversed Proxy.
|
||
Offline Variante ohne KI und ohne Jellyfin klingt super. Vielleicht mit lokalen Medien
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für eine Minute
|
||
Perfekt! Das ist ein pragmatisches Homelab-Setup – deutlich sinnvoller als Enterprise-Overhead. Mit NPM (Nginx Proxy Manager) hast du bereits das beste Tool für SSL-Offloading.
|
||
|
||
Hier ist das pragmatische Konzept für den Familien-URLaubs-Modus:
|
||
|
||
1. Architektur: "Freigabe-Schleuse" mit NPM
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
Internet → NPM (443) → [Entscheidung]:
|
||
│
|
||
┌─────────────────┼─────────────────┐
|
||
│ │
|
||
Kein Token Token vorhanden
|
||
│ │
|
||
├─ Static ├─ Game API
|
||
├─ "Warte auf ├─ Jellyfin Proxy
|
||
│ Freigabe" └─ Ollama (optional)
|
||
│
|
||
└─ Parental-Gate
|
||
(QR-Code / TOTP)
|
||
Warum kein VPN doch besser ist (kurz):
|
||
Dein Bandbreiten-Argument stimmt für das Streaming, aber für Jellyfin im Urlaub ist ein WireGuard-Tunnel oft stabiler bei Hotel-WLANs (die oft HTTPS filtern/throtteln). Aber wir machen es mit NPM wie gewünscht.
|
||
|
||
2. Das "Parental Gate" System
|
||
Da die Kinder die App starten können, aber erst nach Freigabe spielen:
|
||
|
||
Option A: TOTP-Daily-Code (Einfachst)
|
||
Eltern haben Google Authenticator mit einem Secret (z.B. FAMILIE2024)
|
||
Code wechselt alle 30 Minuten (oder täglich für Urlaub)
|
||
Kinder sehen nur: "Frage Mama nach dem Code"
|
||
Eingabe im Tablet entsperrt Session-Cookie für 2 Stunden
|
||
Option B: Push-Freigabe (Eleganter)
|
||
Tablet zeigt QR-Code (Session-ID)
|
||
Eltern scannen mit Handy → Web-App öffnet sich → Button "Freigeben für 30 Min"
|
||
Tablet pollt alle 5 Sekunden /api/status → Status ändert sich zu "grün"
|
||
Implementierung (Node.js/Express):
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// Backend (ein Teil deiner Game-App)
|
||
const sessions = new Map();
|
||
|
||
// Eltern-Endpunkt (geschützt via NPM Basic Auth oder separate Domain)
|
||
app.post('/api/parent/approve', (req, res) => {
|
||
const { sessionId, duration } = req.body;
|
||
sessions.set(sessionId, {
|
||
approved: true,
|
||
expires: Date.now() + (duration * 60000),
|
||
user: 'kind-max' // Für Jellyfin-User-Auswahl
|
||
});
|
||
});
|
||
|
||
// Tablet checkt Status
|
||
app.get('/api/status/:sessionId', (req, res) => {
|
||
const session = sessions.get(req.params.sessionId);
|
||
if (!session) return res.json({ status: 'pending' });
|
||
if (session.expires < Date.now()) return res.json({ status: 'expired' });
|
||
res.json({
|
||
status: 'approved',
|
||
jellyfinUser: session.user,
|
||
token: generateJWT(session)
|
||
});
|
||
});
|
||
3. NPM (Nginx Proxy Manager) Konfiguration
|
||
A. SSL-Zertifikate
|
||
kinder-tablet.deinedomain.de (oder DuckDNS/Cloudflare-Tunnel)
|
||
Let's Encrypt über NPM GUI
|
||
B. Proxy Hosts
|
||
1. Haupt-Game (Standard)
|
||
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
Domain: spiele.zuhause.de
|
||
Forward: http://kids-game:3000
|
||
SSL: Aktiv
|
||
Access List: Öffentlich (für Kinder-Startseite)
|
||
2. Admin/Eltern-Freigabe (Geschützt)
|
||
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
Domain: freigabe.zuhause.de
|
||
Forward: http://kids-game:3000/admin
|
||
SSL: Aktiv
|
||
Access List: IP-Whitelist (nur euer Handy/WLAN)
|
||
ODER HTTP Basic Auth (User/Pass für Eltern)
|
||
3. Jellyfin-Proxy (Intern)
|
||
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
Domain: media-intern.zuhause.de (nicht exposed!)
|
||
Forward: http://jellyfin:8096
|
||
Block Common Exploits: An
|
||
Access List: Nur localhost/Intern (über Docker-Netzwerk)
|
||
Wichtig: Jellyfin bekommt keinen öffentlichen DNS-Eintrag! Nur über die Game-App erreichbar.
|
||
|
||
4. Jellyfin-Integration: Altersfilter
|
||
Setup in Jellyfin:
|
||
User kind-2jahre anlegen → Max Parental Rating: "Toddlers"
|
||
User kind-5jahre anlegen → Max Parental Rating: "Children"
|
||
Bibliotheken taggen mit entsprechenden Ratings
|
||
API-Integration:
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// Game-Backend (Proxy zu Jellyfin)
|
||
app.get('/api/media/episodes', async (req, res) => {
|
||
// Token kommt aus der Freigabe-Session (siehe oben)
|
||
const userId = req.session.jellyfinUser; // 'kind-2jahre' oder 'kind-5jahre'
|
||
|
||
const response = await fetch(`http://jellyfin:8096/Users/${userId}/Items`, {
|
||
headers: {
|
||
'X-Emby-Token': process.env.JELLYFIN_API_KEY
|
||
}
|
||
});
|
||
|
||
const data = await response.json();
|
||
// Filtere nur "Continue Watching" oder "Next Up"
|
||
const safeItems = data.Items.filter(item =>
|
||
item.ParentalRating <= (userId.includes('2') ? 1 : 5)
|
||
);
|
||
|
||
res.json(safeItems);
|
||
});
|
||
|
||
// Video-Stream (kein Direct-Expose)
|
||
app.get('/api/media/stream/:id', async (req, res) => {
|
||
// Proxy den Stream durch dein Backend (kein CORS, kein direkter Jellyfin-Zugang)
|
||
const stream = await fetch(`http://jellyfin:8096/Videos/${req.params.id}/stream?Static=true`, {
|
||
headers: { 'X-Emby-Token': process.env.JELLYFIN_API_KEY }
|
||
});
|
||
stream.body.pipe(res);
|
||
});
|
||
UI im Game:
|
||
Große Kacheln: Nur Serien-Titel-Bilder (keine Beschreibung, die Kinder können nicht lesen)
|
||
Auto-Play: Nächste Folge startet automatisch (kein "Was soll ich gucken?"-Stress)
|
||
Maximale Zeit: Game-Timer zeigt Sanduhr (wenn Eltern nur 20 Min freigegeben)
|
||
5. Offline-Modus: "Urlaubs-Paket"
|
||
Da du weniger Bandbreite willst: Pre-Download für Offline
|
||
|
||
Technik:
|
||
Service Worker (PWA) cached Spiele-Logik
|
||
IndexedDB speichert 3-4 Folgen Peppa Wutz lokal (als Blob)
|
||
Sync API: Wenn wieder online, sync Fortschritt (optional)
|
||
Docker-Volume Strategie:
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
⌄
|
||
⌄
|
||
⌄
|
||
services:
|
||
kids-game:
|
||
volumes:
|
||
- ./offline-media:/app/public/media:ro # Lokale MP4s für Offline-Modus
|
||
- ./game-cache:/app/cache # Service Worker Cache
|
||
Offline-Jellyfin (lokal only):
|
||
Ein zusätzlicher Jellyfin-Container mit lokalen Dateien (nicht NAS-gemountet)
|
||
Nur kleine, transkodierte Dateien (720p, 2MBit) für Tablet-Speicher
|
||
Im Urlaub: Switch auf localhost:8096 statt Domain
|
||
6. Docker-Compose (Pragmatik-Version)
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
version: '3.8'
|
||
|
||
services:
|
||
# Die Game-App (dein NPM-Target)
|
||
kids-app:
|
||
image: node:18-alpine
|
||
working_dir: /app
|
||
volumes:
|
||
- ./game-code:/app
|
||
- ./downloads:/app/public/downloads:ro # Offline-Videos
|
||
command: sh -c "npm install && npm start"
|
||
environment:
|
||
- JELLYFIN_URL=http://jellyfin:8096
|
||
- JELLYFIN_API_KEY=${JELLYFIN_KEY}
|
||
- SESSION_SECRET=${SECRET}
|
||
networks:
|
||
- kidsnet
|
||
|
||
# Jellyfin (nur intern!)
|
||
jellyfin:
|
||
image: jellyfin/jellyfin
|
||
volumes:
|
||
- ./jellyfin-config:/config
|
||
- ./media-kids:/media:ro # Nur Kids-Content mounten!
|
||
networks:
|
||
- kidsnet
|
||
# Keine ports! Nur über kids-app erreichbar
|
||
|
||
# Optional: Ollama für Zuhause
|
||
ollama:
|
||
image: ollama/ollama
|
||
volumes:
|
||
- ./ollama:/root/.ollama
|
||
networks:
|
||
- kidsnet
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
memory: 2G # Für schwaches Urlaubs-Tablet begrenzt
|
||
|
||
networks:
|
||
kidsnet:
|
||
driver: bridge
|
||
7. Der "Urlaubs-Workflow"
|
||
Zuhause vorbereiten:
|
||
Tablet mit NPM-URL konfiguriert
|
||
5 Folgen Lieblingsserie herunterladen (via Jellyfin Download-Funktion)
|
||
Offline-Cache leeren, neu füllen
|
||
Im Hotel:
|
||
Tablet an: Zeigt "Warte auf Freigabe" mit QR-Code
|
||
Papa scannt Code → "Freigeben für 30 Min" → Kinder spielen
|
||
Nach 30 Min: Screen lockt wieder (oder wechselt zu "Pause machen" Animation)
|
||
Kein Internet im Hotel?:
|
||
App erkennt navigator.onLine === false
|
||
Zeigt nur Offline-Spiele + heruntergeladene Videos
|
||
Keine Freigabe nötig (Eltern sind ja da)
|
||
Nächster konkreter Schritt: Soll ich die Service-Worker-Implementierung für den Offline-Modus codieren, oder das Parental-Gate mit QR-Code skripten? Beides ist reiner Vanilla-JS/Node.js Code, passt in dein NPM-Setup.
|
||
|
||
Wie läuft das ganze denn jetzt ab. Docker + APK? Ja bereite gerne alles vor
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 46 Sekunden
|
||
Hier ist der komplette Bauplan mit allen Dateien, die du brauchst. Wir haben einen Hybrid-Stack: Der Docker-Server ist das "Gehirn", die APK ist der "Kiosk-Rahmen" mit Offline-Cache.
|
||
|
||
Architektur-Überblick
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
┌─────────────────┐ WiFi/4G ┌──────────────────┐
|
||
│ Tablet │ ═══════════════════════►│ Docker-Server │
|
||
│ (Android APK) │ │ (Zuhause/VPS) │
|
||
│ │ │ │
|
||
│ ┌─────────────┐ │ HTTP/HTTPS │ ┌──────────────┐ │
|
||
│ │ WebView │ │◄═══════════════════════════│ │ Node.js │ │
|
||
│ │ (PWA Mode) │ │ │ │ Game-API │ │
|
||
│ │ │ │ (Fallback: Offline) │ │ │ │
|
||
│ │ • Spiele │ │ │ │ • Jellyfin │ │
|
||
│ │ • Videos │ │ │ │ • Ollama │ │
|
||
│ │ • Auth │ │ │ │ • Freigabe │ │
|
||
│ └─────────────┘ │ └──────────────────┘
|
||
└─────────────────┘
|
||
Phase 1: Docker-Server (Das Backend)
|
||
Erstelle diese Struktur auf deinem Server:
|
||
|
||
docker-compose.yml
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
70
|
||
71
|
||
72
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
version: '3.8'
|
||
kids-game:
|
||
build: ./game-server
|
||
container_name: kids-game
|
||
restart: unless-stopped
|
||
environment:
|
||
- NODE_ENV=production
|
||
- JELLYFIN_URL=http://jellyfin:8096
|
||
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
|
||
- SESSION_SECRET=${SESSION_SECRET:-random-secret-change-me}
|
||
- OFFLINE_MODE=false
|
||
volumes:
|
||
- ./downloads:/app/public/downloads:ro # Offline-Videos
|
||
networks:
|
||
- kids-net
|
||
# Kein Port nach außen! Nur via NPM erreichbar
|
||
|
||
# Jellyfin (nur intern)
|
||
jellyfin:
|
||
image: jellyfin/jellyfin:latest
|
||
container_name: jellyfin-kids
|
||
restart: unless-stopped
|
||
environment:
|
||
- PUID=1000
|
||
- PGID=1000
|
||
volumes:
|
||
- ./jellyfin-config:/config
|
||
- ./media-kids:/media:ro # Nur Kids-Content!
|
||
networks:
|
||
- kids-net
|
||
# Keine Ports exposed!
|
||
|
||
# Optional: Ollama für KI
|
||
ollama:
|
||
image: ollama/ollama:latest
|
||
container_name: ollama-kids
|
||
restart: unless-stopped
|
||
volumes:
|
||
- ./ollama-models:/root/.ollama
|
||
networks:
|
||
- kids-net
|
||
deploy:
|
||
resources:
|
||
reservations:
|
||
devices:
|
||
- driver: nvidia
|
||
count: all
|
||
capabilities: [gpu]
|
||
|
||
# NPM (Reverse Proxy) - Der einzige öffentliche Zugang
|
||
npm:
|
||
image: jc21/nginx-proxy-manager:latest
|
||
container_name: npm
|
||
restart: unless-stopped
|
||
ports:
|
||
- "80:80"
|
||
- "443:443"
|
||
- "81:81" # Admin-UI
|
||
volumes:
|
||
- ./npm-data:/data
|
||
- ./letsencrypt:/etc/letsencrypt
|
||
networks:
|
||
- kids-net
|
||
- default
|
||
|
||
networks:
|
||
kids-net:
|
||
driver: bridge
|
||
internal: true # Jellyfin & Ollama nur intern!
|
||
game-server/Dockerfile
|
||
dockerfile
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
FROM node:18-alpine
|
||
WORKDIR /app
|
||
COPY package*.json ./
|
||
RUN npm ci --only=production
|
||
COPY . .
|
||
EXPOSE 3000
|
||
CMD ["node", "server.js"]
|
||
game-server/server.js (Schnittstelle)
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
const express = require('express');
|
||
const path = require('path');
|
||
const app = express();
|
||
|
||
// Statische Dateien (PWA)
|
||
app.use(express.static('public'));
|
||
|
||
// Health-Check für APK
|
||
app.get('/api/health', (req, res) => res.json({ status: 'online', timestamp: Date.now() }));
|
||
|
||
// Session-Status für Parental Gate
|
||
const sessions = new Map();
|
||
|
||
app.post('/api/session/create', (req, res) => {
|
||
const id = Math.random().toString(36).substring(7);
|
||
sessions.set(id, { status: 'pending', created: Date.now() });
|
||
res.json({ sessionId: id, qrData: `kids://auth/${id}` });
|
||
});
|
||
|
||
app.get('/api/session/:id', (req, res) => {
|
||
const session = sessions.get(req.params.id);
|
||
if (!session) return res.status(404).json({ error: 'not found' });
|
||
res.json(session);
|
||
});
|
||
|
||
// Freigabe durch Eltern
|
||
app.post('/api/session/:id/approve', express.json(), (req, res) => {
|
||
const { duration = 30, jellyfinUser = 'kind-default' } = req.body;
|
||
sessions.set(req.params.id, {
|
||
status: 'approved',
|
||
approvedAt: Date.now(),
|
||
expires: Date.now() + (duration * 60000),
|
||
jellyfinUser
|
||
});
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// Jellyfin Proxy (sicher, ohne direkten Zugriff)
|
||
app.get('/api/media/list', async (req, res) => {
|
||
try {
|
||
const response = await fetch(`${process.env.JELLYFIN_URL}/Users`, {
|
||
headers: { 'X-Emby-Token': process.env.JELLYFIN_API_KEY }
|
||
});
|
||
const users = await response.json();
|
||
res.json(users);
|
||
} catch (e) {
|
||
res.status(503).json({ error: 'jellyfin offline', offline: true });
|
||
}
|
||
});
|
||
|
||
// Offline-Modus Detection
|
||
app.get('/api/config', (req, res) => {
|
||
res.json({
|
||
serverUrl: process.env.OFFLINE_MODE === 'true' ? null : 'https://deinedomain.de',
|
||
version: '1.0.0'
|
||
});
|
||
});
|
||
|
||
app.listen(3000, () => console.log('Kids Game Server running'));
|
||
game-server/public/index.html (PWA-Grundgerüst)
|
||
html
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
|
||
Vorschau
|
||
106
|
||
107
|
||
108
|
||
109
|
||
110
|
||
111
|
||
112
|
||
113
|
||
114
|
||
115
|
||
116
|
||
117
|
||
118
|
||
119
|
||
120
|
||
121
|
||
122
|
||
123
|
||
124
|
||
125
|
||
126
|
||
127
|
||
128
|
||
129
|
||
130
|
||
131
|
||
132
|
||
133
|
||
134
|
||
135
|
||
136
|
||
137
|
||
138
|
||
139
|
||
140
|
||
141
|
||
142
|
||
143
|
||
144
|
||
145
|
||
146
|
||
147
|
||
148
|
||
149
|
||
150
|
||
151
|
||
152
|
||
153
|
||
154
|
||
155
|
||
156
|
||
157
|
||
158
|
||
159
|
||
160
|
||
161
|
||
162
|
||
163
|
||
164
|
||
165
|
||
166
|
||
167
|
||
168
|
||
169
|
||
170
|
||
171
|
||
172
|
||
173
|
||
174
|
||
175
|
||
176
|
||
177
|
||
178
|
||
179
|
||
180
|
||
181
|
||
182
|
||
183
|
||
184
|
||
185
|
||
186
|
||
187
|
||
188
|
||
189
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
<!DOCTYPE html>
|
||
// QR Code generieren
|
||
new QRious({
|
||
element: document.getElementById('qrCanvas'),
|
||
value: data.qrData,
|
||
size: 200
|
||
});
|
||
|
||
// Polling nach Freigabe
|
||
this.checkInterval = setInterval(() => this.checkApproval(), 5000);
|
||
} catch (e) {
|
||
console.log('Server nicht erreichbar - Offline Modus');
|
||
this.showOfflineMode();
|
||
}
|
||
},
|
||
|
||
async checkApproval() {
|
||
try {
|
||
const res = await fetch(`/api/session/${this.sessionId}`);
|
||
const data = await res.json();
|
||
|
||
if (data.status === 'approved') {
|
||
clearInterval(this.checkInterval);
|
||
this.unlock(data.expires);
|
||
}
|
||
} catch (e) {
|
||
// Server weg -> bleibt gesperrt oder Offline-Modus
|
||
}
|
||
},
|
||
|
||
unlock(expires) {
|
||
document.getElementById('lockScreen').classList.remove('active');
|
||
document.getElementById('gameScreen').classList.add('active');
|
||
|
||
// Countdown
|
||
const updateTimer = () => {
|
||
const left = Math.floor((expires - Date.now()) / 1000);
|
||
if (left <= 0) location.reload(); // Resperren
|
||
const min = Math.floor(left / 60);
|
||
const sec = left % 60;
|
||
// Timer anzeigen...
|
||
};
|
||
setInterval(updateTimer, 1000);
|
||
},
|
||
|
||
showOfflineMode() {
|
||
// Einfache Offline-Spiele ohne Freigabe (nur für Notfall)
|
||
if (confirm('Server nicht erreichbar. Offline-Spiele starten?')) {
|
||
document.getElementById('lockScreen').style.display = 'none';
|
||
document.getElementById('gameScreen').classList.add('active');
|
||
document.body.classList.add('offline');
|
||
}
|
||
},
|
||
|
||
startGame() {
|
||
// Simples Blubber-Spiel
|
||
const canvas = document.getElementById('gameCanvas');
|
||
const createBubble = () => {
|
||
const b = document.createElement('div');
|
||
b.className = 'bubble';
|
||
b.style.left = Math.random() * 80 + 10 + '%';
|
||
b.style.top = Math.random() * 80 + 10 + '%';
|
||
b.style.backgroundColor = ['#FF6B6B', '#4ECDC4', '#45B7D1'][Math.floor(Math.random() * 3)];
|
||
b.ontouchstart = () => {
|
||
b.remove();
|
||
new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmwhBTGH0fPTjjkIHGu8+OGbURE...').play();
|
||
};
|
||
canvas.appendChild(b);
|
||
setTimeout(() => b.remove(), 5000);
|
||
};
|
||
setInterval(createBubble, 2000);
|
||
},
|
||
|
||
requestClose() {
|
||
// Sendet Nachricht an Android-App zum Schließen
|
||
if (window.AndroidInterface) {
|
||
window.AndroidInterface.exitApp();
|
||
}
|
||
}
|
||
};
|
||
|
||
app.init();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
game-server/public/manifest.json (PWA)
|
||
json
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
⌄
|
||
⌄
|
||
{
|
||
"name": "KinderWelt",
|
||
"short_name": "Kinder",
|
||
"start_url": "/",
|
||
"display": "standalone",
|
||
"background_color": "#667eea",
|
||
"theme_color": "#764ba2",
|
||
"icons": [
|
||
{ "src": "/icon-192.png", "sizes": "192x192" },
|
||
{ "src": "/icon-512.png", "sizes": "512x512" }
|
||
]
|
||
}
|
||
game-server/public/sw.js (Service Worker für Offline)
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
const CACHE_NAME = 'kids-game-v1';
|
||
const urlsToCache = [
|
||
'/',
|
||
'/index.html',
|
||
'/style.css', // falls ausgelagert
|
||
'/offline-game.js'
|
||
];
|
||
|
||
self.addEventListener('install', event => {
|
||
event.waitUntil(
|
||
caches.open(CACHE_NAME)
|
||
.then(cache => cache.addAll(urlsToCache))
|
||
);
|
||
});
|
||
|
||
self.addEventListener('fetch', event => {
|
||
event.respondWith(
|
||
caches.match(event.request)
|
||
.then(response => {
|
||
// Cache first, then network
|
||
if (response) return response;
|
||
return fetch(event.request).catch(() => {
|
||
// Fallback für Bilder/Videos
|
||
if (event.request.url.includes('/media/')) {
|
||
return new Response('Offline', { status: 503 });
|
||
}
|
||
});
|
||
})
|
||
);
|
||
});
|
||
Phase 2: Android APK (Der Kiosk-Rahmen)
|
||
Projekt-Struktur (Android Studio)
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
KidsKiosk/
|
||
├── app/
|
||
│ ├── src/main/
|
||
│ │ ├── java/com/kids/kiosk/
|
||
│ │ │ ├── MainActivity.kt
|
||
│ │ │ └── AdminReceiver.kt
|
||
│ │ ├── res/
|
||
│ │ │ ├── layout/activity_main.xml
|
||
│ │ │ └── xml/device_admin.xml
|
||
│ │ └── AndroidManifest.xml
|
||
│ └── build.gradle
|
||
└── build.gradle
|
||
build.gradle (Module: app)
|
||
gradle
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
plugins {
|
||
id 'com.android.application'
|
||
id 'org.jetbrains.kotlin.android'
|
||
}
|
||
|
||
android {
|
||
namespace 'com.kids.kiosk'
|
||
compileSdk 34
|
||
|
||
defaultConfig {
|
||
applicationId "com.kids.kiosk"
|
||
minSdk 24
|
||
targetSdk 34
|
||
versionCode 1
|
||
versionName "1.0"
|
||
}
|
||
|
||
buildFeatures {
|
||
viewBinding true
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
implementation 'androidx.core:core-ktx:1.12.0'
|
||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||
implementation 'com.google.android.material:material:1.11.0'
|
||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||
implementation 'androidx.webkit:webkit:1.9.0'
|
||
}
|
||
AndroidManifest.xml
|
||
xml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||
package="com.kids.kiosk">
|
||
|
||
<uses-permission android:name="android.permission.INTERNET" />
|
||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||
<uses-permission android:name="android.permission.REORDER_TASKS" />
|
||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||
|
||
<!-- Für Kiosk-Modus -->
|
||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||
|
||
<application
|
||
android:allowBackup="false"
|
||
android:icon="@mipmap/ic_launcher"
|
||
android:label="KinderWelt"
|
||
android:roundIcon="@mipmap/ic_launcher_round"
|
||
android:supportsRtl="true"
|
||
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
|
||
android:usesCleartextTraffic="false">
|
||
|
||
<activity
|
||
android:name=".MainActivity"
|
||
android:exported="true"
|
||
android:launchMode="singleTask"
|
||
android:screenOrientation="landscape"
|
||
android:configChanges="orientation|screenSize|smallestScreenSize">
|
||
<intent-filter>
|
||
<action android:name="android.intent.action.MAIN" />
|
||
<category android:name="android.intent.category.LAUNCHER" />
|
||
</intent-filter>
|
||
</activity>
|
||
|
||
<!-- Device Admin für Lock Task Mode -->
|
||
<receiver
|
||
android:name=".AdminReceiver"
|
||
android:description="@string/device_admin_description"
|
||
android:label="@string/device_admin_label"
|
||
android:permission="android.permission.BIND_DEVICE_ADMIN"
|
||
android:exported="true">
|
||
<meta-data
|
||
android:name="android.app.device_admin"
|
||
android:resource="@xml/device_admin" />
|
||
<intent-filter>
|
||
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||
</intent-filter>
|
||
</receiver>
|
||
</application>
|
||
</manifest>
|
||
res/xml/device_admin.xml
|
||
xml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
⌄
|
||
⌄
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
|
||
<uses-policies>
|
||
<force-lock />
|
||
<limit-password />
|
||
</uses-policies>
|
||
</device-admin>
|
||
MainActivity.kt (Der Kiosk-Controller)
|
||
kotlin
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
139
|
||
140
|
||
141
|
||
142
|
||
143
|
||
144
|
||
145
|
||
146
|
||
147
|
||
148
|
||
149
|
||
150
|
||
151
|
||
152
|
||
153
|
||
154
|
||
155
|
||
156
|
||
157
|
||
158
|
||
159
|
||
160
|
||
161
|
||
162
|
||
163
|
||
164
|
||
165
|
||
166
|
||
167
|
||
168
|
||
169
|
||
170
|
||
171
|
||
172
|
||
173
|
||
174
|
||
175
|
||
176
|
||
177
|
||
178
|
||
179
|
||
180
|
||
181
|
||
182
|
||
183
|
||
184
|
||
185
|
||
186
|
||
187
|
||
188
|
||
189
|
||
190
|
||
191
|
||
192
|
||
193
|
||
194
|
||
195
|
||
196
|
||
197
|
||
198
|
||
199
|
||
200
|
||
201
|
||
202
|
||
203
|
||
204
|
||
205
|
||
206
|
||
import android.webkit.*
|
||
}
|
||
// Ansonsten: Nichts tun (Blockieren)
|
||
// Eltern können via 5-fach Tap oben links entsperren
|
||
}
|
||
|
||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||
// Blockiere Home, Recent Apps, etc.
|
||
return when (keyCode) {
|
||
KeyEvent.KEYCODE_HOME -> true
|
||
KeyEvent.KEYCODE_APP_SWITCH -> true
|
||
else -> super.onKeyDown(keyCode, event)
|
||
}
|
||
}
|
||
|
||
private fun hideSystemUI() {
|
||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
|
||
}
|
||
|
||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||
super.onWindowFocusChanged(hasFocus)
|
||
if (hasFocus) hideSystemUI()
|
||
// Re-Enable Kiosk wenn Fokus zurück
|
||
if (hasFocus && isDeviceOwner() && !isKioskMode) {
|
||
enableKioskMode()
|
||
}
|
||
}
|
||
|
||
// Eltern-Entsperrung: 5-fach Tap oben links
|
||
private var tapCount = 0
|
||
private var lastTapTime = 0L
|
||
|
||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||
if (event?.action == MotionEvent.ACTION_DOWN) {
|
||
if (event.y < 100 && event.x < 100) {
|
||
val now = System.currentTimeMillis()
|
||
if (now - lastTapTime < 500) {
|
||
tapCount++
|
||
if (tapCount >= 5) {
|
||
disableKioskMode()
|
||
finish() // App beenden
|
||
}
|
||
} else {
|
||
tapCount = 1
|
||
}
|
||
lastTapTime = now
|
||
}
|
||
}
|
||
return super.onTouchEvent(event)
|
||
}
|
||
|
||
// JavaScript Interface
|
||
class WebAppInterface(private val mContext: Context) {
|
||
@JavascriptInterface
|
||
fun exitApp() {
|
||
(mContext as MainActivity).disableKioskMode()
|
||
(mContext as MainActivity).finish()
|
||
}
|
||
|
||
@JavascriptInterface
|
||
fun isOnline(): Boolean {
|
||
return (mContext as MainActivity).isOnline()
|
||
}
|
||
}
|
||
}
|
||
AdminReceiver.kt
|
||
kotlin
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
package com.kids.kiosk
|
||
|
||
import android.app.admin.DeviceAdminReceiver
|
||
import android.content.Context
|
||
import android.content.Intent
|
||
|
||
class AdminReceiver : DeviceAdminReceiver() {
|
||
override fun onEnabled(context: Context, intent: Intent) {
|
||
super.onEnabled(context, intent)
|
||
}
|
||
}
|
||
res/layout/activity_main.xml
|
||
xml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
⌄
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||
android:layout_width="match_parent"
|
||
android:layout_height="match_parent">
|
||
|
||
<WebView
|
||
android:id="@+id/webView"
|
||
android:layout_width="match_parent"
|
||
android:layout_height="match_parent" />
|
||
|
||
</FrameLayout>
|
||
Phase 3: Deployment & Einrichtung
|
||
Schritt 1: Server starten
|
||
bash
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
# Auf deinem Server
|
||
mkdir kids-world && cd kids-world
|
||
# Kopiere alle Dateien von oben
|
||
docker-compose up -d
|
||
|
||
# NPM einrichten (Web-UI auf Port 81)
|
||
# 1. Proxy Host anlegen:
|
||
# Domain: kinder.deinedomain.de
|
||
# Forward: http://kids-game:3000
|
||
# SSL: Lets Encrypt
|
||
# 2. Access List für /admin (Eltern-Bereich) optional
|
||
Schritt 2: Android App bauen
|
||
bash
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
# In Android Studio
|
||
Build → Generate Signed Bundle/APK
|
||
# ODER für Entwicklung:
|
||
./gradlew assembleDebug
|
||
Schritt 3: Tablet als "Device Owner" einrichten (Wichtig für Kiosk!)
|
||
Achtung: Das Tablet muss dafür neu/werksreset sein oder ihr müsst den bestehenden Account entfernen.
|
||
|
||
bash
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
# Über ADB (USB-Debugging aktivieren)
|
||
adb shell dpm set-device-owner com.kids.kiosk/.AdminReceiver
|
||
Oder via QR-Code Enrollment (für neue Geräte):
|
||
|
||
bash
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
# Erstelle QR-Code mit:
|
||
# {
|
||
# "android.app.extra.PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME": "com.kids.kiosk/.AdminReceiver",
|
||
# "android.app.extra.PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM": "DEIN_HASH",
|
||
# "android.app.extra.PROVISIONING_DEVICE_ADMIN_PACKAGE_DOWNLOAD_LOCATION": "https://deinedomain.de/app.apk"
|
||
# }
|
||
Schritt 4: Offline-Inhalte vorbereiten
|
||
Erstelle app/src/main/assets/offline/index.html mit lokalen Spielen (einfaches HTML/JS), das geladen wird wenn kein Internet.
|
||
|
||
Zusammenfassung Ablauf
|
||
Zuhause:
|
||
Docker startet (Jellyfin, Game-Server)
|
||
Tablet verbindet sich mit WiFi → zeigt QR-Code
|
||
Papa scannt → "Freigeben für 30 Min" → Kinder spielen
|
||
Unterwegs/Im Hotel:
|
||
Kein Internet? → APK zeigt Offline-Spiele aus Assets
|
||
Hotel-WiFi? → Verbindet zu deinem Server (wenn exposed) oder bleibt Offline
|
||
Videos vorab runtergeladen in /downloads (Jellyfin Sync)
|
||
Sicherheit:
|
||
Kinder können nicht raus (Lock Task Mode)
|
||
Nur du kannst entsperren (5-Tap oben links)
|
||
Keine externen Links möglich
|
||
Brauchst du jetzt: Die build.gradle für die Eltern-Companion-App (zum Freigeben via QR-Code scannen)? Oder reicht ein einfacher Web-Link für die Freigabe (https://deinedomain.de/admin/freigabe?code=XXX)?
|
||
|
||
Ich empfehle Letzteres: Einfache Webseite für die Freigabe, die nur für deine IP erreichbar ist oder mit HTTP-Auth geschützt.
|
||
|
||
Oh, lass uns auch direkt einen Emulator für Sega Mega Drive Spiele und nes sowie SNES Spiele einbauen. Zur Steuerung nehmen wir einen Bluetooth Controller
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 29 Sekunden
|
||
Das ist der Game-Changer! Retro-Emulation passt perfekt zu "Reaktion & Bedienung" für Kinder, und Bluetooth-Controller sind robust gegenüber "rabiater" Kinder-Bedienung (im Gegensatz zu Touchscreen).
|
||
|
||
Hier ist die Erweiterte Architektur mit Emulation:
|
||
|
||
1. Technologie-Stack: EmulatorJS + WebView
|
||
Wir nutzen EmulatorJS (WASM-Emulatoren) direkt in der WebView – das ist performanter als native Android-Emulatoren und bleibt in unserer Kiosk-Architektur.
|
||
|
||
Unterstützte Systeme:
|
||
|
||
NES (Nintendo Entertainment System)
|
||
SNES (Super Nintendo)
|
||
Sega Mega Drive/Genesis
|
||
Optional: Game Boy, Game Boy Advance
|
||
2. Backend-Erweiterung (ROM-Management)
|
||
docker-compose.yml Erweiterung
|
||
yaml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
services:
|
||
# ... (bestehende Services) ...
|
||
|
||
# ROM-Server (einfacher nginx für ROMs)
|
||
rom-server:
|
||
image: nginx:alpine
|
||
container_name: roms
|
||
volumes:
|
||
- ./roms:/usr/share/nginx/html/roms:ro
|
||
- ./roms/nginx.conf:/etc/nginx/nginx.conf:ro
|
||
networks:
|
||
- kids-net
|
||
# Nur intern erreichbar (über kids-game proxy)
|
||
roms/nginx.conf (Schützt ROMs vor direktem Zugriff)
|
||
nginx
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
server {
|
||
listen 80;
|
||
root /usr/share/nginx/html;
|
||
|
||
# ROMs nur via Referrer-Check oder Auth-Header (simpler Schutz)
|
||
location /roms/ {
|
||
internal; # Nur interne Redirects erlaubt
|
||
alias /usr/share/nginx/html/roms/;
|
||
}
|
||
|
||
# Cover-Bilder dürfen extern angezeigt werden
|
||
location /covers/ {
|
||
alias /usr/share/nginx/html/covers/;
|
||
}
|
||
}
|
||
game-server/server.js (ROM-Proxy mit Lizenz-Check)
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
33
|
||
34
|
||
35
|
||
36
|
||
37
|
||
38
|
||
39
|
||
40
|
||
41
|
||
42
|
||
43
|
||
44
|
||
45
|
||
46
|
||
47
|
||
48
|
||
49
|
||
50
|
||
51
|
||
52
|
||
53
|
||
54
|
||
55
|
||
56
|
||
57
|
||
58
|
||
59
|
||
60
|
||
61
|
||
62
|
||
63
|
||
64
|
||
65
|
||
66
|
||
67
|
||
68
|
||
69
|
||
70
|
||
71
|
||
72
|
||
73
|
||
74
|
||
75
|
||
76
|
||
77
|
||
78
|
||
79
|
||
80
|
||
81
|
||
82
|
||
83
|
||
84
|
||
85
|
||
86
|
||
87
|
||
88
|
||
89
|
||
90
|
||
91
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// ... bestehende Routes ...
|
||
file: 'sonic.md',
|
||
cover: '/covers/sonic.jpg',
|
||
allowed: true, // Nur eigene Backups!
|
||
description: 'Hilf Sonic die Ringe zu sammeln'
|
||
},
|
||
'mario': {
|
||
title: 'Super Mario Bros',
|
||
system: 'nes',
|
||
file: 'mario.nes',
|
||
cover: '/covers/mario.jpg',
|
||
allowed: true
|
||
},
|
||
'zelda': {
|
||
title: 'Zelda: Link to the Past',
|
||
system: 'snes',
|
||
file: 'zelda.sfc',
|
||
cover: '/covers/zelda.jpg',
|
||
allowed: true
|
||
}
|
||
};
|
||
|
||
// API: Verfügbare Spiele (nur erlaubte)
|
||
app.get('/api/games/library', (req, res) => {
|
||
const games = Object.entries(ROMS_LIBRARY)
|
||
.filter(([_, data]) => data.allowed)
|
||
.map(([id, data]) => ({
|
||
id,
|
||
title: data.title,
|
||
system: data.system,
|
||
cover: data.cover,
|
||
description: data.description
|
||
}));
|
||
res.json(games);
|
||
});
|
||
|
||
// API: ROM laden (geschützt)
|
||
app.get('/api/games/rom/:id', async (req, res) => {
|
||
const game = ROMS_LIBRARY[req.params.id];
|
||
if (!game || !game.allowed) {
|
||
return res.status(404).json({ error: 'not found' });
|
||
}
|
||
|
||
// Proxy zu internem ROM-Server
|
||
try {
|
||
const response = await fetch(`http://rom-server/roms/${game.file}`);
|
||
if (!response.ok) throw new Error('ROM not found');
|
||
|
||
res.setHeader('Content-Type', 'application/octet-stream');
|
||
res.setHeader('Content-Disposition', `attachment; filename="${game.file}"`);
|
||
response.body.pipe(res);
|
||
} catch (e) {
|
||
res.status(404).json({ error: 'ROM nicht verfügbar (nur eigene Backups verwenden!)' });
|
||
}
|
||
});
|
||
|
||
// Save-State speichern (Cloud-Saves!)
|
||
app.post('/api/games/save/:id', express.raw({ type: 'application/octet-stream', limit: '10mb' }), (req, res) => {
|
||
const { id } = req.params;
|
||
const saveData = req.body; // EmulatorJS State
|
||
|
||
// Speichere in Datei oder DB
|
||
const fs = require('fs').promises;
|
||
const path = `./saves/${id}_${Date.now()}.state`;
|
||
|
||
fs.mkdir('./saves', { recursive: true })
|
||
.then(() => fs.writeFile(path, saveData))
|
||
.then(() => res.json({ success: true }))
|
||
.catch(err => res.status(500).json({ error: err.message }));
|
||
});
|
||
|
||
// Save-State laden
|
||
app.get('/api/games/save/:id/latest', async (req, res) => {
|
||
// Letzten Save-State zurückgeben
|
||
const fs = require('fs').promises;
|
||
const files = await fs.readdir('./saves').catch(() => []);
|
||
const latest = files
|
||
.filter(f => f.startsWith(req.params.id))
|
||
.sort().reverse()[0];
|
||
|
||
if (!latest) return res.status(404).json({ error: 'no save' });
|
||
|
||
const data = await fs.readFile(`./saves/${latest}`);
|
||
res.send(data);
|
||
});
|
||
3. Frontend: Retro-Spiele Bereich
|
||
game-server/public/emulator.html (Emulator-Page)
|
||
html
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
|
||
Vorschau
|
||
170
|
||
171
|
||
172
|
||
173
|
||
174
|
||
175
|
||
176
|
||
177
|
||
178
|
||
179
|
||
180
|
||
181
|
||
182
|
||
183
|
||
184
|
||
185
|
||
186
|
||
187
|
||
188
|
||
189
|
||
190
|
||
191
|
||
192
|
||
193
|
||
194
|
||
195
|
||
196
|
||
197
|
||
198
|
||
199
|
||
200
|
||
201
|
||
202
|
||
203
|
||
204
|
||
205
|
||
206
|
||
207
|
||
208
|
||
209
|
||
210
|
||
211
|
||
212
|
||
213
|
||
214
|
||
215
|
||
216
|
||
217
|
||
218
|
||
219
|
||
220
|
||
221
|
||
222
|
||
223
|
||
224
|
||
225
|
||
226
|
||
227
|
||
228
|
||
229
|
||
230
|
||
231
|
||
232
|
||
233
|
||
234
|
||
235
|
||
236
|
||
237
|
||
238
|
||
239
|
||
240
|
||
241
|
||
242
|
||
243
|
||
244
|
||
245
|
||
246
|
||
247
|
||
248
|
||
249
|
||
250
|
||
251
|
||
252
|
||
253
|
||
254
|
||
255
|
||
256
|
||
257
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
<!DOCTYPE html>
|
||
async saveState() {
|
||
if (!this.instance) return;
|
||
const data = await this.instance.getSaveState();
|
||
fetch(`/api/games/save/${this.currentGame}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
body: data
|
||
});
|
||
this.showNotification('💾 Gespeichert!');
|
||
},
|
||
|
||
async loadSaveState(gameId) {
|
||
try {
|
||
const res = await fetch(`/api/games/save/${gameId}/latest`);
|
||
if (res.ok) {
|
||
const data = await res.arrayBuffer();
|
||
this.instance.loadSaveState(new Uint8Array(data));
|
||
this.showNotification('🎮 Willkommen zurück!');
|
||
}
|
||
} catch(e) {
|
||
console.log('Kein Save-State vorhanden');
|
||
}
|
||
},
|
||
|
||
showNotification(text) {
|
||
const div = document.createElement('div');
|
||
div.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#48bb78;padding:20px 40px;border-radius:10px;font-size:24px;z-index:9999;';
|
||
div.textContent = text;
|
||
document.body.appendChild(div);
|
||
setTimeout(() => div.remove(), 2000);
|
||
},
|
||
|
||
// Gamepad API Setup
|
||
setupGamepad() {
|
||
window.addEventListener('gamepadconnected', (e) => {
|
||
console.log('Controller verbunden:', e.gamepad.id);
|
||
this.updateControllerStatus(true, e.gamepad.id);
|
||
this.pollGamepad();
|
||
});
|
||
|
||
window.addEventListener('gamepaddisconnected', (e) => {
|
||
this.updateControllerStatus(false, null);
|
||
});
|
||
|
||
// Prüfe ob bereits verbunden
|
||
const gamepads = navigator.getGamepads();
|
||
if (gamepads[0]) {
|
||
this.updateControllerStatus(true, gamepads[0].id);
|
||
this.pollGamepad();
|
||
}
|
||
},
|
||
|
||
updateControllerStatus(connected, name) {
|
||
const dot = document.getElementById('controller-dot');
|
||
const text = document.getElementById('controller-text');
|
||
|
||
if (connected) {
|
||
dot.classList.add('connected');
|
||
text.textContent = `🎮 ${name || 'Controller'} bereit`;
|
||
} else {
|
||
dot.classList.remove('connected');
|
||
text.textContent = 'Controller verbinden...';
|
||
}
|
||
},
|
||
|
||
pollGamepad() {
|
||
// Wird von EmulatorJS intern gehandelt, aber wir können zusätzliche Features machen
|
||
// z.B. Combo für "Eltern-Menü": START + SELECT gleichzeitig
|
||
const check = () => {
|
||
const gp = navigator.getGamepads()[0];
|
||
if (gp) {
|
||
// START (9) + SELECT (8) = Beenden
|
||
if (gp.buttons[9]?.pressed && gp.buttons[8]?.pressed) {
|
||
this.quit();
|
||
}
|
||
}
|
||
requestAnimationFrame(check);
|
||
};
|
||
check();
|
||
}
|
||
};
|
||
|
||
// Initialisierung
|
||
emulator.init();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|
||
4. Android-Anpassungen (Bluetooth & Gamepad)
|
||
Die WebView unterstützt standardmäßig die Gamepad API, aber wir müssen sicherstellen, dass Bluetooth-Permissions korrekt sind.
|
||
|
||
AndroidManifest.xml Erweiterung
|
||
xml
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
<!-- Bluetooth für Controller -->
|
||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- Android 12+ -->
|
||
|
||
<!-- Für Android 12+ -->
|
||
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
|
||
MainActivity.kt Erweiterung (Gamepad-Optimierung)
|
||
kotlin
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
// In onCreate, nach WebView Setup:
|
||
webView.settings.userAgentString = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
|
||
|
||
// Bluetooth-Activity Result Handler (für Pairing)
|
||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||
super.onActivityResult(requestCode, resultCode, data)
|
||
if (requestCode == REQUEST_ENABLE_BT) {
|
||
Toast.makeText(this, "Bluetooth bereit für Controller", Toast.LENGTH_SHORT).show()
|
||
}
|
||
}
|
||
|
||
// Companion Object
|
||
companion object {
|
||
const val REQUEST_ENABLE_BT = 1
|
||
}
|
||
5. Controller-Setup Guide für Eltern
|
||
Für die Kinder optimal:
|
||
|
||
Controller koppeln (einmalig):
|
||
Xbox/PS Controller per Bluetooth mit Tablet koppeln
|
||
Oder: 8BitDo SN30 Pro (hat direkten XInput-Modus)
|
||
Automatisches Mapping:
|
||
EmulatorJS erkennt automatisch Standard-Layout:
|
||
A/B/X/Y = A/B/X/Y
|
||
D-Pad = Steuerkreuz
|
||
L/R = Schultertasten
|
||
Start/Select = Menü
|
||
Sonderfunktionen:
|
||
Start + Select gleichzeitig = Spiel beenden (zurück zur Auswahl)
|
||
L + R = Schnell-Speichern (Save-State)
|
||
6. Ordner-Struktur auf dem Server
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
kids-world/
|
||
├── docker-compose.yml
|
||
├── game-server/
|
||
│ ├── server.js
|
||
│ └── public/
|
||
│ ├── index.html (Hauptspiel)
|
||
│ └── emulator.html (Retro-Bereich)
|
||
├── roms/ # ⚠️ Nur eigene Backups!
|
||
│ ├── sonic.md
|
||
│ ├── mario.nes
|
||
│ └── covers/
|
||
│ ├── sonic.jpg
|
||
│ └── mario.jpg
|
||
├── saves/ # Cloud-Saves
|
||
└── jellyfin-config/
|
||
7. Wichtige rechtliche Hinweise
|
||
⚠️ ROMs:
|
||
|
||
Nur eigene Backups von Spielen verwenden, die ihr physisch besitzt
|
||
Keine Download-ROMs aus dem Internet (Urheberrecht)
|
||
Die Cover-Bilder sollten selbst erstellt oder aus offiziellen Quellen stammen
|
||
BIOS-Dateien:
|
||
|
||
Einige Emulatoren (z.B. PS1) brauchen BIOS-Dateien → nur aus eigener Konsole dumpen
|
||
8. Integration in Haupt-App
|
||
Füge im Hauptmenü (index.html) einen neuen Button hinzu:
|
||
|
||
html
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
|
||
Vorschau
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
⌄
|
||
<div class="menu-item" onclick="location.href='/emulator.html'"
|
||
style="background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%);">
|
||
<span style="font-size: 60px;">🎮</span>
|
||
<h2>Retro-Spiele</h2>
|
||
<p>Sega, Nintendo & mehr</p>
|
||
</div>
|
||
Nächster Schritt: Soll ich das Controller-Mapping UI bauen (falls die Tasten vertauscht sind), oder eine Bewegungssteuerung als Fallback (wenn kein Controller da ist - z.B. Tablet neigen für Steuerung)?
|
||
|
||
https://ebay.us/m/KJdYqO
|
||
2x Kabelloser Bluetooth-Gamepad-Controller für Android-Handys und TV-Boxen T ]⭐
|
||
Ist der dafür geeignet?
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Nachgedacht für 12 Sekunden
|
||
Da ich den eBay-Link nicht direkt öffnen kann, bewerte ich ihn anhand der Beschreibung "2x Kabelloser Bluetooth-Gamepad für Android".
|
||
|
||
Kurz-Antwort: Wahrscheinlich ja, aber mit Einschränkungen für deine Altersgruppe (2+5 Jahre).
|
||
|
||
Was das wahrscheinlich ist
|
||
Das klingt nach einem generischen iPEGA-Clone oder T3/T4 Bluetooth-Controller (die billigen mit 2 Stück für 15-25€). Die sind technisch okay, aber...
|
||
|
||
Checkliste: Ist der für euch geeignet?
|
||
✅ Was spricht dafür:
|
||
Android-Kompatibilität: Werden erkannt als Gamepad (meistens)
|
||
Preis: Zwei Stück = gut für Geschwister (kein Streit)
|
||
Bluetooth: Funktioniert mit der HTML5 Gamepad API im Browser
|
||
⚠️ Warnsignale (typisch für diese Controller):
|
||
Problem
|
||
Check beim Testen
|
||
Falsches Mapping
|
||
Im Spiel springt er statt zu schießen?
|
||
"Tastatur-Modus"
|
||
Manchmal melden sie sich als Tastatur statt Controller → Gamepad API sieht nichts
|
||
Sleep-Modus
|
||
Geht nach 3 Minuten aus und verliert Verbindung?
|
||
Analog-Stick-Drift
|
||
Billige Analogsticks können nach 2 Monaten spinnen
|
||
Für 2 & 5 Jahre spezifische Probleme
|
||
Für das 2-jährige Kind:
|
||
|
||
Wahrscheinlich zu groß/schwer (die Controller sind meist Standard-Größe)
|
||
Zu viele Tasten (Kind drückt willkürlich)
|
||
Besser: Einfacher Einzel-Button oder Touchscreen für dieses Alter
|
||
Für das 5-jährige Kind:
|
||
|
||
Ideal, wenn es ein einfacher Controller ist (D-Pad + 4 Buttons)
|
||
Test-Protokoll (nach Lieferung)
|
||
Prüfe das sofort beim Empfang:
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
// Im Tablet-Browser öffnen: about:blank
|
||
// Dann in Konsole (F12) oder als Lesezeichen:
|
||
javascript:(function(){window.addEventListener("gamepadconnected",e=>alert("Gamepad: "+e.gamepad.id+"\nButtons: "+e.gamepad.buttons.length));alert("Controller anschalten und warten...")})()
|
||
Oder einfacher Test:
|
||
|
||
Tablet: gamepad-tester.com im Browser öffnen
|
||
Controller verbinden (Bluetooth)
|
||
Tasten drücken → Werden sie erkannt?
|
||
Alternativ-Empfehlung (falls der nicht gut ist)
|
||
Falls der von eBay enttäuscht:
|
||
|
||
Für Kinder (5+):
|
||
|
||
8BitDo Lite 2 (~30€) - kleiner, robuster, perfektes Mapping
|
||
Xbox Controller (wenn ihr einen habt) - funktioniert sofort
|
||
Für das 2-jährige:
|
||
|
||
Gar kein Controller - Touch-Bubbles sind besser für die Feinmotorik
|
||
Oder: Arcade-Stick (große bunte Tasten, z.B. "Arcade1Up" Mini)
|
||
Konfiguration im Code (Fallback)
|
||
Falls der Controller komisches Mapping hat, erweitere die emulator.js:
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
⌄
|
||
// In deinem emulator.html, im setupGamepad():
|
||
const buttonMap = {
|
||
// Falls A/B vertauscht sind:
|
||
0: 'B', // Xbox A = Nintendo B
|
||
1: 'A', // Xbox B = Nintendo A
|
||
2: 'X',
|
||
3: 'Y'
|
||
};
|
||
Fazit: Kauf es, teste es, aber habe einen Plan B (Touch-Steuerung bleibt aktiv im Code). Für 15-20€ für 2 Stück ist das Risiko vertretbar, aber erwarte keine Wunder-Qualität.
|
||
|
||
Soll ich einen QR-Code Generator bauen, den ihr auf das Tablet klebt, um den Controller schnell zu testen (ohne Tippen)?
|
||
|
||
Der 2jährige wird noch keinen Controller bekommen. Aber um die kritik mit den Controllern ist gut.
|
||
|
||
Ich habe hier noch einen:
|
||
|
||
Merkmale & Details
|
||
🎮 BLITZSCHNELLE 1000HZ-POLLING-RATE: Sowohl im kabelgebundenen als auch im kabellosen Modus wird eine blitzschnelle Abfragerate von 1000 Hz erreicht. Entwickelt für Wettkampfspieler. In FPS- und Rennspielen wird jede Eingabe mit höchster Präzision und blitzschneller Reaktion verarbeitet, was Ihnen einen entscheidenden Vorteil verschafft.
|
||
🎮 PRAKTISCHE LADESTATION: Der 1000mAh Akku und der All-in-One-Hub – Magnetische Dock mit Sofortladefunktion sorgen dafür, dass Ihr Wireless Controller immer voll aufgeladen ist. Ein 2,4-GHz-Dongle minimiert Störungen und verbessert die Stabilität, ideal für PC-Spiele. Genießen Sie ununterbrochenes Gaming-Vergnügen.
|
||
🎮 PRÄZISE HALL-JOYSTICKS UND -TRIGGER: Verbessere Ihr Gaming-Erlebnis mit unserem Quad-Hall-System, das doppelte Hall-Effekt-Joysticks und Hall-Trigger bietet. Diese Technologie gewährleistet präzise Verfolgung und Druckempfindlichkeit, die eine reibungslose Steuerung für jede Bewegung ermöglicht.
|
||
🎮 PROFESSIONELLE ANPASSUNG: ① 2 anpassbare Tasten auf der Rückseite unterstützen bis zu 32 Aktionen. ② TURBO+A/B/X/Y/LB/LT/RB/RT ermöglicht Schnellfeuerfunktion. ③ Umschalten der ABXY-Tastenbelegung: Halten Sie FN+A 3 Sekunden lang gedrückt, um die Switch- oder PC/Xbox-Belegung anzupassen. ④ APP-Anpassung für Vollspektrum-RGB-Beleuchtung, Vibrationsmodi und mehr.
|
||
🎮 VIERSTUFIGE VIBRATIONSFUNKTION: Asymmetrische Motoren simulieren vielfältige Spielempfindungen und sorgen für Fahrvibrationen bei Rennspielen und authentisches Rückstoß-Feedback bei Shootern. Tauchen Sie ein in die Spielwelt, in der jede Aktion eine lebensechte Reaktion erhält.
|
||
🎮 DUAL-CORE-EMPFÄNGER 2-SPIELER-KOOP: Ein Zweikanal-Empfänger, der zwei Griffe gleichzeitig sperrt. Das Wohnzimmer verwandelt sich in eine Arena. Freunde / Paare / Eltern und Kinder können jederzeit Seite an Seite zusammenarbeiten. Sich von den Einschränkungen durch zusätzliche USB-Anschlüsse und -Kabel verabschieden und das "Zusammengehen" zur effizientesten Taktik machen. Der USB Receiver / Dongle ist in der Lieferung enthalten.
|
||
🎮 UNTERSTÜTZT MEHRERE GERÄTE: Kompatibel mit PC, Switch, Android und Apple. Der Controller ist für FPS-Spiele geeignet. (Hinweis: Nicht kompatibel mit Xbox oder PlayStation. Für nicht erwähnte Spielplattformen wenden Sie sich bitte vor dem Kauf an den Kundendienst.)
|
||
|
||
profile
|
||
kimi-k2.5:cloud
|
||
Heute um 18:26
|
||
Nachgedacht für 47 Sekunden
|
||
Das ist perfekt! (auch wenn er "Overkill" für ein 5-jähriges Kind ist).
|
||
|
||
Das klingt nach einem GameSir T4 Kaleid oder ähnlichem High-End-Controller. Für dein Projekt besonders wichtig:
|
||
|
||
✅ Warum der ideal ist:
|
||
1. Android-Modus aktivieren (Wichtig!)
|
||
Der Controller hat vermutlich mehrere Modi:
|
||
|
||
Xbox-Modus (Start + X) → Funktioniert meist, aber manchmal komisches Mapping
|
||
Android-Modus (meist Start + A oder Start + B für 3 Sekunden gedrückt halten) → Perfektes Mapping für die Web Gamepad API
|
||
Test: Wenn der Controller als "Wireless Controller" oder "Android Gamepad" erkannt wird (nicht als Xbox 360 Controller), ist es korrekt.
|
||
|
||
2. Hall-Sensoren = Kinder-sicher
|
||
Normale Controller haben Potentiometer (die kaputt gehen wenn Kinder dran ziehen/zwicken). Hall-Sensoren sind magnetisch - die können die Kinder nicht kaputt machen. Sehr gut für das 5-jährige Kind!
|
||
|
||
3. Die 2 Rückseiten-Tasten = Eltern-Shortcut
|
||
Nutze die programmierbaren Tasten auf der Rückseite für Sonderfunktionen:
|
||
|
||
Links hinten: Schnell-Speichern (Save-State)
|
||
Rechts hinten: Zurück zum Menü (beendet Emulator sicher)
|
||
Im Code erweitern:
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
⌄
|
||
// In deinem gamepad-polling:
|
||
if (gp.buttons[6]?.pressed && gp.buttons[7]?.pressed) { // Rückseitentasten
|
||
emulator.saveState();
|
||
this.showNotification('💾 Gespeichert!');
|
||
}
|
||
4. Dual-Core für 2 Spieler?
|
||
Du hast 2 Controller (2 Stück im Paket?). Perfekt für:
|
||
|
||
Co-op (wenn das 5-jährige mit Papa spielt)
|
||
Hot-Swap (einer lädt in der Station, einer wird gespielt)
|
||
⚠️ Was du deaktivieren solltest:
|
||
Vibration AUS
|
||
Für Retro-Spiele und Kinder:
|
||
|
||
Batterie sparen (wichtig!)
|
||
Weniger ablenkend
|
||
In der Hersteller-APP (die du einmalig installierst zum Konfigurieren) oder via Tastenkombination:
|
||
Meistens: Turbo + D-Pad runter drücken um Vibration zu toggeln.
|
||
|
||
1000Hz Polling egal
|
||
Das ist für Competitive FPS-Spieler. Für Retro-Spiele (60 FPS) ist 125Hz völlig ausreichend. Spart Batterie, wenn du es runterstellen kannst (optional).
|
||
|
||
RGB-Beleuchtung aus oder gedimmt
|
||
In der App auf "aus" stellen oder sehr dunkel. Spart Strom und ist abends besser für die Augen.
|
||
|
||
🔧 Konfiguration für dein Projekt
|
||
Layout-Einstellung:
|
||
|
||
Für Retro-Spiele (SNES, NES): Xbox-Layout bevorzugen (nicht Nintendo-Layout)
|
||
Oder: Im Emulator-Code das Mapping anpassen (siehe unten)
|
||
Test-Setup:
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
⌄
|
||
// Erweiterte Gamepad-Detection für deinen spezifischen Controller
|
||
window.addEventListener('gamepadconnected', (e) => {
|
||
console.log('Controller:', e.gamepad.id);
|
||
// Sollte anzeigen: "Wireless Controller" oder ähnlich
|
||
|
||
// Mapping-Check:
|
||
const gp = e.gamepad;
|
||
alert(`Buttons: ${gp.buttons.length}, Axes: ${gp.axes.length}`);
|
||
// Erwartet: 16-18 Buttons, 4 Achsen
|
||
});
|
||
🎮 Code-Anpassung (Eltern-Menü)
|
||
Nutze die Extra-Buttons für das Eltern-Menü statt Start+Select (das könnte das Kind versehentlich drücken):
|
||
|
||
javascript
|
||
|
||
Einklappen
|
||
Speichern
|
||
Kopieren
|
||
1
|
||
2
|
||
3
|
||
4
|
||
5
|
||
6
|
||
7
|
||
8
|
||
9
|
||
10
|
||
11
|
||
12
|
||
13
|
||
14
|
||
15
|
||
16
|
||
17
|
||
18
|
||
19
|
||
20
|
||
21
|
||
22
|
||
23
|
||
24
|
||
25
|
||
26
|
||
27
|
||
28
|
||
29
|
||
30
|
||
31
|
||
32
|
||
⌄
|
||
⌄
|
||
⌄
|
||
⌄
|
||
// In emulator.pollGamepad():
|
||
pollGamepad() {
|
||
const check = () => {
|
||
const gp = navigator.getGamepads()[0];
|
||
if (!gp) return requestAnimationFrame(check);
|
||
|
||
// Standard-Buttons für Spiel
|
||
const isStart = gp.buttons[9]?.pressed; // Xbox-Start
|
||
const isSelect = gp.buttons[8]?.pressed; // Xbox-Back
|
||
|
||
// Rückseiten-Buttons (meist Index 10+11 oder 6+7 je nach Modus)
|
||
const isBackLeft = gp.buttons[6]?.pressed; // Paddel links
|
||
const isBackRight = gp.buttons[7]?.pressed; // Paddel rechts
|
||
|
||
// Kombi: Beide Paddel = Sofort-Speichern + Beenden
|
||
if (isBackLeft && isBackRight) {
|
||
this.saveState();
|
||
setTimeout(() => this.quit(), 500);
|
||
return; // Stop polling
|
||
}
|
||
|
||
// Einzelnes Paddel: Schnell-Speichern
|
||
if (isBackLeft && !this.saveTriggered) {
|
||
this.saveState();
|
||
this.saveTriggered = true;
|
||
setTimeout(() => this.saveTriggered = false, 1000);
|
||
}
|
||
|
||
requestAnimationFrame(check);
|
||
};
|
||
check();
|
||
}
|
||
📱 Ladestation-Tipp
|
||
Die magnetische Ladestation ist perfekt für das Kinder-Tablet-Setup:
|
||
|
||
Kind legt Controller rein → lädt automatisch
|
||
Kein "Wo ist das Kabel?"-Chaos
|
||
1000mAh = ca. 20-30 Stunden Retro-Spielen
|
||
Fazit: Der Controller ist über-speckt für ein Kind, aber die Hall-Sensoren und robusten Sticks machen ihn ideal für langlebigen Einsatz. Die Extra-Tasten auf der Rückseite nutzt du für die Eltern-Steuerung (unsichtbar für das Kind).
|
||
|
||
Brauchst du die Tastenkombination um den Controller in den Android-Modus zu bekommen? (Meistens: Start + A für 3 Sekunden, LED blinkt dann blau oder grün). |