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
@@ -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);
|
||||
|
||||
/// 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
|
||||
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||
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: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<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';
|
||||
|
||||
@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<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;
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
||||
final desktopConstraints = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
const _ConnectionBanner(),
|
||||
const _ActiveCallBar(),
|
||||
Expanded(
|
||||
child: isInvite
|
||||
? _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: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<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() {
|
||||
_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<void> hangUp() async {
|
||||
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
||||
_callGeneration++;
|
||||
unawaited(CallForeground.callEnded('livekit'));
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
|
||||
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
|
||||
Future<void> hangUp();
|
||||
|
||||
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> toggleCamera();
|
||||
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),
|
||||
_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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user