25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
44 KiB
44 KiB
08b — LiveKit Streaming: Vollständige Implementierungsanleitung
Ziel: Echtzeit Audio/Video-Calls und Screen-Sharing in Pyramid via LiveKit SFU,
authentifiziert über MatrixRTC (Matrix-Standard), selbst gehostet auf dem Raspberry Pi.
Inhaltsverzeichnis
- Architektur-Überblick
- Selbst-gehostete Infrastruktur
- LiveKit-Server Setup (Raspberry Pi)
- LiveKit JWT Service (lk-jwt-service)
- Synapse MatrixRTC Konfiguration
- TURN-Server Setup (coturn)
- Nginx Reverse-Proxy
- Flutter: LiveKit-Paket & Abhängigkeiten
- MatrixRTC Membership Flow (Signaling)
- LiveKit-Token holen (OpenID-Austausch)
- LiveKit Room verbinden
- Audio-Call implementieren
- Video-Call implementieren
- Screen-Sharing implementieren
- Klingel-System (Ringing)
- Call-State-Management (Riverpod)
- Call-UI Komponenten
- Hintergrund-Service (Android)
- Komplettes Dart-Implementierungs-Template
- Troubleshooting & Diagnose
Architektur-Überblick
┌─────────────────────────────────────────────────────────────────┐
│ Pyramid App (Flutter) │
│ │
│ MatrixRTC Signaling LiveKit Media │
│ ┌──────────────────┐ ┌─────────────────────┐ │
│ │ Matrix SDK │ │ livekit_client pkg │ │
│ │ - call.member │ │ - Audio/Video tracks │ │
│ │ state events │ │ - Screen share │ │
│ │ - to-device msgs │ │ - Data channels │ │
│ └──────┬───────────┘ └──────────┬──────────┘ │
│ │ │ │
└─────────┼────────────────────────────────┼──────────────────────┘
│ HTTPS/WSS │ WSS (WebSocket Secure)
▼ ▼
┌─────────────────────┐ ┌──────────────────────────────┐
│ Synapse (Matrix HS)│ │ LiveKit Server (SFU) │
│ matrix.steggi- │ │ livekit.steggi-matrix.work │
│ matrix.work │ │ Port: 7880 (HTTPS/WS) │
│ │ │ Port: 7881 (TURN/TLS) │
│ OpenID Token API │ │ Port: 50100-50200 (UDP RTP) │
└────────┬────────────┘ └───────────────┬──────────────┘
│ OpenID Token │
▼ │
┌────────────────────┐ │
│ lk-jwt-service │───────── JWT Token ───────┘
│ (Token Exchange) │
│ Port: 8080 │
└────────────────────┘
│
▼
┌────────────────────┐
│ coturn (TURN) │
│ Port: 3478 UDP │
│ Port: 5349 TLS │
└────────────────────┘
Nginx Reverse Proxy (Port 443):
/livekit/ → LiveKit :7880
/lk-jwt/ → lk-jwt-service :8080
(coturn läuft direkt auf 3478/5349)
Selbst-gehostete Infrastruktur
Benötigte Komponenten
| Dienst | Zweck | Port | Software |
|---|---|---|---|
| LiveKit Server | SFU — verteilt Media-Streams | 7880 (WS/HTTP), 7881 (TURN/TLS), 50100-50200 UDP | livekit/livekit-server |
| lk-jwt-service | Token-Austausch OpenID → LiveKit JWT | 8080 | element-hq/lk-jwt-service |
| coturn | TURN-Server für NAT-Traversal | 3478 UDP, 5349 TLS | coturn |
| Nginx | Reverse Proxy + SSL-Termination | 443 | nginx |
| Synapse | Matrix Homeserver (bereits vorhanden) | 8448 | ✓ läuft |
DNS-Einträge (Beispiel für steggi-matrix.work)
livekit.steggi-matrix.work A <Pi-IP>
turn.steggi-matrix.work A <Pi-IP>
LiveKit-Server Setup (Raspberry Pi)
Installation
# LiveKit Binary herunterladen (ARM64 für Raspberry Pi 4/5)
curl -sSL https://get.livekit.io | bash
# oder manuell:
wget https://github.com/livekit/livekit/releases/latest/download/livekit_linux_arm64.tar.gz
tar xzf livekit_linux_arm64.tar.gz
sudo mv livekit-server /usr/local/bin/
# API-Key und Secret generieren
livekit-server generate-keys
# Ausgabe:
# API Key: APIxxxxxxxxxxxxxxxxxx
# API Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# → SICHER AUFBEWAHREN!
Konfiguration: /etc/livekit/livekit.yaml
port: 7880
bind_addresses:
- "" # Alle Interfaces
# API-Schlüssel (aus generate-keys)
keys:
APIxxxxxxxxxxxxxxxxxx: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# WebRTC-Einstellungen
rtc:
tcp_port: 7881 # TURN over TLS
port_range_start: 50100
port_range_end: 50200
# Öffentliche IP des Raspberry Pi (wichtig für NAT!)
use_external_ip: true
# STUN-Server für ICE (externe Erreichbarkeit prüfen)
stun_servers:
- stun:stun.l.google.com:19302
# TURN eingebaut (LiveKit kann selbst TURN machen)
turn:
enabled: true
domain: livekit.steggi-matrix.work
tls_port: 5349 # TURN über TLS
udp_port: 3478
# Logging
logging:
level: info
pion_level: error # WebRTC-interne Logs reduzieren
# Redis (optional, für Cluster — beim Pi nicht nötig)
# redis:
# address: localhost:6379
Systemd Service: /etc/systemd/system/livekit.service
[Unit]
Description=LiveKit SFU Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=livekit
Group=livekit
ExecStart=/usr/local/bin/livekit-server --config /etc/livekit/livekit.yaml
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# Logs
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
# User anlegen und Service aktivieren
sudo useradd -r -s /bin/false livekit
sudo systemctl daemon-reload
sudo systemctl enable livekit
sudo systemctl start livekit
sudo systemctl status livekit
# Logs verfolgen
sudo journalctl -u livekit -f
LiveKit JWT Service (lk-jwt-service)
Dieser Service tauscht Matrix OpenID-Tokens gegen LiveKit JWTs aus.
Das ist die Brücke zwischen Matrix-Authentifizierung und LiveKit.
Installation
# Go installieren (falls nicht vorhanden)
# Für Raspberry Pi (ARM64):
wget https://go.dev/dl/go1.22.0.linux-arm64.tar.gz
sudo tar -C /usr/local -xzf go1.22.0.linux-arm64.tar.gz
export PATH=$PATH:/usr/local/go/bin
# lk-jwt-service bauen
git clone https://github.com/element-hq/lk-jwt-service
cd lk-jwt-service
go build -o lk-jwt-service .
sudo mv lk-jwt-service /usr/local/bin/
Konfiguration: /etc/lk-jwt-service/config.yaml
# LiveKit-Verbindung
livekit_url: wss://livekit.steggi-matrix.work
livekit_key: APIxxxxxxxxxxxxxxxxxx # selber Key wie in livekit.yaml
livekit_secret: xxxxxxxxxxxxxxxxxxxxx # selbes Secret
# Matrix-Homeserver für OpenID-Validierung
matrix_server: https://matrix.steggi-matrix.work
# Welche Matrix-Homeserver dürfen den Service nutzen
allowed_matrix_servers:
- steggi-matrix.work
# Server-Port
port: 8080
Systemd Service: /etc/systemd/system/lk-jwt-service.service
[Unit]
Description=LiveKit JWT Token Service for MatrixRTC
After=network.target livekit.service
[Service]
Type=simple
User=livekit
ExecStart=/usr/local/bin/lk-jwt-service --config /etc/lk-jwt-service/config.yaml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl enable lk-jwt-service
sudo systemctl start lk-jwt-service
API-Endpoint des lk-jwt-service
POST /sfu/get
Content-Type: application/json
{
"room": "!roomId:server",
"openid_token": {
"access_token": "...", ← von client.requestOpenIdToken()
"token_type": "Bearer",
"matrix_server_name": "steggi-matrix.work"
},
"device_id": "PYRAMID_DEVICE_ID"
}
Antwort:
{
"url": "wss://livekit.steggi-matrix.work",
"jwt": "eyJ..." ← LiveKit JWT Token
}
Synapse MatrixRTC Konfiguration
In der Synapse-Konfiguration (homeserver.yaml):
# MatrixRTC / LiveKit Integration
experimental_features:
msc3401_enabled: true # MatrixRTC Group Calls
msc4140_enabled: true # Delayed events (für Membership-Cleanup)
# Erlaubt das Senden von com.famedly.call.member State-Events
rc_message:
per_second: 100
burst_count: 200
# LiveKit-Service URL im .well-known bekannt machen (MSC4143)
# → client kann automatisch den richtigen JWT-Service finden
.well-known/matrix/client erweitern
Nginx liefert diese JSON-Datei. Ergänze LiveKit-Info:
{
"m.homeserver": {
"base_url": "https://matrix.steggi-matrix.work"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://livekit.steggi-matrix.work/lk-jwt"
}
]
}
TURN-Server Setup (coturn)
LiveKit hat einen eingebauten TURN-Server, aber coturn als Fallback ist empfohlen.
sudo apt install coturn -y
/etc/turnserver.conf
# Listening
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
# Externe IP (Raspberry Pi öffentliche IP)
external-ip=<DEINE_ÖFFENTLICHE_IP>
# Realm
realm=turn.steggi-matrix.work
server-name=turn.steggi-matrix.work
# TLS-Zertifikate (Let's Encrypt)
cert=/etc/letsencrypt/live/turn.steggi-matrix.work/fullchain.pem
pkey=/etc/letsencrypt/live/turn.steggi-matrix.work/privkey.pem
# Auth (shared secret für Matrix)
use-auth-secret
static-auth-secret=ZUFAELLIGES_LANGES_GEHEIMNIS_HIER
# Relay-Ports
min-port=49152
max-port=65535
# Sicherheit
no-multicast-peers
no-loopback-peers
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
# Logging
log-file=/var/log/turnserver.log
# TURN-Service aktivieren
sudo systemctl enable coturn
sudo systemctl start coturn
# Synapse: TURN-Credentials bekannt machen (in homeserver.yaml)
turn_uris:
- "turn:turn.steggi-matrix.work:3478?transport=udp"
- "turn:turn.steggi-matrix.work:3478?transport=tcp"
- "turns:turn.steggi-matrix.work:5349?transport=tcp"
turn_shared_secret: "ZUFAELLIGES_LANGES_GEHEIMNIS_HIER"
turn_user_lifetime: 86400000
turn_allow_guests: false
Nginx Reverse-Proxy
# /etc/nginx/sites-available/livekit.steggi-matrix.work
server {
listen 443 ssl http2;
server_name livekit.steggi-matrix.work;
ssl_certificate /etc/letsencrypt/live/livekit.steggi-matrix.work/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/livekit.steggi-matrix.work/privkey.pem;
# LiveKit WebSocket + HTTP API
location / {
proxy_pass http://localhost:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
client_max_body_size 0;
}
# lk-jwt-service (Token-Austausch)
location /lk-jwt/ {
rewrite ^/lk-jwt/(.*) /$1 break;
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
}
}
# HTTP → HTTPS Redirect
server {
listen 80;
server_name livekit.steggi-matrix.work;
return 301 https://$host$request_uri;
}
sudo nginx -t && sudo nginx -s reload
Firewall-Regeln
# UFW
sudo ufw allow 443/tcp # HTTPS (LiveKit + lk-jwt)
sudo ufw allow 3478/udp # TURN UDP
sudo ufw allow 3478/tcp # TURN TCP
sudo ufw allow 5349/tcp # TURNS TLS
sudo ufw allow 7881/tcp # LiveKit TURN/TLS (eingebaut)
sudo ufw allow 50100:50200/udp # RTP Media Ports
# Für Raspberry Pi hinter Router: Port-Forwarding nötig!
# Im Router: alle oben genannten Ports → Pi-interne IP weiterleiten
Flutter: LiveKit-Paket & Abhängigkeiten
pubspec.yaml
dependencies:
livekit_client: ^2.4.0 # LiveKit Flutter SDK
flutter_webrtc: ^0.10.0 # WebRTC (wird von livekit_client benötigt)
permission_handler: ^11.0.0 # Mikrofon/Kamera-Berechtigung
wakelock_plus: ^1.0.0 # Screen während Call aktiv halten
# Bereits vorhanden:
matrix: ^6.x.x
flutter_riverpod: ^2.x.x
Android: android/app/src/main/AndroidManifest.xml
<!-- Kamera und Mikrofon -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- Foreground-Service für Call im Hintergrund -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<!-- Screen-Sharing -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<!-- Wakelock (Screen während Call) -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Foreground-Service registrieren -->
<service
android:name=".CallForegroundService"
android:foregroundServiceType="microphone|mediaProjection"
android:exported="false"/>
iOS: ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>Pyramid benötigt die Kamera für Video-Calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>Pyramid benötigt das Mikrofon für Audio-Calls</string>
MatrixRTC Membership Flow (Signaling)
MatrixRTC verwendet State-Events im Raum um anzuzeigen, wer im Call ist.
// lib/features/call/matrixrtc_service.dart
const kCallMemberEventType = 'org.matrix.msc3401.call.member';
class MatrixRtcMembership {
final String roomId;
final String deviceId;
final String callId;
final String livekitServiceUrl;
MatrixRtcMembership({
required this.roomId,
required this.deviceId,
required this.callId,
required this.livekitServiceUrl,
});
// ── Call beitreten: Membership-Event senden ────────────────────────────
Future<void> join(Client matrixClient) async {
final expiresTs = DateTime.now()
.add(const Duration(hours: 4))
.millisecondsSinceEpoch;
await matrixClient.sendState(
roomId,
kCallMemberEventType,
{
'application': 'm.call',
'call_id': callId,
'device_id': deviceId,
'expires_ts': expiresTs,
'membershipID': '${deviceId}_${DateTime.now().millisecondsSinceEpoch}',
'backend': {
'type': 'livekit',
'livekit_service_url': livekitServiceUrl,
},
},
stateKey: '$deviceId:$callId',
);
}
// ── Call verlassen: Membership entfernen ───────────────────────────────
Future<void> leave(Client matrixClient) async {
await matrixClient.sendState(
roomId,
kCallMemberEventType,
{}, // Leer = Austritt
stateKey: '$deviceId:$callId',
);
}
// ── Aktive Mitglieder auflisten ───────────────────────────────────────
static List<Map<String, dynamic>> getActiveMembers(Room room, String callId) {
final states = room.states[kCallMemberEventType] ?? {};
final now = DateTime.now().millisecondsSinceEpoch;
return states.values
.where((e) {
final content = e.content;
return content['call_id'] == callId &&
(content['expires_ts'] as int? ?? 0) > now &&
content.isNotEmpty;
})
.map((e) => Map<String, dynamic>.from(e.content))
.toList();
}
// ── Auf Membership-Änderungen hören ──────────────────────────────────
static Stream<void> watchCallMembers(Client client, String roomId) {
return client.onSync.stream
.where((update) {
final rooms = update.rooms?.join;
if (rooms == null) return false;
final roomUpdate = rooms[roomId];
return roomUpdate?.state?.events?.any(
(e) => e.type == kCallMemberEventType) ?? false;
})
.map((_) {});
}
}
LiveKit-Token holen (OpenID-Austausch)
// lib/features/call/livekit_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
class LiveKitService {
static const String _jwtServicePath = '/lk-jwt/sfu/get';
/// Tauscht ein Matrix OpenID-Token gegen einen LiveKit JWT.
/// [livekitServiceUrl] kommt aus dem MatrixRTC-Membership-Event des Raums.
static Future<LiveKitCredentials> getToken({
required Client matrixClient,
required String roomId,
required String livekitServiceUrl,
}) async {
// 1. OpenID-Token vom Homeserver holen
final openIdToken = await matrixClient.requestOpenIdToken(
matrixClient.userID!,
);
// 2. JWT beim lk-jwt-service tauschen
final serviceUrl = Uri.parse(livekitServiceUrl).resolve(_jwtServicePath);
final response = await http.post(
serviceUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'room': roomId,
'openid_token': {
'access_token': openIdToken.accessToken,
'token_type': 'Bearer',
'matrix_server_name': openIdToken.matrixServerName,
},
'device_id': matrixClient.deviceID,
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception(
'lk-jwt-service Fehler ${response.statusCode}: ${response.body}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return LiveKitCredentials(
url: data['url'] as String,
jwt: data['jwt'] as String,
);
}
}
class LiveKitCredentials {
final String url; // wss://livekit.steggi-matrix.work
final String jwt; // LiveKit JWT Token
const LiveKitCredentials({required this.url, required this.jwt});
}
LiveKit Room verbinden
// lib/features/call/livekit_room_manager.dart
import 'package:livekit_client/livekit_client.dart';
class LiveKitRoomManager {
Room? _room;
EventsListener<RoomEvent>? _listener;
Room get room => _room!;
bool get isConnected => _room?.connectionState == ConnectionState.connected;
Future<void> connect({
required String url,
required String token,
bool audioEnabled = true,
bool videoEnabled = false,
}) async {
_room = Room(
roomOptions: RoomOptions(
adaptiveStream: true, // Qualität automatisch anpassen
dynacast: true, // Inaktive Streams pausieren
defaultAudioPublishOptions: const AudioPublishOptions(
name: 'microphone',
audioBitrate: 24000, // 24 kbps — gut für Sprache
),
defaultVideoPublishOptions: VideoPublishOptions(
simulcast: true, // Mehrere Qualitätsstufen senden
videoEncoding: VideoEncoding(
maxBitrate: 1200 * 1000, // 1.2 Mbps max
maxFramerate: 30,
),
),
defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions(
useiOSBroadcastExtension: true,
params: VideoParameters(
dimensions: VideoDimensions(1920, 1080),
encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 15),
),
),
defaultCameraCaptureOptions: CameraCaptureOptions(
cameraPosition: CameraPosition.front,
params: VideoParameters(
dimensions: VideoDimensions(1280, 720),
encoding: VideoEncoding(maxBitrate: 1200 * 1000, maxFramerate: 30),
),
),
),
);
_listener = _room!.createListener();
try {
await _room!.connect(url, token,
connectOptions: const ConnectOptions(
autoSubscribe: true, // Alle Tracks automatisch empfangen
rtcConfig: RTCConfiguration(
iceTransportPolicy: RTCIceTransportPolicy.all,
),
),
);
// Mikrofon aktivieren
if (audioEnabled) {
await _room!.localParticipant?.setMicrophoneEnabled(true);
}
// Kamera aktivieren
if (videoEnabled) {
await _room!.localParticipant?.setCameraEnabled(true);
}
} catch (e) {
await _room?.dispose();
_room = null;
rethrow;
}
}
Future<void> disconnect() async {
await _room?.localParticipant?.setMicrophoneEnabled(false);
await _room?.localParticipant?.setCameraEnabled(false);
await _room?.disconnect();
await _room?.dispose();
_room = null;
}
// Event-Listener
void onParticipantConnected(void Function(RemoteParticipant) callback) {
_listener?.on<ParticipantConnectedEvent>(
(event) => callback(event.participant));
}
void onParticipantDisconnected(void Function(RemoteParticipant) callback) {
_listener?.on<ParticipantDisconnectedEvent>(
(event) => callback(event.participant));
}
void onTrackPublished(
void Function(RemoteParticipant, RemoteTrackPublication) callback) {
_listener?.on<TrackPublishedEvent>((event) =>
callback(event.participant, event.publication));
}
void onTrackSubscribed(
void Function(Track, TrackPublication, RemoteParticipant) callback) {
_listener?.on<TrackSubscribedEvent>(
(event) => callback(event.track, event.publication, event.participant));
}
}
Audio-Call implementieren
// lib/features/call/call_notifier.dart (Audio-Call)
import 'package:permission_handler/permission_handler.dart';
Future<void> startAudioCall({
required Client matrixClient,
required String roomId,
}) async {
// 1. Berechtigung anfordern
final status = await Permission.microphone.request();
if (!status.isGranted) throw Exception('Mikrofon-Berechtigung verweigert');
// 2. LiveKit-Service-URL aus Raum-Metadaten holen (oder Default)
const livekitServiceUrl = 'https://livekit.steggi-matrix.work';
// 3. MatrixRTC Membership senden
final callId = roomId; // Convention: callId = roomId
final membership = MatrixRtcMembership(
roomId: roomId,
deviceId: matrixClient.deviceID!,
callId: callId,
livekitServiceUrl: livekitServiceUrl,
);
await membership.join(matrixClient);
// 4. LiveKit-Token holen
final credentials = await LiveKitService.getToken(
matrixClient: matrixClient,
roomId: roomId,
livekitServiceUrl: livekitServiceUrl,
);
// 5. Mit LiveKit verbinden (nur Audio)
final manager = LiveKitRoomManager();
await manager.connect(
url: credentials.url,
token: credentials.jwt,
audioEnabled: true,
videoEnabled: false,
);
// 6. Screen aktiv halten
WakelockPlus.enable();
}
// Stummschalten
Future<void> setMuted(LiveKitRoomManager manager, bool muted) async {
await manager.room.localParticipant?.setMicrophoneEnabled(!muted);
}
// Lautsprecher umschalten (Android)
Future<void> setSpeakerOn(bool speakerOn) async {
await Helper.setSpeakerphoneOn(speakerOn);
}
Video-Call implementieren
// Widget: Lokale Kamera-Vorschau
class LocalVideoView extends StatelessWidget {
final LocalParticipant participant;
const LocalVideoView({required this.participant, super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: participant.trackPublicationsNotifier,
builder: (ctx, _) {
final videoPub = participant.videoTrackPublications
.where((p) => p.source == TrackSource.camera)
.firstOrNull;
if (videoPub?.track case final VideoTrack track) {
return VideoTrackRenderer(track);
}
return const SizedBox();
},
);
}
}
// Widget: Remote-Teilnehmer Video
class RemoteVideoView extends StatelessWidget {
final RemoteParticipant participant;
const RemoteVideoView({required this.participant, super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: participant.trackPublicationsNotifier,
builder: (ctx, _) {
final videoPub = participant.videoTrackPublications
.where((p) =>
p.source == TrackSource.camera ||
p.source == TrackSource.screenShareVideo)
.firstOrNull;
if (videoPub?.track case final VideoTrack track) {
return VideoTrackRenderer(track, fit: RTCVideoViewObjectFit.contain);
}
return Container(
color: Colors.black87,
child: const Center(
child: Icon(Icons.person, size: 80, color: Colors.white38),
),
);
},
);
}
}
// Kamera ein-/ausschalten
Future<void> toggleCamera(Room room) async {
final enabled = room.localParticipant?.isCameraEnabled() ?? false;
await room.localParticipant?.setCameraEnabled(!enabled);
}
// Kamera wechseln (Vorder-/Rückkamera)
Future<void> switchCamera(Room room) async {
final cameraPub = room.localParticipant?.videoTrackPublications
.where((p) => p.source == TrackSource.camera)
.firstOrNull;
if (cameraPub?.track case final LocalVideoTrack track) {
await track.switchCamera();
}
}
Screen-Sharing implementieren
// Android: benötigt MediaProjection-Foreground-Service
// Screen-Sharing starten
Future<void> startScreenShare(Room room) async {
// Android: MediaProjection-Berechtigung anfordern (System-Dialog)
await room.localParticipant?.setScreenShareEnabled(
true,
captureScreenAudio: true, // Audio mitschneiden
);
}
// Screen-Sharing beenden
Future<void> stopScreenShare(Room room) async {
await room.localParticipant?.setScreenShareEnabled(false);
}
// Android Foreground Service für Screen-Sharing
// android/app/src/main/kotlin/chat/pyramid/pyramid/ScreenShareService.kt:
/*
class ScreenShareService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, "call_channel")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Bildschirm wird geteilt")
.setContentText("Pyramid teilt deinen Bildschirm")
.build()
startForeground(NOTIF_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
return START_STICKY
}
companion object { const val NOTIF_ID = 2001 }
}
*/
Klingel-System (Ringing)
// lib/features/call/ringing_service.dart
// ── Anruf ankündigen (to-device Event) ─────────────────────────────────
Future<void> ringUser({
required Client matrixClient,
required String targetUserId,
required String roomId,
required String callId,
}) async {
await matrixClient.sendToDevice(
'com.famedly.call', // Event-Typ (wie element/fluffychat)
{
targetUserId: {
'*': { // Alle Geräte des Users
'call_id': callId,
'room_id': roomId,
'type': 'invite',
'sender_session_id': matrixClient.deviceID,
'sender_name': matrixClient.displayName,
'version': 1,
},
},
},
);
}
// ── Klingeln abbrechen ────────────────────────────────────────────────
Future<void> cancelRing({
required Client matrixClient,
required String targetUserId,
required String callId,
}) async {
await matrixClient.sendToDevice(
'com.famedly.call',
{
targetUserId: {
'*': {'call_id': callId, 'type': 'hangup', 'reason': 'user_hangup'},
},
},
);
}
// ── Eingehende to-device Events abhören ──────────────────────────────
StreamSubscription<ToDeviceEvent> listenForIncomingCalls(
Client matrixClient, void Function(IncomingCall) onCall) {
return matrixClient.onToDeviceEvent.stream
.where((e) => e.type == 'com.famedly.call')
.listen((event) {
final type = event.content['type'] as String?;
final callId = event.content['call_id'] as String?;
final roomId = event.content['room_id'] as String?;
if (type == 'invite' && callId != null && roomId != null) {
onCall(IncomingCall(
callId: callId,
roomId: roomId,
callerId: event.senderId,
senderName: event.content['sender_name'] as String?,
));
}
if (type == 'hangup') {
// Klingeln beenden (Anruf abgebrochen)
}
});
}
class IncomingCall {
final String callId;
final String roomId;
final String callerId;
final String? senderName;
const IncomingCall({
required this.callId,
required this.roomId,
required this.callerId,
this.senderName,
});
}
Call-State-Management (Riverpod)
// lib/features/call/call_provider.dart
enum CallState { idle, calling, ringing, connecting, connected, ended, failed }
enum CallType { audio, video }
@immutable
class CallStateData {
final CallState state;
final CallType type;
final String? roomId;
final String? callId;
final bool isMuted;
final bool isCameraOff;
final bool isSpeakerOn;
final bool isScreenSharing;
final List<RemoteParticipant> participants;
const CallStateData({
this.state = CallState.idle,
this.type = CallType.audio,
this.roomId,
this.callId,
this.isMuted = false,
this.isCameraOff = false,
this.isSpeakerOn = false,
this.isScreenSharing = false,
this.participants = const [],
});
CallStateData copyWith({
CallState? state,
CallType? type,
String? roomId,
String? callId,
bool? isMuted,
bool? isCameraOff,
bool? isSpeakerOn,
bool? isScreenSharing,
List<RemoteParticipant>? participants,
}) => CallStateData(
state: state ?? this.state,
type: type ?? this.type,
roomId: roomId ?? this.roomId,
callId: callId ?? this.callId,
isMuted: isMuted ?? this.isMuted,
isCameraOff: isCameraOff ?? this.isCameraOff,
isSpeakerOn: isSpeakerOn ?? this.isSpeakerOn,
isScreenSharing: isScreenSharing ?? this.isScreenSharing,
participants: participants ?? this.participants,
);
}
class CallNotifier extends StateNotifier<CallStateData> {
final Ref _ref;
LiveKitRoomManager? _manager;
MatrixRtcMembership? _membership;
CallNotifier(this._ref) : super(const CallStateData());
Future<void> startCall(String roomId, CallType type) async {
state = state.copyWith(state: CallState.calling, type: type, roomId: roomId);
try {
final client = await _ref.read(matrixClientProvider.future);
final callId = roomId;
const livekitServiceUrl = 'https://livekit.steggi-matrix.work';
_membership = MatrixRtcMembership(
roomId: roomId, deviceId: client.deviceID!,
callId: callId, livekitServiceUrl: livekitServiceUrl,
);
await _membership!.join(client);
final credentials = await LiveKitService.getToken(
matrixClient: client, roomId: roomId, livekitServiceUrl: livekitServiceUrl,
);
_manager = LiveKitRoomManager();
state = state.copyWith(state: CallState.connecting, callId: callId);
await _manager!.connect(
url: credentials.url,
token: credentials.jwt,
audioEnabled: true,
videoEnabled: type == CallType.video,
);
_manager!.onParticipantConnected((p) {
state = state.copyWith(participants: [...state.participants, p]);
});
_manager!.onParticipantDisconnected((p) {
state = state.copyWith(
participants: state.participants.where((x) => x.sid != p.sid).toList(),
);
});
WakelockPlus.enable();
state = state.copyWith(state: CallState.connected);
} catch (e) {
await _cleanup();
state = state.copyWith(state: CallState.failed);
}
}
Future<void> hangUp() async {
final client = await _ref.read(matrixClientProvider.future);
await _membership?.leave(client);
await _cleanup();
state = const CallStateData(state: CallState.ended);
await Future.delayed(const Duration(seconds: 1));
state = const CallStateData();
}
Future<void> toggleMute() async {
final muted = !state.isMuted;
await _manager?.room.localParticipant?.setMicrophoneEnabled(!muted);
state = state.copyWith(isMuted: muted);
}
Future<void> toggleCamera() async {
final off = !state.isCameraOff;
await _manager?.room.localParticipant?.setCameraEnabled(!off);
state = state.copyWith(isCameraOff: off);
}
Future<void> toggleSpeaker() async {
final on = !state.isSpeakerOn;
await Helper.setSpeakerphoneOn(on);
state = state.copyWith(isSpeakerOn: on);
}
Future<void> toggleScreenShare() async {
final sharing = !state.isScreenSharing;
if (_manager != null) {
await _manager!.room.localParticipant?.setScreenShareEnabled(sharing);
}
state = state.copyWith(isScreenSharing: sharing);
}
Future<void> _cleanup() async {
await _manager?.disconnect();
_manager = null;
WakelockPlus.disable();
}
}
final callProvider = StateNotifierProvider<CallNotifier, CallStateData>(
(ref) => CallNotifier(ref),
);
Call-UI Komponenten
// lib/features/call/call_page.dart
class CallPage extends ConsumerWidget {
const CallPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final call = ref.watch(callProvider);
final notifier = ref.read(callProvider.notifier);
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
// ── Haupt-Video (Remote Participant) ──────────────────────
if (call.participants.isNotEmpty)
Positioned.fill(
child: RemoteVideoView(participant: call.participants.first),
)
else
const Center(
child: Icon(Icons.person, size: 120, color: Colors.white24),
),
// ── Lokale Kamera (klein, Overlay) ────────────────────────
if (call.type == CallType.video)
Positioned(
right: 16, top: 16,
width: 120, height: 180,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _LocalPreview(),
),
),
// ── Status-Text ───────────────────────────────────────────
Positioned(
top: 24, left: 0, right: 0,
child: Text(
_statusText(call.state),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 18),
),
),
// ── Call-Controls ─────────────────────────────────────────
Positioned(
bottom: 40, left: 0, right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_CallButton(
icon: call.isMuted ? Icons.mic_off : Icons.mic,
label: call.isMuted ? 'Ton an' : 'Stumm',
color: call.isMuted ? Colors.red : Colors.white24,
onTap: notifier.toggleMute,
),
_CallButton(
icon: Icons.call_end,
label: 'Auflegen',
color: Colors.red,
size: 72,
onTap: notifier.hangUp,
),
if (call.type == CallType.video)
_CallButton(
icon: call.isCameraOff
? Icons.videocam_off
: Icons.videocam,
label: 'Kamera',
color: call.isCameraOff ? Colors.red : Colors.white24,
onTap: notifier.toggleCamera,
)
else
_CallButton(
icon: call.isSpeakerOn
? Icons.volume_up
: Icons.volume_down,
label: 'Lautsprecher',
color: Colors.white24,
onTap: notifier.toggleSpeaker,
),
],
),
),
],
),
),
);
}
String _statusText(CallState state) => switch (state) {
CallState.calling => 'Verbinde…',
CallState.connecting => 'Verbinde…',
CallState.connected => 'Verbunden',
CallState.ended => 'Aufgelegt',
CallState.failed => 'Fehler',
_ => '',
};
}
class _CallButton extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final double size;
final VoidCallback onTap;
const _CallButton({
required this.icon, required this.label,
required this.color, required this.onTap,
this.size = 56,
});
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: onTap,
child: CircleAvatar(
radius: size / 2,
backgroundColor: color,
child: Icon(icon, color: Colors.white, size: size * 0.5),
),
),
const SizedBox(height: 6),
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
);
}
Hintergrund-Service (Android)
// android/app/src/main/kotlin/chat/pyramid/pyramid/CallForegroundService.kt
package chat.pyramid.pyramid
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
class CallForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startCallNotification()
ACTION_STOP -> stopSelf()
}
return START_STICKY
}
private fun startCallNotification() {
ensureChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Pyramid — Aktiver Anruf")
.setContentText("Tippe um zum Anruf zurückzukehren")
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notification,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE)
} else {
startForeground(NOTIF_ID, notification)
}
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "Anrufe", NotificationManager.IMPORTANCE_HIGH)
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
const val ACTION_START = "START_CALL"
const val ACTION_STOP = "STOP_CALL"
const val CHANNEL_ID = "pyramid_calls"
const val NOTIF_ID = 1001
}
}
// Dart: Service starten/stoppen
import 'package:flutter/services.dart';
const _callChannel = MethodChannel('chat.pyramid.pyramid/call');
Future<void> startCallService() async {
if (!Platform.isAndroid) return;
await _callChannel.invokeMethod('startCallService');
}
Future<void> stopCallService() async {
if (!Platform.isAndroid) return;
await _callChannel.invokeMethod('stopCallService');
}
Troubleshooting & Diagnose
Server-Tests
# LiveKit-Server erreichbar?
curl https://livekit.steggi-matrix.work/
# lk-jwt-service erreichbar?
curl -X POST https://livekit.steggi-matrix.work/lk-jwt/sfu/get \
-H 'Content-Type: application/json' \
-d '{"room":"!test:server","openid_token":{"access_token":"test","token_type":"Bearer","matrix_server_name":"steggi-matrix.work"},"device_id":"TEST"}'
# Erwartete Antwort: {"url":"wss://...","jwt":"eyJ..."}
# TURN-Server Test (stuncheck)
pip install stun-client
python -c "import stun; print(stun.get_ip_info())"
# LiveKit Logs
sudo journalctl -u livekit -f
# lk-jwt-service Logs
sudo journalctl -u lk-jwt-service -f
# UDP-Port-Reichbarkeit (von außen)
nmap -sU -p 50100-50110 <PI_IP>
Häufige Probleme
| Problem | Ursache | Lösung |
|---|---|---|
ICE failed |
UDP-Ports gesperrt | TURN aktivieren, Firewall prüfen |
| Token-Fehler 401 | OpenID-Validierung schlägt fehl | Matrix-Server-URL in lk-jwt-service prüfen |
WebSocket closed |
Nginx-Proxy-Timeout | proxy_read_timeout 3600s in Nginx |
| Kein Audio | Mikrofon-Berechtigung | Permission.microphone.request() |
Room not found |
LiveKit-Key falsch | API-Key in livekit.yaml = lk-jwt-service Config |
| Kein Video | Kamera gesperrt | Permission.camera.request() |
| Call fällt nach 4h ab | expires_ts zu niedrig |
Bei 30min vor Ablauf Membership erneuern |
Dart-Diagnose-Tool
Future<Map<String, bool>> diagnoseLiveKit(Client client, String roomId) async {
final results = <String, bool>{};
// 1. OpenID-Token
try {
await client.requestOpenIdToken(client.userID!);
results['openid_token'] = true;
} catch (_) { results['openid_token'] = false; }
// 2. JWT-Service erreichbar
try {
final r = await http.get(
Uri.parse('https://livekit.steggi-matrix.work/'));
results['livekit_reachable'] = r.statusCode < 500;
} catch (_) { results['livekit_reachable'] = false; }
// 3. Token-Austausch
try {
await LiveKitService.getToken(
matrixClient: client,
roomId: roomId,
livekitServiceUrl: 'https://livekit.steggi-matrix.work',
);
results['token_exchange'] = true;
} catch (_) { results['token_exchange'] = false; }
return results;
}