Drops dead code across services, models, utils and widgets, including the connection auth service, which had no implementer, and the Live TV DVR provisioning models, which had no caller. Tests that only covered deleted behaviour are removed or trimmed. No behaviour change.
595 lines
19 KiB
Dart
595 lines
19 KiB
Dart
import 'dart:async';
|
|
|
|
import '../../utils/app_logger.dart';
|
|
import '../models/playback_state.dart';
|
|
import '../models/sync_message.dart';
|
|
import '../models/watch_session.dart';
|
|
import '../primitives.dart';
|
|
import 'attached_player.dart';
|
|
import 'clock_sync.dart';
|
|
|
|
/// Callbacks the reconciler surfaces to the provider/UI layer.
|
|
class GuestReconcilerCallbacks {
|
|
/// The host's state names media we don't have loaded — navigate/reload.
|
|
/// Fires on EVERY such state (the heartbeat is the retry channel for
|
|
/// failed switches); the provider's dispatcher dedups.
|
|
final void Function(String ratingKey, String serverId, String? mediaTitle)? onMediaSwitchNeeded;
|
|
|
|
final void Function(ControlMode mode)? onControlModeChanged;
|
|
final void Function(PlaybackPhase phase)? onPhaseChanged;
|
|
final void Function(List<String> waitingOn)? onWaitingOnChanged;
|
|
|
|
/// A hard correction is in flight (drives the syncing pill).
|
|
final void Function(bool correcting)? onCorrectingChanged;
|
|
|
|
/// Another peer caused a transition (drives action toasts).
|
|
final void Function(String peerId, PlaybackActionHint hint)? onRemoteAction;
|
|
|
|
const GuestReconcilerCallbacks({
|
|
this.onMediaSwitchNeeded,
|
|
this.onControlModeChanged,
|
|
this.onPhaseChanged,
|
|
this.onWaitingOnChanged,
|
|
this.onCorrectingChanged,
|
|
this.onRemoteAction,
|
|
});
|
|
}
|
|
|
|
/// Guest-side reconciliation loop: converges the local player onto the
|
|
/// host's authoritative [PlaybackState].
|
|
///
|
|
/// Small drift is corrected invisibly with a brief playback-rate nudge
|
|
/// (skipped while audio passthrough is active — rate changes tear bitstream
|
|
/// output down); large drift hard-seeks with a post-seek settle window so we
|
|
/// never measure mid-seek positions. Local user actions become
|
|
/// [ControlRequest]s in anyone-mode (with a short optimistic window so the
|
|
/// next heartbeat doesn't undo them before the host confirms) and snap back
|
|
/// in host-only mode.
|
|
class GuestPlaybackReconciler {
|
|
GuestPlaybackReconciler({
|
|
required this.myPeerId,
|
|
required this._sendToHost,
|
|
required ClockSync clockSync,
|
|
this._callbacks = const GuestReconcilerCallbacks(),
|
|
int Function()? nowMs,
|
|
}) : _clock = clockSync,
|
|
_nowMs = nowMs ?? watchTogetherSystemNowMs;
|
|
|
|
// Tuning constants.
|
|
static const int tickMs = 500;
|
|
static const int deadbandMs = 350;
|
|
static const int nudgeExitMs = 150;
|
|
static const double nudgeFactor = 0.04;
|
|
static const int hardSeekThresholdMs = 2000;
|
|
static const int hardSeekLeadMs = 250;
|
|
static const int hardSeekCooldownMs = 2000;
|
|
static const int pausedSeekThresholdMs = 500;
|
|
static const int settleExtraMs = 250;
|
|
static const int settleTimeoutMs = 1500;
|
|
static const int optimisticWindowMs = 2000;
|
|
static const int nudgeConfirmMs = 500;
|
|
static const int bufferingStatusRefreshMs = 5000;
|
|
static const int eofClampMs = 200;
|
|
static const int eofToleranceMs = 1000;
|
|
|
|
final String myPeerId;
|
|
final void Function(SyncMessage message) _sendToHost;
|
|
final ClockSync _clock;
|
|
final GuestReconcilerCallbacks _callbacks;
|
|
final int Function() _nowMs;
|
|
|
|
PlaybackState? _latestState;
|
|
int _lastSeq = -1;
|
|
PlaybackPhase? _reportedPhase;
|
|
List<String> _reportedWaitingOn = const [];
|
|
ControlMode? _reportedControlMode;
|
|
|
|
AttachedPlayer? _player;
|
|
final List<StreamSubscription<dynamic>> _playerSubscriptions = [];
|
|
String? _attachedMediaKey;
|
|
bool _localReady = false;
|
|
bool _firstFrameSeen = false;
|
|
bool _startupHoldResolved = true;
|
|
|
|
Timer? _tickTimer;
|
|
bool _backgrounded = false;
|
|
bool _disposed = false;
|
|
|
|
// Correction state.
|
|
bool _settling = false;
|
|
Timer? _settleTimer;
|
|
bool _correcting = false;
|
|
bool _nudging = false;
|
|
bool _nudgeDisabled = false;
|
|
bool _nudgeConfirmed = false;
|
|
Timer? _nudgeConfirmTimer;
|
|
double? _nudgeTargetRate;
|
|
int _lastHardSeekMs = -hardSeekCooldownMs;
|
|
final List<int> _driftSamples = [];
|
|
|
|
// Scheduled group start.
|
|
Timer? _scheduledStartTimer;
|
|
int? _scheduledStartSeq;
|
|
|
|
// Optimistic window after sending a control request.
|
|
int? _optimisticUntilSeq;
|
|
int _optimisticDeadlineMs = 0;
|
|
|
|
// Status reporting.
|
|
PeerStatus? _lastSentStatus;
|
|
Timer? _statusRefreshTimer;
|
|
|
|
PlaybackState? get latestState => _latestState;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Public inputs
|
|
// ---------------------------------------------------------------------
|
|
|
|
void attach(
|
|
AttachedPlayer player, {
|
|
required String ratingKey,
|
|
required String serverId,
|
|
bool hasFirstFrame = false,
|
|
Future<void>? startupHold,
|
|
}) {
|
|
detachPlayer();
|
|
_player = player;
|
|
_attachedMediaKey = PlaybackState.mediaKeyFor(ratingKey: ratingKey, serverId: serverId);
|
|
_firstFrameSeen = hasFirstFrame;
|
|
_startupHoldResolved = startupHold == null;
|
|
|
|
if (startupHold != null) {
|
|
startupHold.then((_) {
|
|
if (_disposed || !identical(_player, player)) return;
|
|
_startupHoldResolved = true;
|
|
_maybeBecomeReady();
|
|
});
|
|
}
|
|
|
|
_playerSubscriptions.add(
|
|
player.loadedSignals.listen((_) {
|
|
if (_settling) {
|
|
_settleTimer?.cancel();
|
|
_settleTimer = Timer(const Duration(milliseconds: settleExtraMs), _endSettle);
|
|
}
|
|
if (!_firstFrameSeen) {
|
|
_firstFrameSeen = true;
|
|
_maybeBecomeReady();
|
|
}
|
|
}),
|
|
);
|
|
_playerSubscriptions.add(
|
|
player.bufferingChanges.listen((_) {
|
|
_sendStatus();
|
|
}),
|
|
);
|
|
_playerSubscriptions.add(player.playingIntents.listen(_onLocalPlayingIntent));
|
|
_playerSubscriptions.add(player.rateIntents.listen(_onLocalRateIntent));
|
|
|
|
_tickTimer = Timer.periodic(const Duration(milliseconds: tickMs), (_) => _onTick());
|
|
if (_firstFrameSeen && _startupHoldResolved) {
|
|
_localReady = true;
|
|
appLogger.d('WatchTogether: Guest player ready for $_attachedMediaKey');
|
|
}
|
|
_sendStatus();
|
|
if (_localReady) _reconcile();
|
|
}
|
|
|
|
void _maybeBecomeReady() {
|
|
if (_localReady || !_firstFrameSeen || !_startupHoldResolved) return;
|
|
_localReady = true;
|
|
appLogger.d('WatchTogether: Guest player ready for $_attachedMediaKey');
|
|
_sendStatus();
|
|
_reconcile();
|
|
}
|
|
|
|
void detachPlayer() {
|
|
for (final subscription in _playerSubscriptions) {
|
|
unawaited(subscription.cancel());
|
|
}
|
|
_playerSubscriptions.clear();
|
|
|
|
// Tell the host we're no longer ready on this media (it re-gates us for
|
|
// the next epoch start instead of waiting on a stale "ready").
|
|
if (_player != null && _attachedMediaKey != null) {
|
|
_lastSentStatus = null;
|
|
_sendToHost(
|
|
SyncMessage.status(
|
|
PeerStatus(mediaKey: _attachedMediaKey!, ready: false, buffering: false, positionMs: 0),
|
|
peerId: myPeerId,
|
|
),
|
|
);
|
|
}
|
|
|
|
_player = null;
|
|
_attachedMediaKey = null;
|
|
_localReady = false;
|
|
_firstFrameSeen = false;
|
|
_startupHoldResolved = true;
|
|
_tickTimer?.cancel();
|
|
_tickTimer = null;
|
|
_settleTimer?.cancel();
|
|
_settleTimer = null;
|
|
_settling = false;
|
|
_nudging = false;
|
|
_nudgeDisabled = false;
|
|
_nudgeConfirmed = false;
|
|
_nudgeTargetRate = null;
|
|
_nudgeConfirmTimer?.cancel();
|
|
_nudgeConfirmTimer = null;
|
|
_scheduledStartTimer?.cancel();
|
|
_scheduledStartTimer = null;
|
|
_scheduledStartSeq = null;
|
|
_statusRefreshTimer?.cancel();
|
|
_statusRefreshTimer = null;
|
|
_driftSamples.clear();
|
|
_setCorrecting(false);
|
|
}
|
|
|
|
/// Latest authoritative state from the host (already host-authenticated).
|
|
void onState(PlaybackState state) {
|
|
if (state.seq <= _lastSeq) return; // Stale or reordered.
|
|
_lastSeq = state.seq;
|
|
_latestState = state;
|
|
|
|
if (state.controlMode != _reportedControlMode) {
|
|
_reportedControlMode = state.controlMode;
|
|
_callbacks.onControlModeChanged?.call(state.controlMode);
|
|
}
|
|
if (state.phase != _reportedPhase) {
|
|
_reportedPhase = state.phase;
|
|
_callbacks.onPhaseChanged?.call(state.phase);
|
|
}
|
|
if (!orderedStringListsEqual(state.waitingOn, _reportedWaitingOn)) {
|
|
_reportedWaitingOn = state.waitingOn;
|
|
_callbacks.onWaitingOnChanged?.call(state.waitingOn);
|
|
}
|
|
if (state.actionHint != null && state.actorPeerId != null && state.actorPeerId != myPeerId) {
|
|
_callbacks.onRemoteAction?.call(state.actorPeerId!, state.actionHint!);
|
|
}
|
|
|
|
// Close the optimistic window only on an explicit transition (the host
|
|
// applied our request — or someone else's superseding one). A plain
|
|
// heartbeat that was already in flight when we sent the request still
|
|
// carries the pre-request anchor and must not yank us back.
|
|
if (_optimisticUntilSeq != null && (state.actorPeerId == myPeerId || state.actionHint != null)) {
|
|
_optimisticUntilSeq = null;
|
|
}
|
|
|
|
// Self-heal: the host thinks it's waiting on us but we're healthy.
|
|
final player = _player;
|
|
if (state.waitingOn.contains(myPeerId) && _localReady && player != null && !player.buffering) {
|
|
_sendStatus(force: true);
|
|
}
|
|
|
|
// Not attached to the host's media (detached, or attached to something
|
|
// else) — hand off to the switch flow on every state so a failed switch
|
|
// retries on the next heartbeat. The provider's dispatcher dedups.
|
|
if (_attachedMediaKey == null || state.mediaKey != _attachedMediaKey) {
|
|
_callbacks.onMediaSwitchNeeded?.call(state.ratingKey, state.serverId, state.mediaTitle);
|
|
return;
|
|
}
|
|
|
|
_reconcile();
|
|
}
|
|
|
|
/// User seek on this guest (the screen already executed it locally).
|
|
void onLocalSeekIntent(Duration position) {
|
|
if (_latestState == null) return;
|
|
if (_canControl) {
|
|
_sendControl(ControlRequest(kind: ControlRequestKind.seek, positionMs: position.inMilliseconds));
|
|
} else {
|
|
_reconcile(); // Snap back.
|
|
}
|
|
}
|
|
|
|
void setBackgrounded(bool value) {
|
|
if (_backgrounded == value) return;
|
|
_backgrounded = value;
|
|
if (!value) _reconcile();
|
|
}
|
|
|
|
/// Host session restarted (fresh join observed) — accept its new counter.
|
|
void resetSequence() {
|
|
_lastSeq = -1;
|
|
}
|
|
|
|
void onReconnected() {
|
|
_sendStatus(force: true);
|
|
}
|
|
|
|
void dispose() {
|
|
_disposed = true;
|
|
detachPlayer();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Local intents
|
|
// ---------------------------------------------------------------------
|
|
|
|
bool get _canControl => _latestState?.controlMode == ControlMode.anyone;
|
|
|
|
void _onLocalPlayingIntent(bool playing) {
|
|
if (_latestState == null) return;
|
|
if (_canControl) {
|
|
_sendControl(
|
|
ControlRequest(
|
|
kind: playing ? ControlRequestKind.play : ControlRequestKind.pause,
|
|
positionMs: _player?.position.inMilliseconds,
|
|
),
|
|
);
|
|
} else {
|
|
_reconcile(); // Snap back to the room state.
|
|
}
|
|
}
|
|
|
|
void _onLocalRateIntent(double rate) {
|
|
if (_latestState == null) return;
|
|
if (_canControl) {
|
|
_sendControl(ControlRequest(kind: ControlRequestKind.rate, rate: rate));
|
|
} else {
|
|
_reconcile();
|
|
}
|
|
}
|
|
|
|
void _sendControl(ControlRequest request) {
|
|
_sendToHost(SyncMessage.control(request, peerId: myPeerId));
|
|
_optimisticUntilSeq = _lastSeq;
|
|
_optimisticDeadlineMs = _nowMs() + optimisticWindowMs;
|
|
}
|
|
|
|
bool get _optimisticWindowActive => _optimisticUntilSeq != null && _nowMs() < _optimisticDeadlineMs;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Reconciliation
|
|
// ---------------------------------------------------------------------
|
|
|
|
void _onTick() {
|
|
_reconcile();
|
|
// Keep the host's view fresh while we're the one buffering.
|
|
final player = _player;
|
|
if (player != null && player.buffering && _statusRefreshTimer == null) {
|
|
_statusRefreshTimer = Timer(const Duration(milliseconds: bufferingStatusRefreshMs), () {
|
|
_statusRefreshTimer = null;
|
|
if (_player?.buffering ?? false) _sendStatus(force: true);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _reconcile() {
|
|
if (_disposed || _backgrounded || _settling) return;
|
|
final state = _latestState;
|
|
final player = _player;
|
|
if (state == null || player == null || !_localReady) return;
|
|
if (state.mediaKey != _attachedMediaKey) return;
|
|
if (_optimisticWindowActive) return;
|
|
|
|
switch (state.phase) {
|
|
case PlaybackPhase.loading:
|
|
// Host is still loading — its anchor is meaningless. Just hold.
|
|
_exitNudgeIfNeeded(state);
|
|
_ensurePaused(player);
|
|
break;
|
|
|
|
case PlaybackPhase.waitingForPeers:
|
|
case PlaybackPhase.paused:
|
|
_exitNudgeIfNeeded(state);
|
|
_ensurePaused(player);
|
|
_alignWhileStopped(player, state);
|
|
break;
|
|
|
|
case PlaybackPhase.playing:
|
|
_reconcilePlaying(player, state);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _reconcilePlaying(AttachedPlayer player, PlaybackState state) {
|
|
final hostNow = _clock.hostNowMs();
|
|
|
|
// Scheduled group start: hold at the anchor, then start on the dot.
|
|
if (state.anchorHostTimeMs > hostNow) {
|
|
if (_scheduledStartSeq != state.seq) {
|
|
_scheduledStartTimer?.cancel();
|
|
_scheduledStartSeq = state.seq;
|
|
final delay = state.anchorHostTimeMs - hostNow;
|
|
appLogger.d('WatchTogether: Group start in ${delay}ms at ${state.anchorPositionMs}ms');
|
|
_scheduledStartTimer = Timer(Duration(milliseconds: delay), () {
|
|
_scheduledStartTimer = null;
|
|
_scheduledStartSeq = null;
|
|
final currentPlayer = _player;
|
|
if (currentPlayer == null || _latestState?.seq != state.seq) return;
|
|
unawaited(currentPlayer.play());
|
|
});
|
|
}
|
|
_exitNudgeIfNeeded(state);
|
|
_ensurePaused(player);
|
|
_alignWhileStopped(player, state);
|
|
return;
|
|
}
|
|
if (_scheduledStartSeq != null && _scheduledStartSeq != state.seq) {
|
|
_scheduledStartTimer?.cancel();
|
|
_scheduledStartTimer = null;
|
|
_scheduledStartSeq = null;
|
|
}
|
|
|
|
final durationMs = player.duration.inMilliseconds;
|
|
var targetMs = state.targetPositionMs(hostNow);
|
|
if (durationMs > 0 && targetMs > durationMs - eofClampMs) {
|
|
targetMs = durationMs - eofClampMs;
|
|
}
|
|
|
|
// Both of us rolled into the credits — don't fight EOF.
|
|
if (player.completed && durationMs > 0 && targetMs >= durationMs - eofToleranceMs) {
|
|
return;
|
|
}
|
|
|
|
if (!player.playing) {
|
|
if (player.completed) {
|
|
// Fell off the end while the room plays on — rejoin via seek+play.
|
|
if (player.seekable && _cooldownElapsed) {
|
|
_hardSeek(player, targetMs, thenPlay: true);
|
|
}
|
|
return;
|
|
}
|
|
unawaited(player.play());
|
|
}
|
|
|
|
// Base rate alignment (never while nudging — the nudge owns the rate).
|
|
if (!_nudging && (player.rate - state.rate).abs() > 0.001) {
|
|
unawaited(player.setRate(state.rate));
|
|
}
|
|
|
|
if (!player.seekable) return; // Live: play/pause/rate only.
|
|
|
|
final drift = _smoothedDrift(player.position.inMilliseconds - targetMs);
|
|
if (drift == null) return;
|
|
|
|
final magnitude = drift.abs();
|
|
if (magnitude <= (_nudging ? nudgeExitMs : deadbandMs)) {
|
|
_exitNudgeIfNeeded(state);
|
|
return;
|
|
}
|
|
|
|
if (magnitude <= deadbandMs) return; // Inside deadband, still nudging.
|
|
|
|
if (magnitude <= hardSeekThresholdMs) {
|
|
_maybeNudge(player, state, drift);
|
|
return;
|
|
}
|
|
|
|
// Hard correction.
|
|
_exitNudgeIfNeeded(state);
|
|
if (!_cooldownElapsed) return;
|
|
_hardSeek(player, targetMs + hardSeekLeadMs);
|
|
}
|
|
|
|
bool get _cooldownElapsed => _nowMs() - _lastHardSeekMs >= hardSeekCooldownMs;
|
|
|
|
void _hardSeek(AttachedPlayer player, int targetMs, {bool thenPlay = false}) {
|
|
_lastHardSeekMs = _nowMs();
|
|
_driftSamples.clear();
|
|
_setCorrecting(true);
|
|
_beginSettle();
|
|
appLogger.d('WatchTogether: Hard sync seek to ${targetMs}ms');
|
|
unawaited(
|
|
player.seek(Duration(milliseconds: targetMs.clamp(0, 1 << 48))).then((didSeek) async {
|
|
if (didSeek && thenPlay) await player.play();
|
|
}),
|
|
);
|
|
}
|
|
|
|
void _alignWhileStopped(AttachedPlayer player, PlaybackState state) {
|
|
if (!player.seekable) return;
|
|
final offBy = (player.position.inMilliseconds - state.anchorPositionMs).abs();
|
|
if (offBy > pausedSeekThresholdMs && _cooldownElapsed) {
|
|
_hardSeek(player, state.anchorPositionMs);
|
|
}
|
|
}
|
|
|
|
void _ensurePaused(AttachedPlayer player) {
|
|
if (player.playing) {
|
|
unawaited(player.pause());
|
|
}
|
|
}
|
|
|
|
void _maybeNudge(AttachedPlayer player, PlaybackState state, int drift) {
|
|
if (_nudgeDisabled || player.passthroughActive) return; // Tolerate up to the seek band.
|
|
|
|
// Ahead of the room → slow down; behind → speed up.
|
|
final factor = drift > 0 ? (1 - nudgeFactor) : (1 + nudgeFactor);
|
|
final targetRate = state.rate * factor;
|
|
if (_nudging && (player.rate - targetRate).abs() < 0.001) return;
|
|
|
|
_nudging = true;
|
|
_nudgeTargetRate = targetRate;
|
|
unawaited(player.setRate(targetRate));
|
|
|
|
// Arm the capability check once per (un-confirmed) nudge episode — a
|
|
// re-issued nudge must not keep pushing the deadline out.
|
|
if (!_nudgeConfirmed && _nudgeConfirmTimer == null) {
|
|
_nudgeConfirmTimer = Timer(const Duration(milliseconds: nudgeConfirmMs), () {
|
|
_nudgeConfirmTimer = null;
|
|
final currentPlayer = _player;
|
|
final expectedRate = _nudgeTargetRate;
|
|
if (currentPlayer == null || !_nudging || expectedRate == null) return;
|
|
if ((currentPlayer.rate - expectedRate).abs() > 0.005) {
|
|
appLogger.w('WatchTogether: Rate nudges not taking effect — disabling for this attachment');
|
|
_nudgeDisabled = true;
|
|
_nudging = false;
|
|
_nudgeTargetRate = null;
|
|
unawaited(currentPlayer.setRate(_latestState?.rate ?? 1.0));
|
|
} else {
|
|
_nudgeConfirmed = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void _exitNudgeIfNeeded(PlaybackState state) {
|
|
if (!_nudging) return;
|
|
_nudging = false;
|
|
_nudgeTargetRate = null;
|
|
final player = _player;
|
|
if (player != null) {
|
|
unawaited(player.setRate(state.rate));
|
|
}
|
|
}
|
|
|
|
int? _smoothedDrift(int rawDrift) {
|
|
_driftSamples.add(rawDrift);
|
|
if (_driftSamples.length > 3) _driftSamples.removeAt(0);
|
|
if (_driftSamples.length < 2) return null; // One sample can be a fluke.
|
|
final sorted = List<int>.of(_driftSamples)..sort();
|
|
return sorted[sorted.length ~/ 2];
|
|
}
|
|
|
|
void _beginSettle() {
|
|
_settling = true;
|
|
_settleTimer?.cancel();
|
|
_settleTimer = Timer(const Duration(milliseconds: settleTimeoutMs), _endSettle);
|
|
}
|
|
|
|
void _endSettle() {
|
|
if (!_settling) return;
|
|
_settling = false;
|
|
_settleTimer?.cancel();
|
|
_settleTimer = null;
|
|
_driftSamples.clear();
|
|
_setCorrecting(false);
|
|
}
|
|
|
|
void _setCorrecting(bool value) {
|
|
if (_correcting == value) return;
|
|
_correcting = value;
|
|
_callbacks.onCorrectingChanged?.call(value);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Status reporting
|
|
// ---------------------------------------------------------------------
|
|
|
|
void _sendStatus({bool force = false}) {
|
|
final mediaKey = _attachedMediaKey;
|
|
if (mediaKey == null || _disposed) return;
|
|
final player = _player;
|
|
final status = PeerStatus(
|
|
mediaKey: mediaKey,
|
|
ready: _localReady,
|
|
buffering: player?.buffering ?? false,
|
|
positionMs: player?.position.inMilliseconds ?? 0,
|
|
rttMs: _clock.minRttMs,
|
|
);
|
|
final last = _lastSentStatus;
|
|
if (!force &&
|
|
last != null &&
|
|
last.mediaKey == status.mediaKey &&
|
|
last.ready == status.ready &&
|
|
last.buffering == status.buffering) {
|
|
return;
|
|
}
|
|
_lastSentStatus = status;
|
|
_sendToHost(SyncMessage.status(status, peerId: myPeerId));
|
|
}
|
|
}
|