From ffd70caffdbad570c0cac97be5428fc76dd08b64 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Mon, 6 Jul 2026 22:19:25 +0200 Subject: [PATCH] feat: 60-FPS-Streaming-Presets (hd60/fhd60) fuer Bildschirm-Streams (M4) Nach docs/STREAMING_60FPS.md: neue Presets 720p/1080p mit 60 FPS und hoher Bitrate (5/8 Mbit/s), Preset-Aufloesung+FPS werden jetzt bis in die Capture-Constraints durchgereicht (vorher blieb der LiveKit-Default 1080p/15 aktiv), 60-FPS-Presets publishen ohne Simulcast mit DegradationPreference.maintainFramerate (unter Last Aufloesung statt Frames opfern). contentHint 'motion' ist in flutter_webrtc 1.4.1 aus Dart nicht setzbar (geprueft) - im Code dokumentiert. 4 neue Vertragstests fuer die Preset-Tabellen. Co-Authored-By: Claude Fable 5 --- lib/features/call_ui/view/quality_menu.dart | 9 ++- .../voice_channel/livekit_call_manager.dart | 44 ++++++++++++--- .../voice_channel/voice_channel_service.dart | 3 +- test/streaming_presets_test.dart | 55 +++++++++++++++++++ 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 test/streaming_presets_test.dart diff --git a/lib/features/call_ui/view/quality_menu.dart b/lib/features/call_ui/view/quality_menu.dart index 11d5aba..b0511da 100644 --- a/lib/features/call_ui/view/quality_menu.dart +++ b/lib/features/call_ui/view/quality_menu.dart @@ -18,7 +18,14 @@ void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Off shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)), items: [ const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), - for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')]) + for (final q in [ + ('sd', 'SD', '360p'), + ('hd', 'HD', '720p'), + ('hd60', 'HD 60fps', '720p·60'), + ('fhd', 'Full HD', '1080p'), + ('fhd60', 'Full HD 60fps', '1080p·60'), + ('4k', '4K', '2160p'), + ]) PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)), const PopupMenuDivider(), const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), diff --git a/lib/features/voice_channel/livekit_call_manager.dart b/lib/features/voice_channel/livekit_call_manager.dart index 6dbe5d9..ad4d93f 100644 --- a/lib/features/voice_channel/livekit_call_manager.dart +++ b/lib/features/voice_channel/livekit_call_manager.dart @@ -21,17 +21,30 @@ class LiveKitCallManager extends VoiceChannelService { static final Map qualityPresets = { 'sd': VideoParametersPresets.h360_169, 'hd': VideoParametersPresets.h720_169, + 'hd60': VideoParametersPresets.h720_169, 'fhd': VideoParametersPresets.h1080_169, + 'fhd60': VideoParametersPresets.h1080_169, '4k': VideoParametersPresets.h2160_169, }; - static const Map _screenShareEncodings = { - 'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10), - 'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15), - 'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20), - '4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30), + /// FPS + Bitrate pro Screenshare-Preset. Die 60-FPS-Presets (M4, + /// `docs/STREAMING_60FPS.md`) brauchen deutlich mehr Bitrate, sonst + /// verwirft der Encoder Frames und es ruckelt trotz hoher Framerate. + @visibleForTesting + static const Map screenShareEncodings = { + 'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10), + 'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15), + 'hd60': VideoEncoding(maxBitrate: 5_000_000, maxFramerate: 60), + 'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20), + 'fhd60': VideoEncoding(maxBitrate: 8_000_000, maxFramerate: 60), + '4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30), }; + /// 60-FPS-Presets bekommen beim Publish eine andere Strategie + /// (kein Simulcast, Framerate vor Auflösung halten). + @visibleForTesting + static bool isHighFpsPreset(String key) => key.endsWith('60'); + Room? _room; EventsListener? _roomEventListener; String _displayName = ''; @@ -445,7 +458,7 @@ class LiveKitCallManager extends VoiceChannelService { } VideoEncoding _screenShareEncoding() { - return _screenShareEncodings[_localQualityKey] ?? + return screenShareEncodings[_localQualityKey] ?? const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15); } @@ -454,10 +467,18 @@ class LiveKitCallManager extends VoiceChannelService { if (isScreenSharing) return; try { final encoding = _screenShareEncoding(); + final highFps = isHighFpsPreset(_localQualityKey); final track = await LocalVideoTrack.createScreenShareTrack( ScreenShareCaptureOptions( sourceId: sourceId, maxFrameRate: encoding.maxFramerate.toDouble(), + // Ohne explizite params bleibt die Capture-Auflösung beim + // LiveKit-Default (1080p/15) hängen – das Preset (Auflösung UND + // FPS) muss bis in die Capture-Constraints durchgereicht werden. + params: VideoParameters( + dimensions: _currentQuality.dimensions, + encoding: encoding, + ), ), ); @@ -466,8 +487,15 @@ class LiveKitCallManager extends VoiceChannelService { await _room!.localParticipant!.publishVideoTrack( track, publishOptions: VideoPublishOptions( - simulcast: true, - degradationPreference: DegradationPreference.balanced, + // 60-FPS-Presets: EIN Layer mit voller Bitrate statt drei + // Simulcast-Encodes (Software-Encoder!), und unter Last lieber + // Auflösung senken als Frames verlieren. contentHint 'motion' + // wäre ein weiterer Hebel, ist aber in flutter_webrtc 1.4.1 aus + // Dart nicht setzbar (Pub-Cache geprüft, 2026-07-06). + simulcast: !highFps, + degradationPreference: highFps + ? DegradationPreference.maintainFramerate + : DegradationPreference.balanced, screenShareEncoding: encoding, ), ); diff --git a/lib/features/voice_channel/voice_channel_service.dart b/lib/features/voice_channel/voice_channel_service.dart index 50da51d..c19091b 100644 --- a/lib/features/voice_channel/voice_channel_service.dart +++ b/lib/features/voice_channel/voice_channel_service.dart @@ -34,7 +34,8 @@ abstract class VoiceChannelService extends ChangeNotifier { String? get currentRoomId; String get displayName; - /// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k'). + /// Sende-Qualität ('sd' | 'hd' | 'hd60' | 'fhd' | 'fhd60' | '4k'). + /// Die *60-Presets sind für flüssiges Bildschirm-Streaming (M4). String get currentQualityKey; /// Empfangs-Qualität ('auto' | 'low' | 'medium' | 'high'). diff --git a/test/streaming_presets_test.dart b/test/streaming_presets_test.dart new file mode 100644 index 0000000..90e46e2 --- /dev/null +++ b/test/streaming_presets_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:pyramid/features/voice_channel/livekit_call_manager.dart'; + +/// Vertragstests für die Streaming-Qualitäts-Presets (M4, +/// `docs/STREAMING_60FPS.md`). Nagelt fest, was `startScreenShare` und das +/// SENDEN-Menü voraussetzen: jede Auflösung hat eine Encoding-Zeile, die +/// 60-FPS-Presets tragen wirklich 60 FPS und genug Bitrate dafür. +/// (Das echte Publish-Verhalten – sender.parameters.encodings im Call – +/// braucht zwei verbundene Clients und bleibt ein Gerätetest.) +void main() { + test('Jedes Auflösungs-Preset hat genau eine Encoding-Zeile (und umgekehrt)', + () { + expect( + LiveKitCallManager.qualityPresets.keys.toSet(), + LiveKitCallManager.screenShareEncodings.keys.toSet(), + reason: 'qualityPresets und screenShareEncodings müssen dieselben ' + 'Keys haben – sonst fällt ein Menü-Eintrag auf den Fallback zurück', + ); + }); + + test('60-FPS-Presets: wirklich 60 FPS, hohe Bitrate, richtige Auflösung', + () { + for (final key in ['hd60', 'fhd60']) { + final enc = LiveKitCallManager.screenShareEncodings[key]!; + expect(enc.maxFramerate, 60, reason: '$key muss 60 FPS tragen'); + expect(enc.maxBitrate, greaterThanOrEqualTo(4_000_000), + reason: '$key: zu wenig Bitrate für 60 FPS → Encoder ruckelt'); + expect(LiveKitCallManager.isHighFpsPreset(key), isTrue); + } + expect(LiveKitCallManager.qualityPresets['hd60']!.dimensions, + VideoParametersPresets.h720_169.dimensions); + expect(LiveKitCallManager.qualityPresets['fhd60']!.dimensions, + VideoParametersPresets.h1080_169.dimensions); + }); + + test('60-FPS-Preset hat mehr Bitrate als sein Standard-Pendant', () { + final enc = LiveKitCallManager.screenShareEncodings; + expect(enc['hd60']!.maxBitrate, greaterThan(enc['hd']!.maxBitrate)); + expect(enc['fhd60']!.maxBitrate, greaterThan(enc['fhd']!.maxBitrate)); + }); + + test('Bestands-Presets unverändert (kein Verhaltenssprung für Uta)', () { + final enc = LiveKitCallManager.screenShareEncodings; + expect(enc['sd'], const VideoEncoding(maxBitrate: 500_000, maxFramerate: 10)); + expect(enc['hd'], const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15)); + expect(enc['fhd'], const VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20)); + expect(enc['4k'], const VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30)); + for (final key in ['sd', 'hd', 'fhd', '4k']) { + expect(LiveKitCallManager.isHighFpsPreset(key), isFalse, + reason: '$key darf NICHT als 60-FPS-Preset behandelt werden ' + '(sonst verliert es Simulcast)'); + } + }); +}