From 441cc6ef5480d62e2b2397553d5e901fdc5fa355 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Thu, 8 Oct 2026 00:01:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(calls):=20Bildschirm=C3=BCbertragung=20ohne?= =?UTF-8?q?=20Absturz,=20Lautsprecher,=20Mikrofon=20im=20Hintergrund,=20zu?= =?UTF-8?q?r=C3=BCck=20in=20den=20Anruf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/app/src/main/AndroidManifest.xml | 9 +- .../chat/pyramid/pyramid/CallService.kt | 143 ++++++++++++ .../chat/pyramid/pyramid/MainActivity.kt | 33 +++ .../android_screen_capture_test.dart | 52 +++++ lib/core/call_foreground.dart | 84 +++++++ .../call_signaling_service.dart | 5 + lib/features/call_signaling/voip_manager.dart | 52 +++++ .../call_ui/view/avatars_controls.dart | 18 +- lib/features/call_ui/view/call_view.dart | 6 + lib/features/call_ui/view/quality_menu.dart | 5 + .../call_ui/view/voice_channel_core.dart | 4 + lib/features/chat/view/chat_misc.dart | 65 ++++++ lib/features/chat/view/chat_view_core.dart | 1 + .../voice_channel/livekit_call_manager.dart | 40 ++++ .../voice_channel/voice_channel_service.dart | 5 + lib/widgets/mic_level_test.dart | 214 ++++++++++++++++++ lib/widgets/settings/settings_voice.dart | 16 ++ lib/widgets/settings_modal.dart | 1 + 18 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/kotlin/chat/pyramid/pyramid/CallService.kt create mode 100644 integration_test/android_screen_capture_test.dart create mode 100644 lib/core/call_foreground.dart create mode 100644 lib/widgets/mic_level_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb6d204..f327c33 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + @@ -26,11 +27,15 @@ android:allowBackup="false" android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules"> + + android:foregroundServiceType="microphone|mediaProjection" /> 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() + 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) + } + ) + } +} diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt index 200d4d9..c53e75b 100644 --- a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt +++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt @@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() { super.configureFlutterEngine(flutterEngine) // Cache so ReplyReceiver can call back into Dart without starting a new engine. 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("title") ?: "Pyramid", + call.argument("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) .setMethodCallHandler { call, result -> when (call.method) { diff --git a/integration_test/android_screen_capture_test.dart b/integration_test/android_screen_capture_test.dart new file mode 100644 index 0000000..314c38d --- /dev/null +++ b/integration_test/android_screen_capture_test.dart @@ -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)), + ); +} diff --git a/lib/core/call_foreground.dart b/lib/core/call_foreground.dart new file mode 100644 index 0000000..3fa212a --- /dev/null +++ b/lib/core/call_foreground.dart @@ -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 = {}; + static final _screenOwners = {}; + static String _title = 'Pyramid'; + + static bool get _android => !kIsWeb && Platform.isAndroid; + + /// true = Dienst läuft (bzw. wurde beendet) wie gewünscht. + static Future _apply() async { + if (!_android) return true; + try { + if (_owners.isEmpty) { + await _channel.invokeMethod('stop'); + return true; + } + final ok = await _channel.invokeMethod('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 callStarted(String owner, {required String title}) async { + _owners.add(owner); + _title = title; + await _apply(); + } + + /// Anruf von [owner] ist beendet. + static Future 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 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 screenCaptureStopped(String owner) async { + if (_screenOwners.remove(owner)) await _apply(); + } +} diff --git a/lib/features/call_signaling/call_signaling_service.dart b/lib/features/call_signaling/call_signaling_service.dart index 3d869a7..d703d13 100644 --- a/lib/features/call_signaling/call_signaling_service.dart +++ b/lib/features/call_signaling/call_signaling_service.dart @@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier { Future changeQuality(String key); + /// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false). + /// Auf dem PC ohne Bedeutung. + bool get isSpeakerOn => true; + Future toggleSpeaker() async {} + /// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den /// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen. Future applyOutputVolume([double? value]); diff --git a/lib/features/call_signaling/voip_manager.dart b/lib/features/call_signaling/voip_manager.dart index 9fd5ed5..20804d1 100644 --- a/lib/features/call_signaling/voip_manager.dart +++ b/lib/features/call_signaling/voip_manager.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:matrix/matrix.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/settings_prefs.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; @@ -35,6 +36,34 @@ class PyramidVoipManager extends CallSignalingService bool _isDeafened = false; bool _isUpdating = false; 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 toggleSpeaker() async { + _speakerOn = !_speakerOn; + notifyListeners(); + await _applySpeaker(); + } + + Future _applySpeaker() async { + if (!_isMobile) return; + try { + await rtc.Helper.setSpeakerphoneOn(_speakerOn); + } catch (e) { + debugPrint('[VOIP] Lautsprecher: $e'); + } + } String _currentQualityKey = 'hd'; @override @@ -104,6 +133,7 @@ class PyramidVoipManager extends CallSignalingService await _ensureRenderers(); currentCall = call; _isCameraMuted = call.type == CallType.kVoice; + _speakerOn = true; _setupCallListeners(call); _updateInternalStates(); notifyListeners(); @@ -120,9 +150,19 @@ class PyramidVoipManager extends CallSignalingService debugPrint('[VOIP] Ensuring camera is unmuted on connect'); 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(); }); + // 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 = Timer.periodic( @@ -259,6 +299,7 @@ class PyramidVoipManager extends CallSignalingService _isRemoteScreensharing = false; notifyListeners(); } + CallForeground.callEnded('voip'); } // ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ───────────── @@ -292,6 +333,7 @@ class PyramidVoipManager extends CallSignalingService currentCall = null; notifyListeners(); } + CallForeground.callEnded('voip'); } @override @@ -361,6 +403,7 @@ class PyramidVoipManager extends CallSignalingService if (_isScreensharing) { await safeAction(() => call.setScreensharingEnabled(false)); + await CallForeground.screenCaptureStopped('voip'); return; } @@ -390,6 +433,7 @@ class PyramidVoipManager extends CallSignalingService await currentCall!.hangup(reason: CallErrorCode.userHangup); currentCall = null; }); + await CallForeground.callEnded('voip'); } @override @@ -399,6 +443,7 @@ class PyramidVoipManager extends CallSignalingService await _ensureRenderers(); _isCameraMuted = !video; + _speakerOn = true; debugPrint('[VOIP] Starting call in room $roomId (video=$video)'); final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice); currentCall = call; @@ -525,6 +570,13 @@ class MediaDevicesWrapper extends MediaDevices { @override Future getDisplayMedia(Map 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; if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) { final desktopConstraints = { diff --git a/lib/features/call_ui/view/avatars_controls.dart b/lib/features/call_ui/view/avatars_controls.dart index 64ba3d0..c998cd7 100644 --- a/lib/features/call_ui/view/avatars_controls.dart +++ b/lib/features/call_ui/view/avatars_controls.dart @@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget { final VoidCallback onToggleScreen; final VoidCallback onHangUp; final PyramidTheme pt; + // Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste). + final bool isSpeakerOn; + final VoidCallback? onToggleSpeaker; const _CallControls({ required this.isMuted, @@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget { required this.onToggleScreen, required this.onHangUp, required this.pt, + this.isSpeakerOn = true, + this.onToggleSpeaker, }); @override Widget build(BuildContext context) { + final platform = Theme.of(context).platform; + final showSpeaker = onToggleSpeaker != null && + (platform == TargetPlatform.android || platform == TargetPlatform.iOS); return SafeArea( top: false, child: Container( @@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget { children: [ _VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic), 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), _VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam), const SizedBox(width: 16), diff --git a/lib/features/call_ui/view/call_view.dart b/lib/features/call_ui/view/call_view.dart index d4f06e2..43eae7f 100644 --- a/lib/features/call_ui/view/call_view.dart +++ b/lib/features/call_ui/view/call_view.dart @@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget { final bool isScreenSharing; final Function(Offset)? onQualityMenu; final PyramidTheme pt; + final bool isSpeakerOn; + final VoidCallback? onToggleSpeaker; const _UnifiedCallView({ required this.participants, @@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget { required this.isScreenSharing, this.onQualityMenu, required this.pt, + this.isSpeakerOn = true, + this.onToggleSpeaker, }); @override @@ -148,6 +152,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> { onToggleScreen: widget.onToggleScreen, onHangUp: widget.onHangUp, pt: pt, + isSpeakerOn: widget.isSpeakerOn, + onToggleSpeaker: widget.onToggleSpeaker, ), ], ), diff --git a/lib/features/call_ui/view/quality_menu.dart b/lib/features/call_ui/view/quality_menu.dart index b0511da..09eb92b 100644 --- a/lib/features/call_ui/view/quality_menu.dart +++ b/lib/features/call_ui/view/quality_menu.dart @@ -3,6 +3,11 @@ part of '../voice_channel.dart'; void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async { if (call.isScreenSharing) { 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 { final source = await ScreenSharePicker.show(context); if (source != null) await call.startScreenShare(source.id); diff --git a/lib/features/call_ui/view/voice_channel_core.dart b/lib/features/call_ui/view/voice_channel_core.dart index 6c3e308..68a544a 100644 --- a/lib/features/call_ui/view/voice_channel_core.dart +++ b/lib/features/call_ui/view/voice_channel_core.dart @@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget { isScreenSharing: voip.isScreensharing, onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos), pt: pt, + isSpeakerOn: voip.isSpeakerOn, + onToggleSpeaker: voip.toggleSpeaker, ); } @@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget { isScreenSharing: call.isScreenSharing, onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos), pt: pt, + isSpeakerOn: call.isSpeakerOn, + onToggleSpeaker: call.toggleSpeaker, ); } diff --git a/lib/features/chat/view/chat_misc.dart b/lib/features/chat/view/chat_misc.dart index b718c8a..9033f2d 100644 --- a/lib/features/chat/view/chat_misc.dart +++ b/lib/features/chat/view/chat_misc.dart @@ -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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart index 4270c4b..874462b 100644 --- a/lib/features/chat/view/chat_view_core.dart +++ b/lib/features/chat/view/chat_view_core.dart @@ -520,6 +520,7 @@ class _ChatViewState extends ConsumerState { setState(() => _pinnedOpen = !_pinnedOpen), ), const _ConnectionBanner(), + const _ActiveCallBar(), Expanded( child: isInvite ? _InvitePanel(room: room!, pt: pt) diff --git a/lib/features/voice_channel/livekit_call_manager.dart b/lib/features/voice_channel/livekit_call_manager.dart index e40aebc..0e1769f 100644 --- a/lib/features/voice_channel/livekit_call_manager.dart +++ b/lib/features/voice_channel/livekit_call_manager.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:livekit_client/livekit_client.dart'; 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/livekit_token.dart'; import 'package:pyramid/core/settings_prefs.dart'; @@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService { 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 toggleSpeaker() async { + _speakerOn = !_speakerOn; + notifyListeners(); + await _applySpeaker(); + } + + Future _applySpeaker() async { + if (!_isMobile) return; + try { + await rtc.Helper.setSpeakerphoneOn(_speakerOn); + } catch (e) { + debugPrint('[LiveKit] Lautsprecher: $e'); + } + } + void _cleanUpAfterDisconnect() { _callGeneration++; + CallForeground.callEnded('livekit'); _stopStatsLogging(); _presenceHeartbeat?.cancel(); _presenceHeartbeat = null; @@ -254,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService { _room = room; isConnecting = false; 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) { await _writePresence(true); @@ -269,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService { isScreenSharing = false; _currentScreenShareSourceId = null; _screenShareTrack = null; + CallForeground.screenCaptureStopped('livekit'); } 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); isScreenSharing = true; notifyListeners(); @@ -587,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService { } } try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {} + await CallForeground.screenCaptureStopped('livekit'); isScreenSharing = false; _currentScreenShareSourceId = null; _screenShareTrack = null; @@ -597,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService { Future hangUp() async { // Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration). _callGeneration++; + unawaited(CallForeground.callEnded('livekit')); _stopStatsLogging(); _presenceHeartbeat?.cancel(); _presenceHeartbeat = null; diff --git a/lib/features/voice_channel/voice_channel_service.dart b/lib/features/voice_channel/voice_channel_service.dart index c19091b..c90a42b 100644 --- a/lib/features/voice_channel/voice_channel_service.dart +++ b/lib/features/voice_channel/voice_channel_service.dart @@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier { Future hangUp(); Future toggleMute(); + /// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false). + /// Auf dem PC ohne Bedeutung. + bool get isSpeakerOn => true; + Future toggleSpeaker() async {} + Future toggleDeafen(); Future toggleCamera(); Future toggleScreenShare(); diff --git a/lib/widgets/mic_level_test.dart b/lib/widgets/mic_level_test.dart new file mode 100644 index 0000000..8c018ba --- /dev/null +++ b/lib/widgets/mic_level_test.dart @@ -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 createState() => _MicLevelTestState(); +} + +class _MicLevelTestState extends State { + 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 _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 _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 _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async { + for (final t in s?.getTracks() ?? []) { + 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, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_voice.dart b/lib/widgets/settings/settings_voice.dart index 258ecd8..eb8554f 100644 --- a/lib/widgets/settings/settings_voice.dart +++ b/lib/widgets/settings/settings_voice.dart @@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> { ]), const SizedBox(height: 16), _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: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Column( diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart index 45393e6..84e4771 100644 --- a/lib/widgets/settings_modal.dart +++ b/lib/widgets/settings_modal.dart @@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:pyramid/widgets/banner_crop_dialog.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/pyramid_loader.dart';