9dc11757e2
Entfernt tatsächlich toten Code, den flutter analyze als Warning markiert hatte: unbenutzte Felder/Parameter (_saved, _hovered wird jetzt für Hover- Feedback genutzt, destructive-Flag ohne Aufrufer, size-Parameter ohne Override), unerreichbaren dead_code (Room.topic/Space.topic sind laut SDK nie null, client.database ist nie null) sowie Legacy-Funktionen aus der alten Push-Architektur (_showNotif/_fetchTitle, ersetzt durch natives PushService.kt) und einen leeren Noise-Suppression-Stub ohne Aufrufer. Keine funktionale Verhaltensänderung an aktiven Code-Pfaden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
605 lines
19 KiB
Dart
605 lines
19 KiB
Dart
// ignore_for_file: avoid_print
|
|
import 'dart:async';
|
|
|
|
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/ice_servers.dart';
|
|
import 'package:pyramid/core/livekit_token.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
const _kVoicePresenceType = 'io.pyramid.voice.presence';
|
|
|
|
class LiveKitCallManager with ChangeNotifier {
|
|
static final LiveKitCallManager instance = LiveKitCallManager._();
|
|
LiveKitCallManager._();
|
|
|
|
// ── Publish quality presets (Streamer → Server) ──────────────────────────
|
|
static final Map<String, VideoParameters> qualityPresets = {
|
|
'sd': VideoParametersPresets.h360_169,
|
|
'hd': VideoParametersPresets.h720_169,
|
|
'fhd': VideoParametersPresets.h1080_169,
|
|
'4k': VideoParametersPresets.h2160_169,
|
|
};
|
|
|
|
static const Map<String, VideoEncoding> _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),
|
|
};
|
|
|
|
Room? _room;
|
|
EventsListener<RoomEvent>? _roomEventListener;
|
|
String _displayName = '';
|
|
bool isConnecting = false;
|
|
String? error;
|
|
bool isMuted = false;
|
|
bool isCameraOff = false;
|
|
bool isScreenSharing = false;
|
|
String _localQualityKey = 'hd';
|
|
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
|
|
String? _currentScreenShareSourceId;
|
|
LocalVideoTrack? _screenShareTrack;
|
|
bool isDeafened = false;
|
|
bool _mutedBeforeDeafen = false;
|
|
bool isVoiceChannel = false;
|
|
String? _matrixRoomId;
|
|
Client? _matrixClient;
|
|
Timer? _presenceHeartbeat;
|
|
|
|
Timer? _statsTimer;
|
|
|
|
bool get isActive => _room != null || isConnecting;
|
|
Room? get room => _room;
|
|
String? get currentRoomId => _matrixRoomId;
|
|
String get displayName => _displayName;
|
|
bool get noiseSuppression => false;
|
|
String get currentQualityKey => _localQualityKey;
|
|
String get subscribeQualityKey => _subscribeQualityKey;
|
|
|
|
VideoParameters get _currentQuality =>
|
|
qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169;
|
|
|
|
void _onRoomChanged() => notifyListeners();
|
|
|
|
void _cleanUpAfterDisconnect() {
|
|
_stopStatsLogging();
|
|
_presenceHeartbeat?.cancel();
|
|
_presenceHeartbeat = null;
|
|
if (_matrixClient != null && _matrixRoomId != null) {
|
|
_writePresence(false).catchError((_) {});
|
|
}
|
|
_roomEventListener?.dispose();
|
|
_roomEventListener = null;
|
|
_room?.removeListener(_onRoomChanged);
|
|
_room = null;
|
|
_displayName = '';
|
|
isConnecting = false;
|
|
error = null;
|
|
isMuted = false;
|
|
isCameraOff = false;
|
|
isScreenSharing = false;
|
|
isDeafened = false;
|
|
isVoiceChannel = false;
|
|
_mutedBeforeDeafen = false;
|
|
_screenShareTrack = null;
|
|
_currentScreenShareSourceId = null;
|
|
_matrixRoomId = null;
|
|
_matrixClient = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _writePresence(bool active) async {
|
|
final client = _matrixClient;
|
|
final roomId = _matrixRoomId;
|
|
final userId = client?.userID;
|
|
if (client == null || roomId == null || userId == null) return;
|
|
try {
|
|
Map<String, dynamic> content;
|
|
if (active) {
|
|
// Resolve display name + avatar from any joined room's member state
|
|
User? member;
|
|
for (final r in client.rooms) {
|
|
final participants = r.getParticipants();
|
|
try {
|
|
member = participants.firstWhere((u) => u.id == userId);
|
|
if (member.displayName != null || member.avatarUrl != null) break;
|
|
} catch (_) {}
|
|
}
|
|
content = {
|
|
'active': true,
|
|
'joined_at': DateTime.now().millisecondsSinceEpoch,
|
|
'display_name': member?.displayName ?? userId,
|
|
if (member?.avatarUrl != null) 'avatar_url': member!.avatarUrl.toString(),
|
|
};
|
|
} else {
|
|
content = {'active': false};
|
|
}
|
|
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
|
|
} catch (e) {
|
|
print('[VoicePresence] $e');
|
|
}
|
|
}
|
|
|
|
Future<void> startCall({
|
|
required String roomName,
|
|
required String roomDisplayName,
|
|
required String identity,
|
|
bool audioOnly = false,
|
|
bool voiceChannel = false,
|
|
Client? matrixClient,
|
|
String? matrixRoomId,
|
|
}) async {
|
|
if (isActive) return;
|
|
|
|
final url = 'wss://livekit.steggi-matrix.work';
|
|
|
|
isConnecting = true;
|
|
isVoiceChannel = voiceChannel;
|
|
error = null;
|
|
_displayName = roomDisplayName;
|
|
isCameraOff = audioOnly;
|
|
isMuted = false;
|
|
isScreenSharing = false;
|
|
isDeafened = false;
|
|
_mutedBeforeDeafen = false;
|
|
_localQualityKey = 'hd';
|
|
_subscribeQualityKey = 'auto';
|
|
_matrixClient = matrixClient;
|
|
_matrixRoomId = matrixRoomId;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final token = LiveKitTokenGenerator.generate(
|
|
roomName: roomName,
|
|
identity: identity,
|
|
displayName: identity,
|
|
ttlSeconds: 21600,
|
|
);
|
|
|
|
final room = Room(
|
|
roomOptions: const RoomOptions(
|
|
adaptiveStream: false, // Matches FluffyChat fix
|
|
dynacast: true,
|
|
),
|
|
);
|
|
room.addListener(_onRoomChanged);
|
|
|
|
// Eigene ICE-Server (STUN/TURN aus ice.json) mitgeben. Client-seitige
|
|
// iceServers ERSETZEN die vom LiveKit-Server gelieferten — die zeigten
|
|
// bisher auf einen hinter CGNAT unerreichbaren coturn.
|
|
RTCConfiguration rtcConfig = const RTCConfiguration();
|
|
try {
|
|
final ice = await IceServers.get();
|
|
rtcConfig = RTCConfiguration(
|
|
iceServers: ice
|
|
.map((m) => RTCIceServer(
|
|
urls: m['urls'] is List
|
|
? List<String>.from(m['urls'] as List)
|
|
: [m['urls'] as String],
|
|
username: m['username'] as String?,
|
|
credential: m['credential'] as String?,
|
|
))
|
|
.toList(),
|
|
);
|
|
} catch (e) {
|
|
print('[LiveKit] ICE server fetch error: $e');
|
|
}
|
|
|
|
await room.connect(
|
|
url,
|
|
token,
|
|
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
|
|
);
|
|
|
|
_room = room;
|
|
isConnecting = false;
|
|
notifyListeners();
|
|
|
|
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
|
|
await _writePresence(true);
|
|
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
_writePresence(true).catchError((_) {});
|
|
});
|
|
}
|
|
|
|
_roomEventListener = room.createListener()
|
|
..on<LocalTrackPublishedEvent>((_) => notifyListeners())
|
|
..on<LocalTrackUnpublishedEvent>((event) {
|
|
if (event.publication.source == TrackSource.screenShareVideo) {
|
|
isScreenSharing = false;
|
|
_currentScreenShareSourceId = null;
|
|
_screenShareTrack = null;
|
|
}
|
|
notifyListeners();
|
|
})
|
|
..on<TrackSubscribedEvent>((event) {
|
|
final pub = event.publication;
|
|
if (_subscribeQualityKey != 'auto' && pub.kind == TrackType.VIDEO) {
|
|
pub.setVideoQuality(_videoQualityFromKey(_subscribeQualityKey));
|
|
}
|
|
// Ausgabelautstärke aus den Einstellungen auf neue Audio-Tracks.
|
|
if (pub.kind == TrackType.AUDIO) {
|
|
try {
|
|
rtc.Helper.setVolume(
|
|
_outputVolume.clamp(0.0, 2.0), event.track.mediaStreamTrack);
|
|
} catch (_) {}
|
|
}
|
|
notifyListeners();
|
|
})
|
|
..on<TrackUnsubscribedEvent>((_) => notifyListeners())
|
|
..on<TrackPublishedEvent>((_) => notifyListeners())
|
|
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
|
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
|
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
|
|
..on<RoomDisconnectedEvent>((_) {
|
|
_cleanUpAfterDisconnect();
|
|
});
|
|
|
|
// In den Einstellungen gewählte Geräte anwenden (voice_*_device Prefs).
|
|
// Stale-IDs (abgestecktes Headset etc.) werden gegen die aktuell
|
|
// vorhandenen Geräte validiert und sonst ignoriert.
|
|
String? prefMic, prefSpeaker, prefCam;
|
|
var deviceIds = <String>{};
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0;
|
|
prefMic = prefs.getString('voice_mic_device');
|
|
prefSpeaker = prefs.getString('voice_speaker_device');
|
|
prefCam = prefs.getString('voice_camera_device');
|
|
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
|
|
deviceIds = devices.map((d) => d.deviceId).toSet();
|
|
} catch (e) {
|
|
print('[LiveKit] Device prefs/enumerate error: $e');
|
|
}
|
|
if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null;
|
|
if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
|
|
if (prefCam != null && !deviceIds.contains(prefCam)) prefCam = null;
|
|
|
|
await room.localParticipant?.setMicrophoneEnabled(
|
|
true,
|
|
audioCaptureOptions:
|
|
prefMic != null ? AudioCaptureOptions(deviceId: prefMic) : null,
|
|
);
|
|
|
|
if (prefSpeaker != null) {
|
|
try {
|
|
await rtc.Helper.selectAudioOutput(prefSpeaker);
|
|
} catch (e) {
|
|
print('[LiveKit] selectAudioOutput error: $e');
|
|
}
|
|
}
|
|
|
|
if (!audioOnly) {
|
|
try {
|
|
await room.localParticipant?.setCameraEnabled(
|
|
true,
|
|
cameraCaptureOptions: CameraCaptureOptions(
|
|
deviceId: prefCam,
|
|
params: _currentQuality,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
print('[LiveKit] Failed to enable camera: $e');
|
|
}
|
|
}
|
|
|
|
_startStatsLogging();
|
|
} catch (e) {
|
|
print('[VOIP] Start call error: $e');
|
|
isConnecting = false;
|
|
error = e.toString();
|
|
_cleanUpAfterDisconnect();
|
|
}
|
|
}
|
|
|
|
VideoQuality _videoQualityFromKey(String key) {
|
|
switch (key) {
|
|
case 'low': return VideoQuality.LOW;
|
|
case 'medium': return VideoQuality.MEDIUM;
|
|
case 'high': return VideoQuality.HIGH;
|
|
default: return VideoQuality.HIGH; // auto defaults to high
|
|
}
|
|
}
|
|
|
|
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
|
double _outputVolume = 1.0;
|
|
|
|
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
|
|
Future<void> setOutputVolume(double v) async {
|
|
_outputVolume = v;
|
|
final room = _room;
|
|
if (room == null) return;
|
|
for (final p in room.remoteParticipants.values) {
|
|
for (final pub in p.audioTrackPublications) {
|
|
final t = pub.track;
|
|
if (t != null) {
|
|
try {
|
|
await rtc.Helper.setVolume(v.clamp(0.0, 2.0), t.mediaStreamTrack);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> setSubscribeQuality(String key) async {
|
|
print('[LiveKit] Viewer changing subscribe quality to: $key');
|
|
_subscribeQualityKey = key;
|
|
if (_room == null) { notifyListeners(); return; }
|
|
|
|
final quality = _videoQualityFromKey(key);
|
|
for (final p in _room!.remoteParticipants.values) {
|
|
for (final pub in p.videoTrackPublications) {
|
|
if (pub.kind == TrackType.VIDEO) {
|
|
print('[LiveKit] Setting subscription quality for ${p.identity} to $quality');
|
|
pub.setVideoQuality(quality);
|
|
}
|
|
}
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> toggleMute() async {
|
|
isMuted = !isMuted;
|
|
notifyListeners();
|
|
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
|
|
}
|
|
|
|
Future<void> toggleDeafen() async {
|
|
if (!isDeafened) {
|
|
_mutedBeforeDeafen = isMuted;
|
|
isDeafened = true;
|
|
if (!isMuted) {
|
|
await _room?.localParticipant?.setMicrophoneEnabled(false);
|
|
isMuted = true;
|
|
}
|
|
} else {
|
|
isDeafened = false;
|
|
if (!_mutedBeforeDeafen) {
|
|
await _room?.localParticipant?.setMicrophoneEnabled(true);
|
|
isMuted = false;
|
|
}
|
|
}
|
|
_applyDeafen();
|
|
notifyListeners();
|
|
}
|
|
|
|
void _applyDeafen() {
|
|
if (_room == null) return;
|
|
for (final p in _room!.remoteParticipants.values) {
|
|
for (final trackPub in p.audioTrackPublications) {
|
|
final track = trackPub.track;
|
|
if (track != null) {
|
|
track.mediaStreamTrack.enabled = !isDeafened;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> toggleCamera() async {
|
|
if (isCameraOff) {
|
|
try {
|
|
await _room?.localParticipant?.setCameraEnabled(
|
|
true,
|
|
cameraCaptureOptions: CameraCaptureOptions(params: _currentQuality),
|
|
);
|
|
isCameraOff = false;
|
|
} catch (e) {
|
|
print('[LiveKit] toggleCamera ON error: $e');
|
|
}
|
|
} else {
|
|
try {
|
|
await _room?.localParticipant?.setCameraEnabled(false);
|
|
} catch (e) {
|
|
print('[LiveKit] toggleCamera OFF error: $e');
|
|
}
|
|
isCameraOff = true;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> toggleScreenShare() async {
|
|
if (isScreenSharing) {
|
|
await stopScreenShare();
|
|
} else {
|
|
try {
|
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) {
|
|
final sources = await rtc.desktopCapturer.getSources(types: [rtc.SourceType.Screen, rtc.SourceType.Window]);
|
|
if (sources.isNotEmpty) {
|
|
await startScreenShare(sources.first.id);
|
|
return;
|
|
}
|
|
}
|
|
|
|
await _room?.localParticipant?.setScreenShareEnabled(true);
|
|
isScreenSharing = true;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
print('[ScreenShare] Toggle error: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
VideoEncoding _screenShareEncoding() {
|
|
return _screenShareEncodings[_localQualityKey] ??
|
|
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
|
}
|
|
|
|
Future<void> startScreenShare(String sourceId) async {
|
|
if (isScreenSharing) return;
|
|
try {
|
|
final encoding = _screenShareEncoding();
|
|
final track = await LocalVideoTrack.createScreenShareTrack(
|
|
ScreenShareCaptureOptions(
|
|
sourceId: sourceId,
|
|
maxFrameRate: encoding.maxFramerate.toDouble(),
|
|
),
|
|
);
|
|
|
|
if (_room == null || _room!.localParticipant == null) return;
|
|
|
|
await _room!.localParticipant!.publishVideoTrack(
|
|
track,
|
|
publishOptions: VideoPublishOptions(
|
|
simulcast: true,
|
|
degradationPreference: DegradationPreference.balanced,
|
|
screenShareEncoding: encoding,
|
|
),
|
|
);
|
|
|
|
isScreenSharing = true;
|
|
_currentScreenShareSourceId = sourceId;
|
|
_screenShareTrack = track;
|
|
notifyListeners();
|
|
unawaited(_applyFpsToAllSimulcastLayers(track));
|
|
} catch (e) {
|
|
print('[ScreenShare] ERROR: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _applyFpsToAllSimulcastLayers(LocalVideoTrack track) async {
|
|
final targetFps = _screenShareEncoding().maxFramerate;
|
|
for (var attempt = 1; attempt <= 5; attempt++) {
|
|
await Future.delayed(const Duration(milliseconds: 600));
|
|
if (!isScreenSharing || _screenShareTrack != track) return;
|
|
final sender = track.transceiver?.sender;
|
|
if (sender == null) continue;
|
|
final params = sender.parameters;
|
|
final encodings = params.encodings;
|
|
if (encodings == null || encodings.isEmpty) continue;
|
|
var anyUpdated = false;
|
|
for (final enc in encodings) {
|
|
if ((enc.maxFramerate ?? 0) < targetFps) {
|
|
enc.maxFramerate = targetFps;
|
|
anyUpdated = true;
|
|
}
|
|
}
|
|
if (anyUpdated) await sender.setParameters(params);
|
|
return;
|
|
}
|
|
}
|
|
|
|
Future<void> stopScreenShare() async {
|
|
final local = _room?.localParticipant;
|
|
if (local != null) {
|
|
final toStop = <String>[];
|
|
for (final pub in local.videoTrackPublications) {
|
|
if (pub.source == TrackSource.screenShareVideo) {
|
|
toStop.add(pub.sid);
|
|
await pub.track?.stop();
|
|
}
|
|
}
|
|
for (final sid in toStop) {
|
|
try { await local.removePublishedTrack(sid); } catch (_) {}
|
|
}
|
|
}
|
|
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
|
isScreenSharing = false;
|
|
_currentScreenShareSourceId = null;
|
|
_screenShareTrack = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> hangUp() async {
|
|
_stopStatsLogging();
|
|
_presenceHeartbeat?.cancel();
|
|
_presenceHeartbeat = null;
|
|
if (_matrixClient != null && _matrixRoomId != null) {
|
|
await _writePresence(false).catchError((_) {});
|
|
}
|
|
_roomEventListener?.dispose();
|
|
_roomEventListener = null;
|
|
_room?.removeListener(_onRoomChanged);
|
|
await _room?.disconnect();
|
|
_room = null;
|
|
_displayName = '';
|
|
isConnecting = false;
|
|
error = null;
|
|
isMuted = false;
|
|
isCameraOff = false;
|
|
isScreenSharing = false;
|
|
isDeafened = false;
|
|
isVoiceChannel = false;
|
|
_mutedBeforeDeafen = false;
|
|
_screenShareTrack = null;
|
|
_currentScreenShareSourceId = null;
|
|
_matrixRoomId = null;
|
|
_matrixClient = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> changeQuality(String qualityKey) async {
|
|
print('[LiveKit] Streamer changing publish quality to: $qualityKey');
|
|
_localQualityKey = qualityKey;
|
|
final params = qualityPresets[qualityKey];
|
|
if (params == null) { notifyListeners(); return; }
|
|
if (_room == null) { notifyListeners(); return; }
|
|
|
|
final local = _room!.localParticipant;
|
|
if (local == null) { notifyListeners(); return; }
|
|
|
|
if (!isCameraOff) {
|
|
await local.setCameraEnabled(false);
|
|
await Future.delayed(const Duration(milliseconds: 400));
|
|
try {
|
|
await local.setCameraEnabled(
|
|
true,
|
|
cameraCaptureOptions: CameraCaptureOptions(params: params),
|
|
);
|
|
} catch (e) {
|
|
print('[LiveKit] Camera restart error: $e');
|
|
isCameraOff = true;
|
|
}
|
|
}
|
|
|
|
if (isScreenSharing && _currentScreenShareSourceId != null) {
|
|
final sid = _currentScreenShareSourceId!;
|
|
await stopScreenShare();
|
|
await Future.delayed(const Duration(milliseconds: 400));
|
|
await startScreenShare(sid);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> changeAudioInput(String deviceId) async {
|
|
final participant = _room?.localParticipant;
|
|
if (participant == null) return;
|
|
try {
|
|
final track = await LocalAudioTrack.create(AudioCaptureOptions(deviceId: deviceId));
|
|
for (final pub in List.of(participant.audioTrackPublications)) {
|
|
await participant.removePublishedTrack(pub.sid);
|
|
}
|
|
await participant.publishAudioTrack(track);
|
|
if (isMuted) await participant.setMicrophoneEnabled(false);
|
|
} catch (e) { print('[Audio] error: $e'); }
|
|
}
|
|
|
|
void setRemoteVolume(double volume) {
|
|
if (_room == null) return;
|
|
final mute = volume < 0.05;
|
|
for (final p in _room!.remoteParticipants.values) {
|
|
for (final pub in p.audioTrackPublications) {
|
|
pub.track?.mediaStreamTrack.enabled = !mute;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _startStatsLogging() {
|
|
_stopStatsLogging();
|
|
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
|
|
}
|
|
|
|
void _stopStatsLogging() {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
}
|
|
|
|
Future<void> _logStats() async {
|
|
if (_room == null) return;
|
|
// Basic stats logging...
|
|
print('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
|
|
}
|
|
}
|