fix(calls): Bildschirmübertragung ohne Absturz, Lautsprecher, Mikrofon im Hintergrund, zurück in den Anruf
Bernds Anruftest Handy↔PC (2026-10-07): - Teilen am Handy: App "verkleinerte sich", Anruf danach weg. Ursache: Android 14+ verlangt vor MediaProjection einen Vordergrunddienst vom Typ mediaProjection; flutter_webrtc startet keinen, der Manifest-Eintrag verwies auf eine nicht mehr existierende Klasse. Neu: CallService (microphone|mediaProjection) + CallForeground: Zustimmung → Dienst (wartet auf echten startForeground) → Mitschnitt. Emulator-Test (Android 16): mit Fix 1080x2400@30 läuft; Gegenprobe ohne Dienst wirft genau 'Media projections require a foreground service ...'. - Dienst läuft während jedes Anrufs: Mikrofon bleibt im Hintergrund aktiv, Benachrichtigung "Anruf läuft" führt zurück. Mikrofon-Typ nur mit Berechtigung, sonst schmalere Variante statt Dienst-Abbruch. - Handy hörte nichts: SDK schaltet Sprachanrufe auf die Hörmuschel. Jetzt Lautsprecher als Standard (wie Discord) + Umschalter in der Anrufleiste. - LiveKit-Teilen am Handy öffnete den PC-Quellen-Auswähler → System-Dialog. - Im offenen Chat (Handy) Leiste "Anruf läuft · Zurück". - Einstellungen → Sprache & Video: Mikrofontest mit Pegel (PC-Diagnose: das Standardgerät lieferte nur Stille – Voicemeeter/virtuelle Geräte). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c817de924a
commit
441cc6ef54
@@ -9,6 +9,7 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
@@ -26,11 +27,15 @@
|
|||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:fullBackupContent="false"
|
android:fullBackupContent="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||||
|
<!-- Laufender Anruf: hält das Mikrofon im Hintergrund am Leben und
|
||||||
|
ist Pflicht für Bildschirmübertragung ab Android 14 (CallService.kt).
|
||||||
|
Der frühere Eintrag com.cloudwebrtc...FlutterForegroundService
|
||||||
|
verwies auf eine Klasse, die flutter_webrtc 1.x nicht mehr hat. -->
|
||||||
<service
|
<service
|
||||||
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
android:name=".CallService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection" />
|
android:foregroundServiceType="microphone|mediaProjection" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vordergrunddienst für laufende Anrufe (1:1 und Sprachkanäle).
|
||||||
|
*
|
||||||
|
* Warum: Android schaltet das Mikrofon einer App im Hintergrund stumm, und
|
||||||
|
* seit Android 14 stürzt jede Bildschirmübertragung ab, wenn vorher kein
|
||||||
|
* Dienst vom Typ „mediaProjection“ läuft (flutter_webrtc startet keinen).
|
||||||
|
* Genau das war Bernds Fehlerbild 2026-10-07: Teilen gestartet → App weg →
|
||||||
|
* beim Öffnen kein Anruf mehr.
|
||||||
|
*
|
||||||
|
* Typen: immer „microphone“ (ab Android 11), bei laufender Übertragung
|
||||||
|
* zusätzlich „mediaProjection“. Der Dienst darf mit mediaProjection erst
|
||||||
|
* NACH der Zustimmung des Nutzers gestartet werden – das stellt die
|
||||||
|
* Dart-Seite sicher (lib/core/call_foreground.dart).
|
||||||
|
*/
|
||||||
|
class CallService : Service() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PYRAMID-CALL"
|
||||||
|
private const val CHANNEL_ID = "pyramid_call"
|
||||||
|
private const val NOTIF_ID = 7301
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_SCREEN = "screen"
|
||||||
|
|
||||||
|
// Wer auf den tatsächlichen startForeground wartet (Hauptthread).
|
||||||
|
// Die Bildschirmübertragung darf erst danach beginnen.
|
||||||
|
private val waiters = mutableListOf<(Boolean) -> Unit>()
|
||||||
|
|
||||||
|
fun whenStarted(callback: (Boolean) -> Unit) {
|
||||||
|
waiters.add(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyWaiters(ok: Boolean) {
|
||||||
|
val list = waiters.toList()
|
||||||
|
waiters.clear()
|
||||||
|
list.forEach { it(ok) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(ctx: Context, title: String, screen: Boolean) {
|
||||||
|
val intent = Intent(ctx, CallService::class.java)
|
||||||
|
.putExtra(EXTRA_TITLE, title)
|
||||||
|
.putExtra(EXTRA_SCREEN, screen)
|
||||||
|
ContextCompat.startForegroundService(ctx, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(ctx: Context) {
|
||||||
|
ctx.stopService(Intent(ctx, CallService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Pyramid"
|
||||||
|
val screen = intent?.getBooleanExtra(EXTRA_SCREEN, false) ?: false
|
||||||
|
ensureChannel()
|
||||||
|
|
||||||
|
val launch = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||||
|
}
|
||||||
|
val open = launch?.let {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this, 0, it,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(if (screen) "Bildschirm wird übertragen" else "Anruf läuft")
|
||||||
|
.setContentText("$title – tippen, um zurückzukehren")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Typ „microphone“ nur mit Mikrofon-Berechtigung (sonst verweigert
|
||||||
|
// Android den GANZEN Dienst – auch den für die Übertragung).
|
||||||
|
val micAllowed = ContextCompat.checkSelfPermission(
|
||||||
|
this, Manifest.permission.RECORD_AUDIO
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val candidates = mutableListOf<Int>()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val mic = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||||
|
val proj = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||||
|
if (screen) {
|
||||||
|
if (micAllowed) candidates.add(mic or proj)
|
||||||
|
candidates.add(proj)
|
||||||
|
} else if (micAllowed) {
|
||||||
|
candidates.add(mic)
|
||||||
|
}
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
candidates.add(if (screen) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else 0)
|
||||||
|
} else {
|
||||||
|
candidates.add(-1) // ohne Typ
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = false
|
||||||
|
for (type in candidates) {
|
||||||
|
try {
|
||||||
|
if (type == -1) startForeground(NOTIF_ID, notification)
|
||||||
|
else startForeground(NOTIF_ID, notification, type)
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Nächste, schmalere Variante versuchen – lieber ohne Dienst
|
||||||
|
// weiter als die App abstürzen zu lassen.
|
||||||
|
Log.e(TAG, "startForeground fehlgeschlagen (screen=$screen, type=$type): ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) stopSelf()
|
||||||
|
notifyWaiters(ok)
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Laufende Anrufe", NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = "Zeigt an, dass ein Anruf oder eine Bildschirmübertragung läuft."
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
||||||
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
||||||
|
// Anruf-Vordergrunddienst (lib/core/call_foreground.dart ↔ CallService.kt).
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/call")
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"start" -> {
|
||||||
|
// Erst antworten, wenn der Dienst wirklich im Vordergrund
|
||||||
|
// läuft (oder nach 3 s aufgeben) – eine Übertragung vor
|
||||||
|
// dem startForeground würde die App abstürzen lassen.
|
||||||
|
val done = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
val reply = { ok: Boolean ->
|
||||||
|
if (done.compareAndSet(false, true)) result.success(ok)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CallService.whenStarted(reply)
|
||||||
|
CallService.start(
|
||||||
|
this,
|
||||||
|
call.argument<String>("title") ?: "Pyramid",
|
||||||
|
call.argument<Boolean>("screen") ?: false,
|
||||||
|
)
|
||||||
|
android.os.Handler(mainLooper).postDelayed({ reply(false) }, 3000)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// z. B. Start aus dem Hintergrund verboten (Android 12+).
|
||||||
|
Log.e("PYRAMID-CALL", "CallService-Start abgelehnt: ${e.message}")
|
||||||
|
reply(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stop" -> {
|
||||||
|
CallService.stop(this)
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:pyramid/core/call_foreground.dart';
|
||||||
|
|
||||||
|
/// Android 14+: Bildschirmübertragung darf die App nicht abstürzen lassen
|
||||||
|
/// (Bernds Test 2026-10-07: „Teilen“ → App weg → Anruf verloren).
|
||||||
|
///
|
||||||
|
/// Ablauf wie im Anruf: Zustimmung → Anruf-Dienst mit mediaProjection →
|
||||||
|
/// Mitschnitt. Der System-Dialog muss bestätigt werden (auf dem Emulator per
|
||||||
|
/// adb, siehe docs/TESTS_UND_BENCHMARKS.md).
|
||||||
|
///
|
||||||
|
/// flutter test integration_test/android_screen_capture_test.dart -d emulator-5554
|
||||||
|
///
|
||||||
|
/// PYRAMID_SCREEN_NEGATIVE=1 (per --dart-define) überspringt den Dienst –
|
||||||
|
/// Gegenprobe: dann stürzt die App wie früher ab.
|
||||||
|
const _negative = bool.fromEnvironment('PYRAMID_SCREEN_NEGATIVE');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Bildschirmübertragung startet ohne Absturz',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||||
|
if (_negative) {
|
||||||
|
expect(await Helper.requestCapturePermission(), isTrue);
|
||||||
|
} else {
|
||||||
|
expect(
|
||||||
|
await CallForeground.prepareScreenCapture('test'),
|
||||||
|
isTrue,
|
||||||
|
reason: 'Zustimmung oder Dienststart fehlgeschlagen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final stream = await navigator.mediaDevices
|
||||||
|
.getDisplayMedia({'video': true, 'audio': false});
|
||||||
|
expect(stream.getVideoTracks(), isNotEmpty);
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
for (final t in stream.getTracks()) {
|
||||||
|
await t.stop();
|
||||||
|
}
|
||||||
|
await stream.dispose();
|
||||||
|
await CallForeground.screenCaptureStopped('test');
|
||||||
|
await CallForeground.callEnded('test');
|
||||||
|
},
|
||||||
|
skip: !Platform.isAndroid,
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
|
||||||
|
/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
|
||||||
|
///
|
||||||
|
/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
|
||||||
|
/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
|
||||||
|
/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
|
||||||
|
/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
|
||||||
|
/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
|
||||||
|
/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
|
||||||
|
/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
|
||||||
|
///
|
||||||
|
/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
|
||||||
|
/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
|
||||||
|
/// Auf anderen Plattformen passiert nichts.
|
||||||
|
class CallForeground {
|
||||||
|
CallForeground._();
|
||||||
|
|
||||||
|
static const _channel = MethodChannel('chat.pyramid.pyramid/call');
|
||||||
|
static final _owners = <String>{};
|
||||||
|
static final _screenOwners = <String>{};
|
||||||
|
static String _title = 'Pyramid';
|
||||||
|
|
||||||
|
static bool get _android => !kIsWeb && Platform.isAndroid;
|
||||||
|
|
||||||
|
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
|
||||||
|
static Future<bool> _apply() async {
|
||||||
|
if (!_android) return true;
|
||||||
|
try {
|
||||||
|
if (_owners.isEmpty) {
|
||||||
|
await _channel.invokeMethod('stop');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final ok = await _channel.invokeMethod<bool>('start', {
|
||||||
|
'title': _title,
|
||||||
|
'screen': _screenOwners.isNotEmpty,
|
||||||
|
});
|
||||||
|
return ok ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[CallForeground] $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
|
||||||
|
static Future<void> callStarted(String owner, {required String title}) async {
|
||||||
|
_owners.add(owner);
|
||||||
|
_title = title;
|
||||||
|
await _apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anruf von [owner] ist beendet.
|
||||||
|
static Future<void> callEnded(String owner) async {
|
||||||
|
final changed = _owners.remove(owner) | _screenOwners.remove(owner);
|
||||||
|
if (changed) await _apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
|
||||||
|
/// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
|
||||||
|
static Future<bool> prepareScreenCapture(String owner) async {
|
||||||
|
if (!_android) return true;
|
||||||
|
final granted = await rtc.Helper.requestCapturePermission();
|
||||||
|
if (!granted) return false;
|
||||||
|
_owners.add(owner);
|
||||||
|
_screenOwners.add(owner);
|
||||||
|
if (!await _apply()) {
|
||||||
|
// Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
|
||||||
|
// abstürzen lassen – dann lieber gar nicht übertragen.
|
||||||
|
_screenOwners.remove(owner);
|
||||||
|
await _apply();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
|
||||||
|
static Future<void> screenCaptureStopped(String owner) async {
|
||||||
|
if (_screenOwners.remove(owner)) await _apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> changeQuality(String key);
|
Future<void> changeQuality(String key);
|
||||||
|
|
||||||
|
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||||
|
/// Auf dem PC ohne Bedeutung.
|
||||||
|
bool get isSpeakerOn => true;
|
||||||
|
Future<void> toggleSpeaker() async {}
|
||||||
|
|
||||||
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||||
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||||
Future<void> applyOutputVolume([double? value]);
|
Future<void> applyOutputVolume([double? value]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
|
import 'package:pyramid/core/call_foreground.dart';
|
||||||
import 'package:pyramid/core/ice_servers.dart';
|
import 'package:pyramid/core/ice_servers.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
@@ -35,6 +36,34 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
bool _isDeafened = false;
|
bool _isDeafened = false;
|
||||||
bool _isUpdating = false;
|
bool _isUpdating = false;
|
||||||
String? _selectedSourceId;
|
String? _selectedSourceId;
|
||||||
|
|
||||||
|
// Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
|
||||||
|
// Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
|
||||||
|
// dann praktisch nichts (Bernds Test 2026-10-07).
|
||||||
|
bool _speakerOn = true;
|
||||||
|
bool get _isMobile =>
|
||||||
|
!kIsWeb &&
|
||||||
|
(defaultTargetPlatform == TargetPlatform.android ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.iOS);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSpeakerOn => _speakerOn;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> toggleSpeaker() async {
|
||||||
|
_speakerOn = !_speakerOn;
|
||||||
|
notifyListeners();
|
||||||
|
await _applySpeaker();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySpeaker() async {
|
||||||
|
if (!_isMobile) return;
|
||||||
|
try {
|
||||||
|
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VOIP] Lautsprecher: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
String _currentQualityKey = 'hd';
|
String _currentQualityKey = 'hd';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -104,6 +133,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
_isCameraMuted = call.type == CallType.kVoice;
|
_isCameraMuted = call.type == CallType.kVoice;
|
||||||
|
_speakerOn = true;
|
||||||
_setupCallListeners(call);
|
_setupCallListeners(call);
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -120,9 +150,19 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
||||||
call.setLocalVideoMuted(false);
|
call.setLocalVideoMuted(false);
|
||||||
}
|
}
|
||||||
|
_applySpeaker();
|
||||||
|
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||||
|
CallForeground.callStarted('voip',
|
||||||
|
title: call.room.getLocalizedDisplayname());
|
||||||
}
|
}
|
||||||
|
if (state == CallState.kEnded) CallForeground.callEnded('voip');
|
||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
});
|
});
|
||||||
|
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
||||||
|
// danach unsere Wahl erneut anwenden.
|
||||||
|
call.onCallEventChanged.stream.listen((event) {
|
||||||
|
if (event == CallStateChange.kFeedsChanged) _applySpeaker();
|
||||||
|
});
|
||||||
|
|
||||||
_renderRefreshTimer?.cancel();
|
_renderRefreshTimer?.cancel();
|
||||||
_renderRefreshTimer = Timer.periodic(
|
_renderRefreshTimer = Timer.periodic(
|
||||||
@@ -259,6 +299,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_isRemoteScreensharing = false;
|
_isRemoteScreensharing = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
||||||
@@ -292,6 +333,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
currentCall = null;
|
currentCall = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -361,6 +403,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
|
|
||||||
if (_isScreensharing) {
|
if (_isScreensharing) {
|
||||||
await safeAction(() => call.setScreensharingEnabled(false));
|
await safeAction(() => call.setScreensharingEnabled(false));
|
||||||
|
await CallForeground.screenCaptureStopped('voip');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +433,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
||||||
currentCall = null;
|
currentCall = null;
|
||||||
});
|
});
|
||||||
|
await CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -399,6 +443,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
_isCameraMuted = !video;
|
_isCameraMuted = !video;
|
||||||
|
|
||||||
|
_speakerOn = true;
|
||||||
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
||||||
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
@@ -525,6 +570,13 @@ class MediaDevicesWrapper extends MediaDevices {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
|
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
|
||||||
|
// Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
|
||||||
|
// Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
|
||||||
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
if (!await CallForeground.prepareScreenCapture('voip')) {
|
||||||
|
throw Exception('Bildschirmübertragung wurde nicht erlaubt');
|
||||||
|
}
|
||||||
|
}
|
||||||
final String? sourceId = _manager._selectedSourceId;
|
final String? sourceId = _manager._selectedSourceId;
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
||||||
final desktopConstraints = {
|
final desktopConstraints = {
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
|
|||||||
final VoidCallback onToggleScreen;
|
final VoidCallback onToggleScreen;
|
||||||
final VoidCallback onHangUp;
|
final VoidCallback onHangUp;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
|
||||||
|
final bool isSpeakerOn;
|
||||||
|
final VoidCallback? onToggleSpeaker;
|
||||||
|
|
||||||
const _CallControls({
|
const _CallControls({
|
||||||
required this.isMuted,
|
required this.isMuted,
|
||||||
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
|
|||||||
required this.onToggleScreen,
|
required this.onToggleScreen,
|
||||||
required this.onHangUp,
|
required this.onHangUp,
|
||||||
required this.pt,
|
required this.pt,
|
||||||
|
this.isSpeakerOn = true,
|
||||||
|
this.onToggleSpeaker,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final platform = Theme.of(context).platform;
|
||||||
|
final showSpeaker = onToggleSpeaker != null &&
|
||||||
|
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
|
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
|
if (showSpeaker)
|
||||||
|
_VoiceCtrlBtn(
|
||||||
|
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
|
||||||
|
active: isSpeakerOn,
|
||||||
|
pt: pt,
|
||||||
|
onTap: onToggleSpeaker!,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
|
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget {
|
|||||||
final bool isScreenSharing;
|
final bool isScreenSharing;
|
||||||
final Function(Offset)? onQualityMenu;
|
final Function(Offset)? onQualityMenu;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
final bool isSpeakerOn;
|
||||||
|
final VoidCallback? onToggleSpeaker;
|
||||||
|
|
||||||
const _UnifiedCallView({
|
const _UnifiedCallView({
|
||||||
required this.participants,
|
required this.participants,
|
||||||
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
|
|||||||
required this.isScreenSharing,
|
required this.isScreenSharing,
|
||||||
this.onQualityMenu,
|
this.onQualityMenu,
|
||||||
required this.pt,
|
required this.pt,
|
||||||
|
this.isSpeakerOn = true,
|
||||||
|
this.onToggleSpeaker,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -148,6 +152,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
onToggleScreen: widget.onToggleScreen,
|
onToggleScreen: widget.onToggleScreen,
|
||||||
onHangUp: widget.onHangUp,
|
onHangUp: widget.onHangUp,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
isSpeakerOn: widget.isSpeakerOn,
|
||||||
|
onToggleSpeaker: widget.onToggleSpeaker,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
|
|||||||
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
|
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
|
||||||
if (call.isScreenSharing) {
|
if (call.isScreenSharing) {
|
||||||
await call.stopScreenShare();
|
await call.stopScreenShare();
|
||||||
|
} else if (Theme.of(context).platform == TargetPlatform.android ||
|
||||||
|
Theme.of(context).platform == TargetPlatform.iOS) {
|
||||||
|
// Handy: ganzer Bildschirm über die System-Zustimmung (kein
|
||||||
|
// Fenster-Auswähler wie am PC).
|
||||||
|
await call.toggleScreenShare();
|
||||||
} else {
|
} else {
|
||||||
final source = await ScreenSharePicker.show(context);
|
final source = await ScreenSharePicker.show(context);
|
||||||
if (source != null) await call.startScreenShare(source.id);
|
if (source != null) await call.startScreenShare(source.id);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
isScreenSharing: voip.isScreensharing,
|
isScreenSharing: voip.isScreensharing,
|
||||||
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
|
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
isSpeakerOn: voip.isSpeakerOn,
|
||||||
|
onToggleSpeaker: voip.toggleSpeaker,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
isScreenSharing: call.isScreenSharing,
|
isScreenSharing: call.isScreenSharing,
|
||||||
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
|
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
isSpeakerOn: call.isSpeakerOn,
|
||||||
|
onToggleSpeaker: call.toggleSpeaker,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -290,3 +290,68 @@ class _InvitePanelState extends ConsumerState<_InvitePanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
|
||||||
|
/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
|
||||||
|
/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
|
||||||
|
/// kommt (Bernd 2026-10-07).
|
||||||
|
class _ActiveCallBar extends ConsumerWidget {
|
||||||
|
const _ActiveCallBar();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
|
||||||
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
final lk = ref.watch(voiceChannelProvider);
|
||||||
|
final voipCall = voip.currentCall;
|
||||||
|
final String? name;
|
||||||
|
if (voipCall != null && voipCall.state != CallState.kEnded) {
|
||||||
|
name = voipCall.room.getLocalizedDisplayname();
|
||||||
|
} else if (lk.isActive || lk.isConnecting) {
|
||||||
|
name = lk.displayName;
|
||||||
|
} else {
|
||||||
|
name = null;
|
||||||
|
}
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: motionDuration(context, 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
child: name == null
|
||||||
|
? const SizedBox(width: double.infinity)
|
||||||
|
: Material(
|
||||||
|
color: pt.online.withValues(alpha: 0.16),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.call_rounded, size: 18, color: pt.online),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Anruf läuft · $name',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text('Zurück',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.online,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
setState(() => _pinnedOpen = !_pinnedOpen),
|
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||||
),
|
),
|
||||||
const _ConnectionBanner(),
|
const _ConnectionBanner(),
|
||||||
|
const _ActiveCallBar(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: isInvite
|
child: isInvite
|
||||||
? _InvitePanel(room: room!, pt: pt)
|
? _InvitePanel(room: room!, pt: pt)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:livekit_client/livekit_client.dart';
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
import 'package:matrix/matrix.dart' hide Room;
|
import 'package:matrix/matrix.dart' hide Room;
|
||||||
|
import 'package:pyramid/core/call_foreground.dart';
|
||||||
import 'package:pyramid/core/ice_servers.dart';
|
import 'package:pyramid/core/ice_servers.dart';
|
||||||
import 'package:pyramid/core/livekit_token.dart';
|
import 'package:pyramid/core/livekit_token.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
|
|
||||||
void _onRoomChanged() => notifyListeners();
|
void _onRoomChanged() => notifyListeners();
|
||||||
|
|
||||||
|
// Handy: Lautsprecher statt Hörmuschel (wie Discord).
|
||||||
|
bool _speakerOn = true;
|
||||||
|
bool get _isMobile =>
|
||||||
|
!kIsWeb &&
|
||||||
|
(defaultTargetPlatform == TargetPlatform.android ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.iOS);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSpeakerOn => _speakerOn;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> toggleSpeaker() async {
|
||||||
|
_speakerOn = !_speakerOn;
|
||||||
|
notifyListeners();
|
||||||
|
await _applySpeaker();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySpeaker() async {
|
||||||
|
if (!_isMobile) return;
|
||||||
|
try {
|
||||||
|
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] Lautsprecher: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _cleanUpAfterDisconnect() {
|
void _cleanUpAfterDisconnect() {
|
||||||
_callGeneration++;
|
_callGeneration++;
|
||||||
|
CallForeground.callEnded('livekit');
|
||||||
_stopStatsLogging();
|
_stopStatsLogging();
|
||||||
_presenceHeartbeat?.cancel();
|
_presenceHeartbeat?.cancel();
|
||||||
_presenceHeartbeat = null;
|
_presenceHeartbeat = null;
|
||||||
@@ -254,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
_room = room;
|
_room = room;
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
_speakerOn = true;
|
||||||
|
unawaited(_applySpeaker());
|
||||||
|
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||||
|
unawaited(CallForeground.callStarted('livekit', title: roomDisplayName));
|
||||||
|
|
||||||
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
|
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
|
||||||
await _writePresence(true);
|
await _writePresence(true);
|
||||||
@@ -269,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
isScreenSharing = false;
|
isScreenSharing = false;
|
||||||
_currentScreenShareSourceId = null;
|
_currentScreenShareSourceId = null;
|
||||||
_screenShareTrack = null;
|
_screenShareTrack = null;
|
||||||
|
CallForeground.screenCaptureStopped('livekit');
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
})
|
})
|
||||||
@@ -487,6 +520,11 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android 14+: erst Zustimmung + mediaProjection-Dienst, sonst
|
||||||
|
// stürzt die App beim Mitschnitt ab.
|
||||||
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
if (!await CallForeground.prepareScreenCapture('livekit')) return;
|
||||||
|
}
|
||||||
await _room?.localParticipant?.setScreenShareEnabled(true);
|
await _room?.localParticipant?.setScreenShareEnabled(true);
|
||||||
isScreenSharing = true;
|
isScreenSharing = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -587,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
||||||
|
await CallForeground.screenCaptureStopped('livekit');
|
||||||
isScreenSharing = false;
|
isScreenSharing = false;
|
||||||
_currentScreenShareSourceId = null;
|
_currentScreenShareSourceId = null;
|
||||||
_screenShareTrack = null;
|
_screenShareTrack = null;
|
||||||
@@ -597,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
Future<void> hangUp() async {
|
Future<void> hangUp() async {
|
||||||
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
||||||
_callGeneration++;
|
_callGeneration++;
|
||||||
|
unawaited(CallForeground.callEnded('livekit'));
|
||||||
_stopStatsLogging();
|
_stopStatsLogging();
|
||||||
_presenceHeartbeat?.cancel();
|
_presenceHeartbeat?.cancel();
|
||||||
_presenceHeartbeat = null;
|
_presenceHeartbeat = null;
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
|
|||||||
Future<void> hangUp();
|
Future<void> hangUp();
|
||||||
|
|
||||||
Future<void> toggleMute();
|
Future<void> toggleMute();
|
||||||
|
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||||
|
/// Auf dem PC ohne Bedeutung.
|
||||||
|
bool get isSpeakerOn => true;
|
||||||
|
Future<void> toggleSpeaker() async {}
|
||||||
|
|
||||||
Future<void> toggleDeafen();
|
Future<void> toggleDeafen();
|
||||||
Future<void> toggleCamera();
|
Future<void> toggleCamera();
|
||||||
Future<void> toggleScreenShare();
|
Future<void> toggleScreenShare();
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
|
||||||
|
/// Mikrofontest mit Pegelanzeige (Einstellungen → Sprache & Video).
|
||||||
|
///
|
||||||
|
/// Nimmt über DENSELBEN Weg wie ein Anruf auf (gewähltes Gerät aus den
|
||||||
|
/// Einstellungen, WebRTC) und zeigt den Pegel live. Anlass: Bernds Test
|
||||||
|
/// 2026-10-07 – das Handy hörte nichts vom PC, weil (mit Voicemeeter und
|
||||||
|
/// vielen virtuellen Geräten) das Standardgerät nur Stille lieferte. Ohne
|
||||||
|
/// Pegelanzeige war das nicht zu erkennen.
|
||||||
|
///
|
||||||
|
/// Technik: lokale Verbindung zu sich selbst (zwei PeerConnections im selben
|
||||||
|
/// Prozess) – erst dann startet libwebrtc die Aufnahme und liefert den Pegel
|
||||||
|
/// in den Statistiken. Es wird nichts verschickt oder gespeichert. Nach
|
||||||
|
/// 30 Sekunden endet der Test von selbst.
|
||||||
|
class MicLevelTest extends StatefulWidget {
|
||||||
|
const MicLevelTest({super.key, required this.pt, this.enabled = true});
|
||||||
|
|
||||||
|
final PyramidTheme pt;
|
||||||
|
|
||||||
|
/// false z. B. während eines Anrufs (zwei Aufnahmen gleichzeitig vermeiden).
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MicLevelTest> createState() => _MicLevelTestState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MicLevelTestState extends State<MicLevelTest> {
|
||||||
|
MediaStream? _stream;
|
||||||
|
RTCPeerConnection? _a;
|
||||||
|
RTCPeerConnection? _b;
|
||||||
|
Timer? _poll;
|
||||||
|
Timer? _autoStop;
|
||||||
|
bool _running = false;
|
||||||
|
bool _starting = false;
|
||||||
|
double _level = 0; // 0..1 (geglättet)
|
||||||
|
double _peak = 0;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stop(notify: false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _start() async {
|
||||||
|
setState(() {
|
||||||
|
_starting = true;
|
||||||
|
_error = null;
|
||||||
|
_peak = 0;
|
||||||
|
_level = 0;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final mic = (await VoicePrefs.loadDevices()).mic;
|
||||||
|
final audio = mic == null
|
||||||
|
? true
|
||||||
|
: {
|
||||||
|
'deviceId': mic,
|
||||||
|
'optional': [
|
||||||
|
{'sourceId': mic},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
final stream = await navigator.mediaDevices
|
||||||
|
.getUserMedia({'audio': audio, 'video': false});
|
||||||
|
final a = await createPeerConnection({});
|
||||||
|
final b = await createPeerConnection({});
|
||||||
|
a.onIceCandidate = (c) => b.addCandidate(c);
|
||||||
|
b.onIceCandidate = (c) => a.addCandidate(c);
|
||||||
|
await a.addTrack(stream.getAudioTracks().first, stream);
|
||||||
|
final offer = await a.createOffer();
|
||||||
|
await a.setLocalDescription(offer);
|
||||||
|
await b.setRemoteDescription(offer);
|
||||||
|
final answer = await b.createAnswer();
|
||||||
|
await b.setLocalDescription(answer);
|
||||||
|
await a.setRemoteDescription(answer);
|
||||||
|
if (!mounted) {
|
||||||
|
await _release(stream, a, b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_stream = stream;
|
||||||
|
_a = a;
|
||||||
|
_b = b;
|
||||||
|
_poll = Timer.periodic(const Duration(milliseconds: 120), (_) => _read());
|
||||||
|
_autoStop = Timer(const Duration(seconds: 30), _stop);
|
||||||
|
setState(() {
|
||||||
|
_running = true;
|
||||||
|
_starting = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_starting = false;
|
||||||
|
_error = 'Mikrofon ließ sich nicht öffnen: ${e.toString().split('\n').first}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _read() async {
|
||||||
|
final a = _a;
|
||||||
|
if (a == null) return;
|
||||||
|
try {
|
||||||
|
for (final r in await a.getStats()) {
|
||||||
|
if (r.type != 'media-source') continue;
|
||||||
|
final raw = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
|
||||||
|
// Wurzel-Skala: leise Sprache soll sichtbar ausschlagen.
|
||||||
|
final shown = math.sqrt(raw.clamp(0.0, 1.0));
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_level = _level * 0.4 + shown * 0.6;
|
||||||
|
_peak = math.max(_peak, shown);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async {
|
||||||
|
for (final t in s?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||||
|
try {
|
||||||
|
await t.stop();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await s?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await a?.close();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await b?.close();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stop({bool notify = true}) {
|
||||||
|
_poll?.cancel();
|
||||||
|
_autoStop?.cancel();
|
||||||
|
_poll = null;
|
||||||
|
_autoStop = null;
|
||||||
|
final s = _stream, a = _a, b = _b;
|
||||||
|
_stream = null;
|
||||||
|
_a = null;
|
||||||
|
_b = null;
|
||||||
|
_release(s, a, b);
|
||||||
|
if (notify && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_running = false;
|
||||||
|
_level = 0;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
final silent = _running && _peak < 0.02;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.mic_rounded, size: 15, color: pt.fgMuted),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Mikrofontest',
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: !widget.enabled || _starting
|
||||||
|
? null
|
||||||
|
: (_running ? _stop : _start),
|
||||||
|
child: Text(_running ? 'Beenden' : 'Testen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Container(
|
||||||
|
height: 8,
|
||||||
|
color: pt.bg3,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: AnimatedFractionallySizedBox(
|
||||||
|
duration: const Duration(milliseconds: 110),
|
||||||
|
widthFactor: _level.clamp(0.0, 1.0),
|
||||||
|
heightFactor: 1,
|
||||||
|
child: Container(color: _level > 0.85 ? pt.danger : pt.accent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
!widget.enabled
|
||||||
|
? 'Während eines Anrufs nicht möglich.'
|
||||||
|
: _error ??
|
||||||
|
(_running
|
||||||
|
? (silent
|
||||||
|
? 'Kein Ton zu hören – sprich etwas. Bleibt der Balken leer, oben ein anderes Mikrofon wählen.'
|
||||||
|
: 'Sprich etwas – der Balken zeigt, was die anderen hören würden.')
|
||||||
|
: 'Prüft das oben gewählte Mikrofon. Es wird nichts gesendet oder gespeichert.'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: _error != null || silent ? pt.away : pt.fgDim,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
|
|||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_SettingsGroup(title: 'Pegel', pt: pt, children: [
|
_SettingsGroup(title: 'Pegel', pt: pt, children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
child: Builder(builder: (context) {
|
||||||
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
final lk = ref.watch(voiceChannelProvider);
|
||||||
|
final inCall = voip.currentCall != null || lk.isActive;
|
||||||
|
// Schlüssel je gewähltem Mikrofon: Gerätewechsel beendet
|
||||||
|
// einen laufenden Test (sonst misst er das alte Gerät).
|
||||||
|
return MicLevelTest(
|
||||||
|
key: ValueKey('mictest:$_selectedMic'),
|
||||||
|
pt: pt,
|
||||||
|
enabled: !inCall,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
_Divider(pt: pt),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:pyramid/widgets/banner_crop_dialog.dart';
|
import 'package:pyramid/widgets/banner_crop_dialog.dart';
|
||||||
import 'package:pyramid/widgets/hover_region.dart';
|
import 'package:pyramid/widgets/hover_region.dart';
|
||||||
|
import 'package:pyramid/widgets/mic_level_test.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
import 'package:pyramid/widgets/pyramid_loader.dart';
|
import 'package:pyramid/widgets/pyramid_loader.dart';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user