feat: watch together
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'watch_session.dart';
|
||||
|
||||
/// Types of sync messages sent over the WebRTC data channel
|
||||
enum SyncMessageType {
|
||||
/// Start playback
|
||||
play,
|
||||
|
||||
/// Pause playback
|
||||
pause,
|
||||
|
||||
/// Seek to position
|
||||
seek,
|
||||
|
||||
/// Buffering state changed
|
||||
buffering,
|
||||
|
||||
/// Periodic position update (for drift correction)
|
||||
positionSync,
|
||||
|
||||
/// Playback rate changed
|
||||
rate,
|
||||
|
||||
/// Participant joined the session
|
||||
join,
|
||||
|
||||
/// Participant left the session
|
||||
leave,
|
||||
|
||||
/// Session configuration (sent by host on join)
|
||||
sessionConfig,
|
||||
|
||||
/// Ping for latency measurement
|
||||
ping,
|
||||
|
||||
/// Pong response
|
||||
pong,
|
||||
|
||||
/// Media switch (host changed content)
|
||||
mediaSwitch,
|
||||
|
||||
/// Host exited the video player
|
||||
hostExitedPlayer,
|
||||
|
||||
/// Player is ready (attached and loaded)
|
||||
playerReady,
|
||||
}
|
||||
|
||||
/// A message sent over the WebRTC data channel for synchronization
|
||||
class SyncMessage {
|
||||
/// Type of this message
|
||||
final SyncMessageType type;
|
||||
|
||||
/// Timestamp when this message was created (Unix ms)
|
||||
final int timestamp;
|
||||
|
||||
/// Position in milliseconds (for seek, positionSync)
|
||||
final int? positionMs;
|
||||
|
||||
/// Buffering state (for buffering message)
|
||||
final bool? bufferingState;
|
||||
|
||||
/// Playback rate (for rate message)
|
||||
final double? rate;
|
||||
|
||||
/// Peer ID of the sender
|
||||
final String? peerId;
|
||||
|
||||
/// Display name of the sender (for join message)
|
||||
final String? displayName;
|
||||
|
||||
/// Whether the sender is the host (for join message)
|
||||
final bool? isHost;
|
||||
|
||||
/// Control mode (for sessionConfig message)
|
||||
final ControlMode? controlMode;
|
||||
|
||||
/// Ping ID for matching pong responses
|
||||
final int? pingId;
|
||||
|
||||
/// Rating key of the media (for mediaSwitch message)
|
||||
final String? ratingKey;
|
||||
|
||||
/// Server ID of the media (for mediaSwitch message)
|
||||
final String? serverId;
|
||||
|
||||
/// Title of the media (for mediaSwitch message)
|
||||
final String? mediaTitle;
|
||||
|
||||
const SyncMessage({
|
||||
required this.type,
|
||||
required this.timestamp,
|
||||
this.positionMs,
|
||||
this.bufferingState,
|
||||
this.rate,
|
||||
this.peerId,
|
||||
this.displayName,
|
||||
this.isHost,
|
||||
this.controlMode,
|
||||
this.pingId,
|
||||
this.ratingKey,
|
||||
this.serverId,
|
||||
this.mediaTitle,
|
||||
});
|
||||
|
||||
/// Create a PLAY message
|
||||
factory SyncMessage.play({String? peerId, Duration? position}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.play,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
peerId: peerId,
|
||||
positionMs: position?.inMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a PAUSE message
|
||||
factory SyncMessage.pause({String? peerId}) {
|
||||
return SyncMessage(type: SyncMessageType.pause, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId);
|
||||
}
|
||||
|
||||
/// Create a SEEK message
|
||||
factory SyncMessage.seek(Duration position, {String? peerId}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.seek,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
positionMs: position.inMilliseconds,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a BUFFERING message
|
||||
factory SyncMessage.buffering(bool isBuffering, {String? peerId}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.buffering,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
bufferingState: isBuffering,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a POSITION_SYNC message
|
||||
factory SyncMessage.positionSync(Duration position, {String? peerId}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.positionSync,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
positionMs: position.inMilliseconds,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a RATE message
|
||||
factory SyncMessage.rate(double playbackRate, {String? peerId}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.rate,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
rate: playbackRate,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a JOIN message
|
||||
factory SyncMessage.join({required String peerId, required String displayName, required bool isHost}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.join,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
peerId: peerId,
|
||||
displayName: displayName,
|
||||
isHost: isHost,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a LEAVE message
|
||||
factory SyncMessage.leave({required String peerId}) {
|
||||
return SyncMessage(type: SyncMessageType.leave, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId);
|
||||
}
|
||||
|
||||
/// Create a SESSION_CONFIG message (sent by host to new guests)
|
||||
factory SyncMessage.sessionConfig({
|
||||
required ControlMode controlMode,
|
||||
required Duration currentPosition,
|
||||
required bool isPlaying,
|
||||
required double playbackRate,
|
||||
String? peerId,
|
||||
}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.sessionConfig,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
controlMode: controlMode,
|
||||
positionMs: currentPosition.inMilliseconds,
|
||||
bufferingState: !isPlaying, // Reuse field: false = playing, true = paused
|
||||
rate: playbackRate,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a PING message
|
||||
factory SyncMessage.ping(int pingId, {String? peerId}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.ping,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
pingId: pingId,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a PONG message
|
||||
factory SyncMessage.pong(int pingId, {String? peerId}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.pong,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
pingId: pingId,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a MEDIA_SWITCH message (sent by host when changing content)
|
||||
factory SyncMessage.mediaSwitch({
|
||||
required String ratingKey,
|
||||
required String serverId,
|
||||
required String mediaTitle,
|
||||
String? peerId,
|
||||
}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.mediaSwitch,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
mediaTitle: mediaTitle,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a HOST_EXITED_PLAYER message (sent by host when exiting video player)
|
||||
factory SyncMessage.hostExitedPlayer({String? peerId}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.hostExitedPlayer,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
peerId: peerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a PLAYER_READY message (sent when player is attached and ready)
|
||||
factory SyncMessage.playerReady({required String peerId, required bool ready}) {
|
||||
return SyncMessage(
|
||||
type: SyncMessageType.playerReady,
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
peerId: peerId,
|
||||
bufferingState: ready, // Reuse bufferingState field for ready status
|
||||
);
|
||||
}
|
||||
|
||||
/// Position as Duration (convenience getter)
|
||||
Duration? get position => positionMs != null ? Duration(milliseconds: positionMs!) : null;
|
||||
|
||||
/// Serialize to JSON string for sending over data channel
|
||||
String toJson() {
|
||||
final map = <String, dynamic>{'t': type.name, 'ts': timestamp};
|
||||
|
||||
if (positionMs != null) map['pos'] = positionMs;
|
||||
if (bufferingState != null) map['buf'] = bufferingState;
|
||||
if (rate != null) map['r'] = rate;
|
||||
if (peerId != null) map['pid'] = peerId;
|
||||
if (displayName != null) map['name'] = displayName;
|
||||
if (isHost != null) map['host'] = isHost;
|
||||
if (controlMode != null) map['ctrl'] = controlMode!.index;
|
||||
if (pingId != null) map['ping'] = pingId;
|
||||
if (ratingKey != null) map['rk'] = ratingKey;
|
||||
if (serverId != null) map['sid'] = serverId;
|
||||
if (mediaTitle != null) map['title'] = mediaTitle;
|
||||
|
||||
return jsonEncode(map);
|
||||
}
|
||||
|
||||
/// Parse from JSON string received from data channel
|
||||
factory SyncMessage.fromJson(String jsonString) {
|
||||
final map = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
final typeString = map['t'] as String;
|
||||
final type = SyncMessageType.values.firstWhere(
|
||||
(t) => t.name == typeString,
|
||||
orElse: () => throw FormatException('Unknown message type: $typeString'),
|
||||
);
|
||||
|
||||
return SyncMessage(
|
||||
type: type,
|
||||
timestamp: map['ts'] as int,
|
||||
positionMs: map['pos'] as int?,
|
||||
bufferingState: map['buf'] as bool?,
|
||||
rate: (map['r'] as num?)?.toDouble(),
|
||||
peerId: map['pid'] as String?,
|
||||
displayName: map['name'] as String?,
|
||||
isHost: map['host'] as bool?,
|
||||
controlMode: map['ctrl'] != null ? ControlMode.values[map['ctrl'] as int] : null,
|
||||
pingId: map['ping'] as int?,
|
||||
ratingKey: map['rk'] as String?,
|
||||
serverId: map['sid'] as String?,
|
||||
mediaTitle: map['title'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SyncMessage(type: $type, timestamp: $timestamp, positionMs: $positionMs, '
|
||||
'bufferingState: $bufferingState, rate: $rate, peerId: $peerId)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/// Session role - whether this device is the host or a guest
|
||||
enum SessionRole { host, guest }
|
||||
|
||||
/// Control mode - who can control playback
|
||||
enum ControlMode {
|
||||
/// Only the host can control playback
|
||||
hostOnly,
|
||||
|
||||
/// Anyone in the session can control playback
|
||||
anyone,
|
||||
}
|
||||
|
||||
/// Current state of the watch together session
|
||||
enum SessionState {
|
||||
/// Not connected to any session
|
||||
disconnected,
|
||||
|
||||
/// Attempting to connect/create session
|
||||
connecting,
|
||||
|
||||
/// Successfully connected to session
|
||||
connected,
|
||||
|
||||
/// Connection error occurred
|
||||
error,
|
||||
}
|
||||
|
||||
/// Represents a participant in a watch together session
|
||||
class Participant {
|
||||
final String peerId;
|
||||
final String displayName;
|
||||
final bool isHost;
|
||||
Duration lastKnownPosition;
|
||||
bool isBuffering;
|
||||
|
||||
Participant({
|
||||
required this.peerId,
|
||||
required this.displayName,
|
||||
required this.isHost,
|
||||
this.lastKnownPosition = Duration.zero,
|
||||
this.isBuffering = false,
|
||||
});
|
||||
|
||||
Participant copyWith({
|
||||
String? peerId,
|
||||
String? displayName,
|
||||
bool? isHost,
|
||||
Duration? lastKnownPosition,
|
||||
bool? isBuffering,
|
||||
}) {
|
||||
return Participant(
|
||||
peerId: peerId ?? this.peerId,
|
||||
displayName: displayName ?? this.displayName,
|
||||
isHost: isHost ?? this.isHost,
|
||||
lastKnownPosition: lastKnownPosition ?? this.lastKnownPosition,
|
||||
isBuffering: isBuffering ?? this.isBuffering,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || other is Participant && runtimeType == other.runtimeType && peerId == other.peerId;
|
||||
|
||||
@override
|
||||
int get hashCode => peerId.hashCode;
|
||||
}
|
||||
|
||||
/// Represents a watch together session
|
||||
class WatchSession {
|
||||
/// Unique identifier for this session (used for joining)
|
||||
final String sessionId;
|
||||
|
||||
/// This device's role in the session
|
||||
final SessionRole role;
|
||||
|
||||
/// Who can control playback
|
||||
final ControlMode controlMode;
|
||||
|
||||
/// Current connection state
|
||||
final SessionState state;
|
||||
|
||||
/// List of participants in the session
|
||||
final List<Participant> participants;
|
||||
|
||||
/// Error message if state is error
|
||||
final String? errorMessage;
|
||||
|
||||
/// Rating key of the media being watched (for validation)
|
||||
final String? mediaRatingKey;
|
||||
|
||||
/// Server ID of the media being watched (same-server requirement)
|
||||
final String? mediaServerId;
|
||||
|
||||
/// Title of the media being watched
|
||||
final String? mediaTitle;
|
||||
|
||||
/// The host's peer ID (used to identify host messages)
|
||||
final String? hostPeerId;
|
||||
|
||||
const WatchSession({
|
||||
required this.sessionId,
|
||||
required this.role,
|
||||
required this.controlMode,
|
||||
required this.state,
|
||||
this.participants = const [],
|
||||
this.errorMessage,
|
||||
this.mediaRatingKey,
|
||||
this.mediaServerId,
|
||||
this.mediaTitle,
|
||||
this.hostPeerId,
|
||||
});
|
||||
|
||||
/// Whether this device is the host
|
||||
bool get isHost => role == SessionRole.host;
|
||||
|
||||
/// Whether the session is currently connected
|
||||
bool get isConnected => state == SessionState.connected;
|
||||
|
||||
/// Number of participants (including self)
|
||||
int get participantCount => participants.length;
|
||||
|
||||
WatchSession copyWith({
|
||||
String? sessionId,
|
||||
SessionRole? role,
|
||||
ControlMode? controlMode,
|
||||
SessionState? state,
|
||||
List<Participant>? participants,
|
||||
String? errorMessage,
|
||||
String? mediaRatingKey,
|
||||
String? mediaServerId,
|
||||
String? mediaTitle,
|
||||
String? hostPeerId,
|
||||
}) {
|
||||
return WatchSession(
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
role: role ?? this.role,
|
||||
controlMode: controlMode ?? this.controlMode,
|
||||
state: state ?? this.state,
|
||||
participants: participants ?? this.participants,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
mediaRatingKey: mediaRatingKey ?? this.mediaRatingKey,
|
||||
mediaServerId: mediaServerId ?? this.mediaServerId,
|
||||
mediaTitle: mediaTitle ?? this.mediaTitle,
|
||||
hostPeerId: hostPeerId ?? this.hostPeerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new session as host
|
||||
factory WatchSession.createAsHost({
|
||||
required String sessionId,
|
||||
required String hostPeerId,
|
||||
required ControlMode controlMode,
|
||||
String? mediaRatingKey,
|
||||
String? mediaServerId,
|
||||
String? mediaTitle,
|
||||
}) {
|
||||
return WatchSession(
|
||||
sessionId: sessionId,
|
||||
role: SessionRole.host,
|
||||
controlMode: controlMode,
|
||||
state: SessionState.connecting,
|
||||
hostPeerId: hostPeerId,
|
||||
mediaRatingKey: mediaRatingKey,
|
||||
mediaServerId: mediaServerId,
|
||||
mediaTitle: mediaTitle,
|
||||
participants: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a session as guest (joining)
|
||||
factory WatchSession.joinAsGuest({required String sessionId}) {
|
||||
return WatchSession(
|
||||
sessionId: sessionId,
|
||||
role: SessionRole.guest,
|
||||
controlMode: ControlMode.hostOnly, // Will be updated when connected
|
||||
state: SessionState.connecting,
|
||||
participants: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../mpv/mpv.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models/sync_message.dart';
|
||||
import '../models/watch_session.dart';
|
||||
import '../services/watch_together_peer_service.dart';
|
||||
import '../services/watch_together_sync_manager.dart';
|
||||
|
||||
/// Callback type for when media switches (for guest navigation)
|
||||
typedef MediaSwitchCallback = void Function(String ratingKey, String serverId, String mediaTitle);
|
||||
|
||||
/// Provider for Watch Together functionality
|
||||
///
|
||||
/// This provider manages:
|
||||
/// - Session creation/joining
|
||||
/// - Peer connections
|
||||
/// - Playback synchronization
|
||||
/// - Participant list
|
||||
/// - Media switching across the session
|
||||
class WatchTogetherProvider with ChangeNotifier {
|
||||
WatchSession? _session;
|
||||
WatchTogetherPeerService? _peerService;
|
||||
WatchTogetherSyncManager? _syncManager;
|
||||
final List<Participant> _participants = [];
|
||||
bool _isSyncing = false;
|
||||
String _displayName = 'User';
|
||||
|
||||
/// Generate a random display name for this session
|
||||
static String _generateDisplayName() {
|
||||
const adjectives = ['Happy', 'Sleepy', 'Sunny', 'Cozy', 'Chill', 'Swift', 'Brave', 'Calm', 'Jolly', 'Lucky'];
|
||||
const nouns = ['Panda', 'Koala', 'Fox', 'Owl', 'Cat', 'Dog', 'Bear', 'Bunny', 'Duck', 'Penguin'];
|
||||
final random = Random();
|
||||
return '${adjectives[random.nextInt(adjectives.length)]} ${nouns[random.nextInt(nouns.length)]}';
|
||||
}
|
||||
|
||||
/// Callback for when host switches media (guests should navigate)
|
||||
/// Used by MainScreen when VideoPlayerScreen is not active
|
||||
MediaSwitchCallback? onMediaSwitched;
|
||||
|
||||
/// Callback for VideoPlayerScreen to handle media switch internally (guest only)
|
||||
/// When set, takes priority over onMediaSwitched for proper navigation context
|
||||
MediaSwitchCallback? onPlayerMediaSwitched;
|
||||
|
||||
/// Callback for when host exits the video player (guests should exit too)
|
||||
VoidCallback? onHostExitedPlayer;
|
||||
|
||||
// Stream subscriptions
|
||||
StreamSubscription<String>? _peerConnectedSubscription;
|
||||
StreamSubscription<String>? _peerDisconnectedSubscription;
|
||||
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||
StreamSubscription<PeerError>? _errorSubscription;
|
||||
|
||||
// Getters
|
||||
bool get isInSession => _session != null && _session!.state != SessionState.disconnected;
|
||||
bool get isHost => _session?.isHost ?? false;
|
||||
bool get isConnected => _session?.isConnected ?? false;
|
||||
bool get isSyncing => _isSyncing;
|
||||
WatchSession? get session => _session;
|
||||
List<Participant> get participants => List.unmodifiable(_participants);
|
||||
int get participantCount => _participants.length;
|
||||
ControlMode get controlMode => _session?.controlMode ?? ControlMode.hostOnly;
|
||||
String? get sessionId => _session?.sessionId;
|
||||
WatchTogetherSyncManager? get syncManager => _syncManager;
|
||||
|
||||
// Current media getters
|
||||
String? get currentMediaRatingKey => _session?.mediaRatingKey;
|
||||
String? get currentMediaServerId => _session?.mediaServerId;
|
||||
String? get currentMediaTitle => _session?.mediaTitle;
|
||||
|
||||
/// Set the display name for this user
|
||||
void setDisplayName(String name) {
|
||||
_displayName = name;
|
||||
}
|
||||
|
||||
/// Create a new watch together session as host
|
||||
Future<String> createSession({
|
||||
required ControlMode controlMode,
|
||||
String? mediaRatingKey,
|
||||
String? mediaServerId,
|
||||
String? mediaTitle,
|
||||
}) async {
|
||||
// Clean up any existing session
|
||||
await leaveSession();
|
||||
|
||||
appLogger.d('WatchTogether: Creating session with control mode: $controlMode');
|
||||
|
||||
_peerService = WatchTogetherPeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
try {
|
||||
final sessionId = await _peerService!.createSession();
|
||||
|
||||
_session = WatchSession.createAsHost(
|
||||
sessionId: sessionId,
|
||||
hostPeerId: _peerService!.myPeerId!,
|
||||
controlMode: controlMode,
|
||||
mediaRatingKey: mediaRatingKey,
|
||||
mediaServerId: mediaServerId,
|
||||
mediaTitle: mediaTitle,
|
||||
).copyWith(state: SessionState.connected);
|
||||
|
||||
// Generate a random display name and add self to participants
|
||||
_displayName = _generateDisplayName();
|
||||
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true));
|
||||
|
||||
_syncManager = WatchTogetherSyncManager(
|
||||
peerService: _peerService!,
|
||||
session: _session!,
|
||||
displayName: _displayName,
|
||||
);
|
||||
|
||||
_syncManager!.onSyncStateChanged = (isSyncing) {
|
||||
_isSyncing = isSyncing;
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d('WatchTogether: Session created: $sessionId');
|
||||
|
||||
return sessionId;
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to create session', error: e);
|
||||
_session = _session?.copyWith(state: SessionState.error, errorMessage: e.toString());
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Join an existing session as guest
|
||||
Future<void> joinSession(String sessionId) async {
|
||||
// Clean up any existing session
|
||||
await leaveSession();
|
||||
|
||||
appLogger.d('WatchTogether: Joining session: $sessionId');
|
||||
|
||||
_peerService = WatchTogetherPeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
_session = WatchSession.joinAsGuest(sessionId: sessionId);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _peerService!.joinSession(sessionId);
|
||||
|
||||
// Session will be fully configured when we receive sessionConfig from host
|
||||
_session = _session!.copyWith(state: SessionState.connected, hostPeerId: 'wt-${sessionId.toUpperCase()}');
|
||||
|
||||
// Generate a random display name for this session
|
||||
_displayName = _generateDisplayName();
|
||||
|
||||
_syncManager = WatchTogetherSyncManager(
|
||||
peerService: _peerService!,
|
||||
session: _session!,
|
||||
displayName: _displayName,
|
||||
);
|
||||
|
||||
_syncManager!.onSessionConfigReceived = (controlMode) {
|
||||
_session = _session!.copyWith(controlMode: controlMode);
|
||||
_syncManager!.updateSession(_session!);
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
_syncManager!.onSyncStateChanged = (isSyncing) {
|
||||
_isSyncing = isSyncing;
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
// Add self to participants
|
||||
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false));
|
||||
|
||||
// Announce join to other participants
|
||||
_syncManager!.announceJoin(_displayName);
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d('WatchTogether: Joined session successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to join session', error: e);
|
||||
_session = _session?.copyWith(state: SessionState.error, errorMessage: e.toString());
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the current session
|
||||
Future<void> leaveSession() async {
|
||||
if (_session == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Leaving session');
|
||||
|
||||
// Announce leave if connected
|
||||
_syncManager?.announceLeave();
|
||||
|
||||
// Clean up subscriptions
|
||||
_peerConnectedSubscription?.cancel();
|
||||
_peerDisconnectedSubscription?.cancel();
|
||||
_messageSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
|
||||
_peerConnectedSubscription = null;
|
||||
_peerDisconnectedSubscription = null;
|
||||
_messageSubscription = null;
|
||||
_errorSubscription = null;
|
||||
|
||||
// Clean up services
|
||||
_syncManager?.dispose();
|
||||
_syncManager = null;
|
||||
|
||||
await _peerService?.disconnect();
|
||||
_peerService?.dispose();
|
||||
_peerService = null;
|
||||
|
||||
_session = null;
|
||||
_participants.clear();
|
||||
_isSyncing = false;
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d('WatchTogether: Session left');
|
||||
}
|
||||
|
||||
/// Attach a player to the sync manager
|
||||
void attachPlayer(Player player) {
|
||||
if (_syncManager == null) {
|
||||
appLogger.w('WatchTogether: Cannot attach player - no sync manager');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize sync manager with existing participants (may have joined before player attached)
|
||||
final peerIds = _participants.map((p) => p.peerId).toList();
|
||||
_syncManager!.initializeParticipants(peerIds);
|
||||
|
||||
_syncManager!.attachPlayer(player);
|
||||
appLogger.d('WatchTogether: Player attached to sync manager');
|
||||
}
|
||||
|
||||
/// Detach the player from the sync manager
|
||||
void detachPlayer() {
|
||||
_syncManager?.detachPlayer();
|
||||
appLogger.d('WatchTogether: Player detached from sync manager');
|
||||
}
|
||||
|
||||
/// Set up listeners for peer service events
|
||||
void _setupPeerServiceListeners() {
|
||||
_peerConnectedSubscription = _peerService!.onPeerConnected.listen((peerId) {
|
||||
appLogger.d('WatchTogether: Peer connected: $peerId');
|
||||
// Peer will announce themselves with a join message
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((peerId) {
|
||||
appLogger.d('WatchTogether: Peer disconnected: $peerId');
|
||||
_participants.removeWhere((p) => p.peerId == peerId);
|
||||
|
||||
// If host disconnected, end session for guests
|
||||
if (!isHost && peerId == _session?.hostPeerId) {
|
||||
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session');
|
||||
// Ensure guests exit the player if host disappears
|
||||
onHostExitedPlayer?.call();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_messageSubscription = _peerService!.onMessageReceived.listen((message) {
|
||||
_handleSyncMessage(message);
|
||||
});
|
||||
|
||||
_errorSubscription = _peerService!.onError.listen((error) {
|
||||
appLogger.e('WatchTogether: Peer error: ${error.message}');
|
||||
|
||||
// Update session state on error
|
||||
if (_session != null && _session!.state == SessionState.connected) {
|
||||
_session = _session!.copyWith(state: SessionState.error, errorMessage: error.message);
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle incoming sync messages for participant management
|
||||
void _handleSyncMessage(SyncMessage message) {
|
||||
switch (message.type) {
|
||||
case SyncMessageType.join:
|
||||
if (message.peerId != null && message.displayName != null) {
|
||||
// Check if participant already exists
|
||||
final existingIndex = _participants.indexWhere((p) => p.peerId == message.peerId);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing participant
|
||||
_participants[existingIndex] = Participant(
|
||||
peerId: message.peerId!,
|
||||
displayName: message.displayName!,
|
||||
isHost: message.isHost ?? false,
|
||||
);
|
||||
} else {
|
||||
// Add new participant
|
||||
_participants.add(
|
||||
Participant(peerId: message.peerId!, displayName: message.displayName!, isHost: message.isHost ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
// If we're the host and this is a guest joining, send our join info back
|
||||
// so they add us to their participants list
|
||||
if (isHost && message.peerId != _peerService?.myPeerId && !(message.isHost ?? false)) {
|
||||
_peerService?.sendTo(
|
||||
message.peerId!,
|
||||
SyncMessage.join(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true),
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.leave:
|
||||
if (message.peerId != null) {
|
||||
_participants.removeWhere((p) => p.peerId == message.peerId);
|
||||
notifyListeners();
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.buffering:
|
||||
if (message.peerId != null) {
|
||||
final index = _participants.indexWhere((p) => p.peerId == message.peerId);
|
||||
if (index >= 0) {
|
||||
_participants[index] = _participants[index].copyWith(isBuffering: message.bufferingState ?? false);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.positionSync:
|
||||
if (message.peerId != null && message.position != null) {
|
||||
final index = _participants.indexWhere((p) => p.peerId == message.peerId);
|
||||
if (index >= 0) {
|
||||
_participants[index] = _participants[index].copyWith(lastKnownPosition: message.position);
|
||||
// Don't notify for position updates - too frequent
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.mediaSwitch:
|
||||
_handleMediaSwitch(message);
|
||||
break;
|
||||
|
||||
case SyncMessageType.hostExitedPlayer:
|
||||
_handleHostExitedPlayer(message);
|
||||
break;
|
||||
|
||||
case SyncMessageType.sessionConfig:
|
||||
_handleSessionConfig(message);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle session config from host (guest only)
|
||||
/// This is handled at provider level so it's processed even before player is attached
|
||||
void _handleSessionConfig(SyncMessage message) {
|
||||
if (isHost) return; // Host doesn't need to process config
|
||||
|
||||
if (message.controlMode != null) {
|
||||
appLogger.d('WatchTogether: Received session config, controlMode: ${message.controlMode}');
|
||||
_session = _session!.copyWith(controlMode: message.controlMode);
|
||||
_syncManager?.updateSession(_session!); // Update sync manager if it exists
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when user seeks locally (to broadcast to peers)
|
||||
void onLocalSeek(Duration position) {
|
||||
_syncManager?.onLocalSeek(position);
|
||||
}
|
||||
|
||||
/// Whether the current user can control playback
|
||||
bool canControl() {
|
||||
if (_session == null) return true; // Not in session, can control
|
||||
if (_session!.controlMode == ControlMode.anyone) return true;
|
||||
return isHost;
|
||||
}
|
||||
|
||||
/// Set the current media (host only) and broadcast to guests
|
||||
///
|
||||
/// Call this when the host starts playing new content.
|
||||
/// Guests will receive a media switch notification and should navigate.
|
||||
void setCurrentMedia({required String ratingKey, required String serverId, required String mediaTitle}) {
|
||||
if (!isHost || _session == null || _peerService == null) {
|
||||
appLogger.w('WatchTogether: Cannot set media - not host or not in session');
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('WatchTogether: Host setting current media: $mediaTitle (ratingKey: $ratingKey)');
|
||||
|
||||
// Update session with new media info
|
||||
_session = _session!.copyWith(mediaRatingKey: ratingKey, mediaServerId: serverId, mediaTitle: mediaTitle);
|
||||
|
||||
// Broadcast media switch to all guests
|
||||
_peerService!.broadcast(
|
||||
SyncMessage.mediaSwitch(
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
mediaTitle: mediaTitle,
|
||||
peerId: _peerService!.myPeerId,
|
||||
),
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Handle media switch message from host (guest only)
|
||||
void _handleMediaSwitch(SyncMessage message) {
|
||||
if (isHost) return; // Host doesn't need to handle their own switch
|
||||
|
||||
// Skip if already playing this media (prevents duplicate navigation from duplicate messages)
|
||||
if (_session?.mediaRatingKey == message.ratingKey) {
|
||||
appLogger.d('WatchTogether: Ignoring duplicate media switch for ${message.ratingKey}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.ratingKey == null || message.serverId == null || message.mediaTitle == null) {
|
||||
appLogger.w('WatchTogether: Received incomplete media switch message');
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}');
|
||||
|
||||
// Update local session state
|
||||
_session = _session?.copyWith(
|
||||
mediaRatingKey: message.ratingKey,
|
||||
mediaServerId: message.serverId,
|
||||
mediaTitle: message.mediaTitle,
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// If player handler is set (VideoPlayerScreen is active), use that for proper navigation context
|
||||
if (onPlayerMediaSwitched != null) {
|
||||
onPlayerMediaSwitched!(message.ratingKey!, message.serverId!, message.mediaTitle!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, trigger app-level navigation callback (MainScreen handles it)
|
||||
onMediaSwitched?.call(message.ratingKey!, message.serverId!, message.mediaTitle!);
|
||||
}
|
||||
|
||||
/// Notify guests that host is exiting the video player
|
||||
///
|
||||
/// Call this from video player dispose when host exits.
|
||||
void notifyHostExitedPlayer() {
|
||||
if (!isHost || _session == null || _peerService == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('WatchTogether: Host exiting player, notifying guests');
|
||||
|
||||
_peerService!.broadcast(SyncMessage.hostExitedPlayer(peerId: _peerService!.myPeerId));
|
||||
}
|
||||
|
||||
/// Handle host exited player message (guest only)
|
||||
void _handleHostExitedPlayer(SyncMessage message) {
|
||||
if (isHost) return; // Host doesn't need to handle their own exit
|
||||
|
||||
appLogger.d('WatchTogether: Host exited player, callback set: ${onHostExitedPlayer != null}');
|
||||
|
||||
// Trigger callback for the app to navigate guest out of player
|
||||
if (onHostExitedPlayer != null) {
|
||||
onHostExitedPlayer!.call();
|
||||
} else {
|
||||
appLogger.w('WatchTogether: onHostExitedPlayer callback not set!');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
leaveSession();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../models/watch_session.dart';
|
||||
import '../providers/watch_together_provider.dart';
|
||||
import '../widgets/join_session_dialog.dart';
|
||||
|
||||
/// Main screen for Watch Together functionality
|
||||
///
|
||||
/// Allows users to:
|
||||
/// - Create a new watch session
|
||||
/// - Join an existing session
|
||||
/// - View active session info and participants
|
||||
/// - Leave/end session
|
||||
class WatchTogetherScreen extends StatelessWidget {
|
||||
const WatchTogetherScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<WatchTogetherProvider>(
|
||||
builder: (context, watchTogether, child) {
|
||||
// Non-hosts must use "Leave Session" button - disable back navigation and hide button
|
||||
final canGoBack = watchTogether.isHost || !watchTogether.isInSession;
|
||||
return PopScope(
|
||||
canPop: canGoBack,
|
||||
child: FocusedScrollScaffold(
|
||||
title: const Text('Watch Together'),
|
||||
automaticallyImplyLeading: canGoBack,
|
||||
slivers: watchTogether.isInSession
|
||||
? _buildActiveSessionSlivers(watchTogether)
|
||||
: [SliverFillRemaining(hasScrollBody: false, child: _NotInSessionView(watchTogether: watchTogether))],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildActiveSessionSlivers(WatchTogetherProvider watchTogether) {
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: _ActiveSessionContent(watchTogether: watchTogether),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// View shown when not in a session
|
||||
class _NotInSessionView extends StatefulWidget {
|
||||
final WatchTogetherProvider watchTogether;
|
||||
|
||||
const _NotInSessionView({required this.watchTogether});
|
||||
|
||||
@override
|
||||
State<_NotInSessionView> createState() => _NotInSessionViewState();
|
||||
}
|
||||
|
||||
class _NotInSessionViewState extends State<_NotInSessionView> {
|
||||
bool _isCreating = false;
|
||||
bool _isJoining = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Symbols.group_rounded, size: 80, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 24),
|
||||
Text('Watch Together', style: theme.textTheme.headlineMedium, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Watch content in sync with friends and family',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _isCreating || _isJoining ? null : _createSession,
|
||||
icon: _isCreating
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Symbols.add_rounded),
|
||||
label: Text(_isCreating ? 'Creating...' : 'Create Session'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isCreating || _isJoining ? null : _joinSession,
|
||||
icon: _isJoining
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Symbols.group_add_rounded),
|
||||
label: Text(_isJoining ? 'Joining...' : 'Join Session'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createSession() async {
|
||||
final controlMode = await _showControlModeDialog();
|
||||
if (controlMode == null || !mounted) return;
|
||||
|
||||
setState(() => _isCreating = true);
|
||||
|
||||
try {
|
||||
await widget.watchTogether.createSession(controlMode: controlMode);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create session', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to create session: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCreating = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<ControlMode?> _showControlModeDialog() {
|
||||
return showDialog<ControlMode>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Control Mode'),
|
||||
content: const Text('Who can control playback?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, ControlMode.hostOnly), child: const Text('Host Only')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, ControlMode.anyone), child: const Text('Anyone')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _joinSession() async {
|
||||
final sessionId = await showJoinSessionDialog(context);
|
||||
if (sessionId == null || !mounted) return;
|
||||
|
||||
setState(() => _isJoining = true);
|
||||
|
||||
try {
|
||||
await widget.watchTogether.joinSession(sessionId);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to join session', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to join session: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isJoining = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content shown when in an active session (without scroll wrapper)
|
||||
class _ActiveSessionContent extends StatelessWidget {
|
||||
final WatchTogetherProvider watchTogether;
|
||||
|
||||
const _ActiveSessionContent({required this.watchTogether});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final session = watchTogether.session!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Session Info Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
watchTogether.isHost ? Symbols.star_rounded : Symbols.group_rounded,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
watchTogether.isHost ? 'Hosting Session' : 'In Session',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'Code: ${session.sessionId}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
session.controlMode == ControlMode.anyone
|
||||
? Symbols.groups_rounded
|
||||
: Symbols.admin_panel_settings_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
session.controlMode == ControlMode.anyone
|
||||
? 'Anyone can control playback'
|
||||
: 'Host controls playback',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Participants Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.people_rounded, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text('Participants (${watchTogether.participantCount})', style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...watchTogether.participants.map(
|
||||
(participant) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
participant.isHost ? Symbols.star_rounded : Symbols.person_rounded,
|
||||
size: 20,
|
||||
color: participant.isHost ? Colors.amber : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(participant.displayName, style: theme.textTheme.bodyMedium),
|
||||
if (participant.isHost) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Host',
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: Colors.amber.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (participant.isBuffering) ...[
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Leave/End Session Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _leaveSession(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(color: theme.colorScheme.error),
|
||||
),
|
||||
icon: Icon(watchTogether.isHost ? Symbols.close_rounded : Symbols.logout_rounded),
|
||||
label: Text(watchTogether.isHost ? 'End Session' : 'Leave Session'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _leaveSession(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(watchTogether.isHost ? 'End Session?' : 'Leave Session?'),
|
||||
content: Text(
|
||||
watchTogether.isHost
|
||||
? 'This will end the session for all participants.'
|
||||
: 'You will be removed from the session.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
|
||||
child: Text(watchTogether.isHost ? 'End' : 'Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await watchTogether.leaveSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:peerdart/peerdart.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models/sync_message.dart';
|
||||
|
||||
/// Error types that can occur in the peer service
|
||||
enum PeerErrorType { connectionFailed, peerDisconnected, dataChannelError, serverError, timeout, unknown }
|
||||
|
||||
/// Represents an error in the peer service
|
||||
class PeerError {
|
||||
final PeerErrorType type;
|
||||
final String message;
|
||||
final dynamic originalError;
|
||||
|
||||
const PeerError({required this.type, required this.message, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => 'PeerError($type): $message';
|
||||
}
|
||||
|
||||
/// Service for managing WebRTC peer connections using PeerJS
|
||||
///
|
||||
/// This service handles:
|
||||
/// - Creating sessions (as host)
|
||||
/// - Joining sessions (as guest)
|
||||
/// - Sending/receiving sync messages over data channels
|
||||
/// - Managing multiple peer connections
|
||||
class WatchTogetherPeerService {
|
||||
Peer? _peer;
|
||||
final Map<String, DataConnection> _connections = {};
|
||||
String? _sessionId;
|
||||
String? _myPeerId;
|
||||
bool _isHost = false;
|
||||
|
||||
// Stream controllers for events
|
||||
final _peerConnectedController = StreamController<String>.broadcast();
|
||||
final _peerDisconnectedController = StreamController<String>.broadcast();
|
||||
final _messageReceivedController = StreamController<SyncMessage>.broadcast();
|
||||
final _errorController = StreamController<PeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<bool>.broadcast();
|
||||
|
||||
// Reconnection state
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 3;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
/// Stream of peer IDs when a new peer connects
|
||||
Stream<String> get onPeerConnected => _peerConnectedController.stream;
|
||||
|
||||
/// Stream of peer IDs when a peer disconnects
|
||||
Stream<String> get onPeerDisconnected => _peerDisconnectedController.stream;
|
||||
|
||||
/// Stream of sync messages received from peers
|
||||
Stream<SyncMessage> get onMessageReceived => _messageReceivedController.stream;
|
||||
|
||||
/// Stream of errors
|
||||
Stream<PeerError> get onError => _errorController.stream;
|
||||
|
||||
/// Stream of connection state changes (true = connected, false = disconnected)
|
||||
Stream<bool> get onConnectionStateChanged => _connectionStateController.stream;
|
||||
|
||||
/// Current session ID (null if not in a session)
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
/// This peer's ID
|
||||
String? get myPeerId => _myPeerId;
|
||||
|
||||
/// Whether this peer is the host
|
||||
bool get isHost => _isHost;
|
||||
|
||||
/// Whether currently connected to a session
|
||||
bool get isConnected => _peer != null && _connections.isNotEmpty;
|
||||
|
||||
/// List of connected peer IDs
|
||||
List<String> get connectedPeers => _connections.keys.toList();
|
||||
|
||||
/// Generate a short, readable session ID
|
||||
String _generateSessionId() {
|
||||
// Use first 8 characters of UUID for readability
|
||||
return const Uuid().v4().substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
/// Create a new session as host
|
||||
///
|
||||
/// Returns the session ID that others can use to join
|
||||
Future<String> createSession() async {
|
||||
if (_peer != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_isHost = true;
|
||||
_sessionId = _generateSessionId();
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
// Create peer with session ID as the peer ID so guests can connect directly
|
||||
final completer = Completer<String>();
|
||||
|
||||
try {
|
||||
_peer = Peer(id: 'wt-$_sessionId');
|
||||
|
||||
_peer!.on('open').listen((id) {
|
||||
_myPeerId = id as String;
|
||||
appLogger.d('WatchTogether: Host peer opened with ID: $_myPeerId');
|
||||
_connectionStateController.add(true);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(_sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
_peer!.on('connection').listen((conn) {
|
||||
final dataConn = conn as DataConnection;
|
||||
_handleNewConnection(dataConn);
|
||||
});
|
||||
|
||||
_peer!.on('error').listen((error) {
|
||||
appLogger.e('WatchTogether: Peer error', error: error);
|
||||
_errorController.add(
|
||||
PeerError(type: PeerErrorType.serverError, message: error.toString(), originalError: error),
|
||||
);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
});
|
||||
|
||||
_peer!.on('disconnected').listen((_) {
|
||||
appLogger.w('WatchTogether: Peer disconnected from server');
|
||||
_handleDisconnectedFromServer();
|
||||
});
|
||||
|
||||
_peer!.on('close').listen((_) {
|
||||
appLogger.d('WatchTogether: Peer closed');
|
||||
_connectionStateController.add(false);
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to create peer', error: e);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout after 10 seconds
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Join an existing session as guest
|
||||
Future<void> joinSession(String sessionId) async {
|
||||
if (_peer != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_isHost = false;
|
||||
_sessionId = sessionId.toUpperCase();
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
try {
|
||||
// Create a random peer ID for guest
|
||||
_peer = Peer();
|
||||
|
||||
_peer!.on('open').listen((id) {
|
||||
_myPeerId = id as String;
|
||||
appLogger.d('WatchTogether: Guest peer opened with ID: $_myPeerId');
|
||||
|
||||
// Connect to the host
|
||||
final hostPeerId = 'wt-$_sessionId';
|
||||
appLogger.d('WatchTogether: Connecting to host: $hostPeerId');
|
||||
|
||||
final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true));
|
||||
_handleNewConnection(conn, isOutgoing: true, completer: completer);
|
||||
});
|
||||
|
||||
_peer!.on('error').listen((error) {
|
||||
appLogger.e('WatchTogether: Peer error', error: error);
|
||||
_errorController.add(
|
||||
PeerError(
|
||||
type: PeerErrorType.connectionFailed,
|
||||
message: 'Failed to connect to session: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
});
|
||||
|
||||
_peer!.on('disconnected').listen((_) {
|
||||
appLogger.w('WatchTogether: Peer disconnected from server');
|
||||
_handleDisconnectedFromServer();
|
||||
});
|
||||
|
||||
_peer!.on('close').listen((_) {
|
||||
appLogger.d('WatchTogether: Peer closed');
|
||||
_connectionStateController.add(false);
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to create peer for joining', error: e);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout after 15 seconds
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out joining session');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle a new data connection (incoming or outgoing)
|
||||
void _handleNewConnection(DataConnection conn, {bool isOutgoing = false, Completer<void>? completer}) {
|
||||
final peerId = conn.peer;
|
||||
appLogger.d('WatchTogether: New connection ${isOutgoing ? "to" : "from"}: $peerId');
|
||||
|
||||
conn.on('open').listen((_) {
|
||||
appLogger.d('WatchTogether: Data channel opened with: $peerId');
|
||||
_connections[peerId] = conn;
|
||||
_peerConnectedController.add(peerId);
|
||||
_connectionStateController.add(true);
|
||||
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
});
|
||||
|
||||
conn.on('data').listen((data) {
|
||||
try {
|
||||
final message = SyncMessage.fromJson(data as String);
|
||||
appLogger.d('WatchTogether: Received message: ${message.type} from $peerId');
|
||||
_messageReceivedController.add(message);
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to parse message', error: e);
|
||||
}
|
||||
});
|
||||
|
||||
conn.on('close').listen((_) {
|
||||
appLogger.d('WatchTogether: Connection closed with: $peerId');
|
||||
_connections.remove(peerId);
|
||||
_peerDisconnectedController.add(peerId);
|
||||
|
||||
if (_connections.isEmpty) {
|
||||
_connectionStateController.add(false);
|
||||
}
|
||||
});
|
||||
|
||||
conn.on('error').listen((error) {
|
||||
appLogger.e('WatchTogether: Connection error with $peerId', error: error);
|
||||
_errorController.add(
|
||||
PeerError(
|
||||
type: PeerErrorType.dataChannelError,
|
||||
message: 'Connection error with peer: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle disconnection from PeerJS server
|
||||
void _handleDisconnectedFromServer() {
|
||||
if (_reconnectAttempts < _maxReconnectAttempts) {
|
||||
_reconnectAttempts++;
|
||||
final delay = Duration(seconds: _reconnectAttempts * 2); // Exponential backoff
|
||||
|
||||
appLogger.d(
|
||||
'WatchTogether: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s',
|
||||
);
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
_peer?.reconnect();
|
||||
});
|
||||
} else {
|
||||
appLogger.e('WatchTogether: Max reconnect attempts reached');
|
||||
_errorController.add(
|
||||
const PeerError(
|
||||
type: PeerErrorType.connectionFailed,
|
||||
message: 'Lost connection to server after multiple reconnect attempts',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast a message to all connected peers
|
||||
void broadcast(SyncMessage message) {
|
||||
final json = message.toJson();
|
||||
appLogger.d('WatchTogether: Broadcasting ${message.type} to ${_connections.length} peers');
|
||||
|
||||
for (final conn in _connections.values) {
|
||||
try {
|
||||
conn.send(json);
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to send to ${conn.peer}', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message to a specific peer
|
||||
void sendTo(String peerId, SyncMessage message) {
|
||||
final conn = _connections[peerId];
|
||||
if (conn != null) {
|
||||
try {
|
||||
conn.send(message.toJson());
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to send to $peerId', error: e);
|
||||
}
|
||||
} else {
|
||||
appLogger.w('WatchTogether: No connection to peer: $peerId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from all peers and close the session
|
||||
Future<void> disconnect() async {
|
||||
appLogger.d('WatchTogether: Disconnecting...');
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
|
||||
// Close all data connections
|
||||
for (final conn in _connections.values) {
|
||||
conn.close();
|
||||
}
|
||||
_connections.clear();
|
||||
|
||||
// Destroy the peer
|
||||
_peer?.dispose();
|
||||
_peer = null;
|
||||
|
||||
_sessionId = null;
|
||||
_myPeerId = null;
|
||||
_isHost = false;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
_connectionStateController.add(false);
|
||||
}
|
||||
|
||||
/// Dispose all resources
|
||||
void dispose() {
|
||||
disconnect();
|
||||
|
||||
_peerConnectedController.close();
|
||||
_peerDisconnectedController.close();
|
||||
_messageReceivedController.close();
|
||||
_errorController.close();
|
||||
_connectionStateController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../mpv/mpv.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models/sync_message.dart';
|
||||
import '../models/watch_session.dart';
|
||||
import 'watch_together_peer_service.dart';
|
||||
|
||||
/// Callback type for when session configuration is received
|
||||
typedef SessionConfigCallback = void Function(ControlMode controlMode);
|
||||
|
||||
/// Callback type for when sync state changes
|
||||
typedef SyncStateCallback = void Function(bool isSyncing);
|
||||
|
||||
/// Manages playback synchronization between peers
|
||||
///
|
||||
/// This class:
|
||||
/// - Subscribes to player stream events
|
||||
/// - Broadcasts local playback actions to peers
|
||||
/// - Applies remote playback actions to the local player
|
||||
/// - Handles drift correction
|
||||
class WatchTogetherSyncManager {
|
||||
final WatchTogetherPeerService _peerService;
|
||||
final String displayName;
|
||||
WatchSession _session;
|
||||
|
||||
Player? _player;
|
||||
bool _isRemoteAction = false; // Flag to prevent echo
|
||||
bool _isSyncing = false; // Flag for UI indicator during sync
|
||||
|
||||
// Stream subscriptions
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<bool>? _bufferingSubscription;
|
||||
StreamSubscription<double>? _rateSubscription;
|
||||
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||
|
||||
// Position sync timer (host broadcasts position periodically)
|
||||
Timer? _positionSyncTimer;
|
||||
|
||||
// Drift correction constants
|
||||
static const Duration maxAllowedDrift = Duration(seconds: 2);
|
||||
static const Duration positionSyncInterval = Duration(seconds: 5);
|
||||
static const Duration excessiveDrift = Duration(seconds: 10);
|
||||
|
||||
// Track last known state to avoid duplicate broadcasts
|
||||
bool _lastKnownPlaying = false;
|
||||
double _lastKnownRate = 1.0;
|
||||
|
||||
// Track if we were playing before a peer started buffering (for auto-resume)
|
||||
bool _wasPlayingBeforeBuffering = false;
|
||||
|
||||
// Position to seek to when auto-resuming deferred playback
|
||||
Duration? _pendingPlayPosition;
|
||||
|
||||
// Whether we've announced our player as ready (first buffering: false)
|
||||
bool _hasAnnouncedReady = false;
|
||||
|
||||
// Callbacks
|
||||
SessionConfigCallback? onSessionConfigReceived;
|
||||
SyncStateCallback? onSyncStateChanged;
|
||||
|
||||
/// Participants' buffering states (peer ID -> isBuffering)
|
||||
final Map<String, bool> _participantBuffering = {};
|
||||
|
||||
/// Participants' ready states (peer ID -> hasPlayerReady)
|
||||
final Map<String, bool> _participantReady = {};
|
||||
|
||||
WatchTogetherSyncManager({
|
||||
required WatchTogetherPeerService peerService,
|
||||
required WatchSession session,
|
||||
required this.displayName,
|
||||
}) : _peerService = peerService,
|
||||
_session = session;
|
||||
|
||||
/// Update the session (e.g., when control mode changes)
|
||||
void updateSession(WatchSession session) {
|
||||
_session = session;
|
||||
appLogger.d('WatchTogether: Sync manager session updated, controlMode: ${session.controlMode}');
|
||||
}
|
||||
|
||||
/// Whether this manager has a player attached
|
||||
bool get hasPlayer => _player != null;
|
||||
|
||||
/// Whether any participant (including local player) is currently buffering
|
||||
bool get isAnyBuffering => _participantBuffering.values.any((b) => b) || (_player?.state.buffering ?? false);
|
||||
|
||||
/// Whether all participants have their player attached and ready
|
||||
/// Returns true if:
|
||||
/// - We're alone (no other peers tracked)
|
||||
/// - All tracked participants have sent playerReady(true)
|
||||
bool get isAllReady {
|
||||
// If no other peers are tracked, we're ready (solo viewing)
|
||||
if (_participantBuffering.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
// All peers in _participantBuffering must also be in _participantReady with value true
|
||||
for (final peerId in _participantBuffering.keys) {
|
||||
final ready = _participantReady[peerId];
|
||||
if (ready != true) {
|
||||
return false; // Peer hasn't sent ready yet or sent ready(false)
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether sync is in progress (for UI indicator)
|
||||
bool get isSyncing => _isSyncing;
|
||||
|
||||
/// Attach a player to sync
|
||||
void attachPlayer(Player player) {
|
||||
if (_player != null) {
|
||||
detachPlayer();
|
||||
}
|
||||
|
||||
_player = player;
|
||||
_lastKnownPlaying = player.state.playing;
|
||||
_lastKnownRate = player.state.rate;
|
||||
|
||||
_setupPlayerSubscriptions();
|
||||
_setupMessageSubscription();
|
||||
|
||||
// If host, start broadcasting position periodically
|
||||
if (_session.isHost) {
|
||||
_startPositionSync();
|
||||
// Note: sessionConfig is sent after video loads (with correct position) in buffering handler
|
||||
}
|
||||
|
||||
// Note: playerReady will be announced when video loads (first buffering: false)
|
||||
|
||||
appLogger.d('WatchTogether: Player attached, isHost: ${_session.isHost}');
|
||||
}
|
||||
|
||||
/// Initialize participant tracking from existing session participants
|
||||
/// Call this before attachPlayer() to ensure we know about participants who joined before
|
||||
void initializeParticipants(List<String> peerIds) {
|
||||
for (final peerId in peerIds) {
|
||||
if (peerId != _peerService.myPeerId) {
|
||||
// Assume they're buffering until they tell us otherwise
|
||||
_participantBuffering[peerId] = true;
|
||||
// They're not ready until they send playerReady
|
||||
_participantReady[peerId] = false;
|
||||
}
|
||||
}
|
||||
final otherCount = peerIds.where((id) => id != _peerService.myPeerId).length;
|
||||
appLogger.d('WatchTogether: Initialized $otherCount existing participants');
|
||||
}
|
||||
|
||||
/// Detach the player and stop sync
|
||||
void detachPlayer() {
|
||||
// Announce that our player is no longer ready
|
||||
if (_peerService.myPeerId != null) {
|
||||
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: false));
|
||||
_participantReady[_peerService.myPeerId!] = false;
|
||||
}
|
||||
_hasAnnouncedReady = false;
|
||||
|
||||
_playingSubscription?.cancel();
|
||||
_positionSubscription?.cancel();
|
||||
_bufferingSubscription?.cancel();
|
||||
_rateSubscription?.cancel();
|
||||
_messageSubscription?.cancel();
|
||||
_positionSyncTimer?.cancel();
|
||||
|
||||
_playingSubscription = null;
|
||||
_positionSubscription = null;
|
||||
_bufferingSubscription = null;
|
||||
_rateSubscription = null;
|
||||
_messageSubscription = null;
|
||||
_positionSyncTimer = null;
|
||||
|
||||
_player = null;
|
||||
appLogger.d('WatchTogether: Player detached');
|
||||
}
|
||||
|
||||
/// Set up subscriptions to player streams
|
||||
void _setupPlayerSubscriptions() {
|
||||
// Listen to playing state changes
|
||||
_playingSubscription = _player!.streams.playing.listen((isPlaying) async {
|
||||
if (_isRemoteAction) return; // Skip if this change was caused by a remote action
|
||||
|
||||
if (isPlaying != _lastKnownPlaying) {
|
||||
_lastKnownPlaying = isPlaying;
|
||||
|
||||
// If trying to play, check if all peers are ready first
|
||||
if (isPlaying && (!isAllReady || isAnyBuffering)) {
|
||||
appLogger.d('WatchTogether: Deferring local play - waiting for all peers to be ready');
|
||||
_wasPlayingBeforeBuffering = true;
|
||||
_pendingPlayPosition = _player?.state.position;
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await _player!.pause();
|
||||
_lastKnownPlaying = false;
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
// Still broadcast so peers know we want to play
|
||||
_broadcastPlayPause(true);
|
||||
return;
|
||||
}
|
||||
|
||||
_broadcastPlayPause(isPlaying);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to buffering state changes
|
||||
_bufferingSubscription = _player!.streams.buffering.listen((isBuffering) {
|
||||
if (_isRemoteAction) return;
|
||||
|
||||
// Announce ready when we stop buffering for the first time (video loaded)
|
||||
if (!isBuffering && !_hasAnnouncedReady) {
|
||||
_hasAnnouncedReady = true;
|
||||
_participantReady[_peerService.myPeerId!] = true;
|
||||
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true));
|
||||
appLogger.d('WatchTogether: Video loaded, announcing player ready');
|
||||
|
||||
// If host, send session config now that video is loaded with correct position
|
||||
if (_session.isHost) {
|
||||
_sendSessionConfig();
|
||||
}
|
||||
}
|
||||
|
||||
_peerService.broadcast(SyncMessage.buffering(isBuffering, peerId: _peerService.myPeerId));
|
||||
});
|
||||
|
||||
// Listen to rate changes
|
||||
_rateSubscription = _player!.streams.rate.listen((rate) {
|
||||
if (_isRemoteAction) return;
|
||||
|
||||
if (rate != _lastKnownRate) {
|
||||
_lastKnownRate = rate;
|
||||
if (_canControl()) {
|
||||
_peerService.broadcast(SyncMessage.rate(rate, peerId: _peerService.myPeerId));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set up subscription to incoming sync messages
|
||||
void _setupMessageSubscription() {
|
||||
_messageSubscription = _peerService.onMessageReceived.listen(_handleMessage);
|
||||
}
|
||||
|
||||
/// Start periodic position sync (host only)
|
||||
void _startPositionSync() {
|
||||
_positionSyncTimer?.cancel();
|
||||
_positionSyncTimer = Timer.periodic(positionSyncInterval, (_) {
|
||||
if (_player != null && _session.isHost) {
|
||||
_peerService.broadcast(SyncMessage.positionSync(_player!.state.position, peerId: _peerService.myPeerId));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if this peer can control playback
|
||||
bool _canControl() {
|
||||
if (_session.controlMode == ControlMode.anyone) {
|
||||
return true;
|
||||
}
|
||||
return _session.isHost;
|
||||
}
|
||||
|
||||
/// Broadcast play/pause state
|
||||
void _broadcastPlayPause(bool isPlaying) {
|
||||
if (!_canControl()) {
|
||||
appLogger.d('WatchTogether: Cannot control playback in hostOnly mode');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
final position = _player?.state.position ?? Duration.zero;
|
||||
_peerService.broadcast(SyncMessage.play(peerId: _peerService.myPeerId, position: position));
|
||||
} else {
|
||||
_peerService.broadcast(SyncMessage.pause(peerId: _peerService.myPeerId));
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when user seeks locally
|
||||
void onLocalSeek(Duration position) {
|
||||
if (!_canControl()) {
|
||||
appLogger.d('WatchTogether: Cannot control playback in hostOnly mode');
|
||||
return;
|
||||
}
|
||||
|
||||
_peerService.broadcast(SyncMessage.seek(position, peerId: _peerService.myPeerId));
|
||||
}
|
||||
|
||||
/// Handle incoming sync messages
|
||||
void _handleMessage(SyncMessage message) async {
|
||||
// Ignore our own messages
|
||||
if (message.peerId == _peerService.myPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// HOST RELAY: In "anyone" mode, host rebroadcasts control commands from guests
|
||||
// This is needed because guests only connect to host (star topology), not to each other
|
||||
if (_session.isHost && _session.controlMode == ControlMode.anyone) {
|
||||
final isControlMessage =
|
||||
message.type == SyncMessageType.play ||
|
||||
message.type == SyncMessageType.pause ||
|
||||
message.type == SyncMessageType.seek ||
|
||||
message.type == SyncMessageType.rate;
|
||||
|
||||
if (isControlMessage) {
|
||||
appLogger.d('WatchTogether: Host relaying ${message.type} from ${message.peerId}');
|
||||
_peerService.broadcast(message);
|
||||
}
|
||||
}
|
||||
|
||||
// In hostOnly mode, only process messages from host (unless it's join/leave/sessionConfig)
|
||||
if (_session.controlMode == ControlMode.hostOnly && !_session.isHost) {
|
||||
final isHostMessage = message.peerId == _session.hostPeerId;
|
||||
final isMetaMessage =
|
||||
message.type == SyncMessageType.join ||
|
||||
message.type == SyncMessageType.leave ||
|
||||
message.type == SyncMessageType.sessionConfig ||
|
||||
message.type == SyncMessageType.buffering ||
|
||||
message.type == SyncMessageType.ping ||
|
||||
message.type == SyncMessageType.pong ||
|
||||
message.type == SyncMessageType.mediaSwitch;
|
||||
|
||||
if (!isHostMessage && !isMetaMessage) {
|
||||
appLogger.d('WatchTogether: Ignoring non-host message in hostOnly mode');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case SyncMessageType.play:
|
||||
await _applyRemotePlay(position: message.position);
|
||||
break;
|
||||
|
||||
case SyncMessageType.pause:
|
||||
_wasPlayingBeforeBuffering = false; // User intentionally paused, don't auto-resume
|
||||
await _applyRemotePause();
|
||||
break;
|
||||
|
||||
case SyncMessageType.seek:
|
||||
if (message.position != null) {
|
||||
await _applyRemoteSeek(message.position!);
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.buffering:
|
||||
if (message.peerId != null && message.bufferingState != null) {
|
||||
_participantBuffering[message.peerId!] = message.bufferingState!;
|
||||
|
||||
// Auto-pause when any peer starts buffering
|
||||
if (isAnyBuffering && _player!.state.playing) {
|
||||
_wasPlayingBeforeBuffering = true;
|
||||
appLogger.d('WatchTogether: Peer buffering, pausing playback');
|
||||
await _applyRemotePause();
|
||||
}
|
||||
// Auto-resume when all peers stop buffering AND all ready (if we were playing before)
|
||||
else if (isAllReady && !isAnyBuffering && !_player!.state.playing && _wasPlayingBeforeBuffering) {
|
||||
_wasPlayingBeforeBuffering = false;
|
||||
appLogger.d('WatchTogether: All peers done buffering, resuming playback');
|
||||
await _applyRemotePlay(position: _pendingPlayPosition);
|
||||
_pendingPlayPosition = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.positionSync:
|
||||
if (message.position != null) {
|
||||
_checkAndCorrectDrift(message.position!, message.timestamp);
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.rate:
|
||||
if (message.rate != null) {
|
||||
await _applyRemoteRate(message.rate!);
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.join:
|
||||
_handlePeerJoin(message);
|
||||
break;
|
||||
|
||||
case SyncMessageType.leave:
|
||||
if (message.peerId != null) {
|
||||
_participantBuffering.remove(message.peerId);
|
||||
_participantReady.remove(message.peerId);
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.sessionConfig:
|
||||
await _handleSessionConfig(message);
|
||||
break;
|
||||
|
||||
case SyncMessageType.ping:
|
||||
if (message.pingId != null) {
|
||||
_peerService.broadcast(SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId));
|
||||
}
|
||||
break;
|
||||
|
||||
case SyncMessageType.pong:
|
||||
// Could be used for latency measurement
|
||||
break;
|
||||
|
||||
case SyncMessageType.mediaSwitch:
|
||||
// Handled at the provider level, not in sync manager
|
||||
break;
|
||||
|
||||
case SyncMessageType.hostExitedPlayer:
|
||||
// Handled at the provider level, not in sync manager
|
||||
break;
|
||||
|
||||
case SyncMessageType.playerReady:
|
||||
if (message.peerId != null) {
|
||||
_participantReady[message.peerId!] = message.bufferingState ?? false;
|
||||
appLogger.d('WatchTogether: Peer ${message.peerId} player ready: ${message.bufferingState}');
|
||||
|
||||
// If we were waiting to play and all are now ready, start playback
|
||||
if (isAllReady && !isAnyBuffering && _wasPlayingBeforeBuffering) {
|
||||
_wasPlayingBeforeBuffering = false;
|
||||
appLogger.d('WatchTogether: All players ready, starting playback');
|
||||
await _applyRemotePlay(position: _pendingPlayPosition);
|
||||
_pendingPlayPosition = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply remote play command
|
||||
Future<void> _applyRemotePlay({Duration? position}) async {
|
||||
if (_player == null) return;
|
||||
|
||||
// If not all participants have their player ready, defer play
|
||||
if (!isAllReady) {
|
||||
appLogger.d('WatchTogether: Deferring play - waiting for all players to be ready');
|
||||
_wasPlayingBeforeBuffering = true;
|
||||
if (position != null) _pendingPlayPosition = position;
|
||||
return; // Will trigger when all players send playerReady
|
||||
}
|
||||
|
||||
// If anyone is buffering, defer play until all ready
|
||||
if (isAnyBuffering) {
|
||||
appLogger.d('WatchTogether: Deferring play - waiting for all peers to stop buffering');
|
||||
_wasPlayingBeforeBuffering = true;
|
||||
if (position != null) _pendingPlayPosition = position;
|
||||
return; // Auto-resume will trigger when buffering clears
|
||||
}
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote PLAY${position != null ? ' at ${position.inSeconds}s' : ''}');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
// Seek to position first if provided
|
||||
if (position != null) {
|
||||
await _player!.seek(position);
|
||||
}
|
||||
await _player!.play();
|
||||
_lastKnownPlaying = true;
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote PLAY', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply remote pause command
|
||||
Future<void> _applyRemotePause() async {
|
||||
if (_player == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote PAUSE');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await _player!.pause();
|
||||
_lastKnownPlaying = false;
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote PAUSE', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply remote seek command
|
||||
Future<void> _applyRemoteSeek(Duration position) async {
|
||||
if (_player == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote SEEK to ${position.inSeconds}s');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await _player!.seek(position);
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote SEEK', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply remote rate change
|
||||
Future<void> _applyRemoteRate(double rate) async {
|
||||
if (_player == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote RATE: $rate');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await _player!.setRate(rate);
|
||||
_lastKnownRate = rate;
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote RATE', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check and correct position drift
|
||||
void _checkAndCorrectDrift(Duration remotePosition, int remoteTimestamp) {
|
||||
if (_player == null || _session.isHost) return;
|
||||
|
||||
final localPosition = _player!.state.position;
|
||||
final networkDelay = DateTime.now().millisecondsSinceEpoch - remoteTimestamp;
|
||||
|
||||
// Estimate where remote should be now, accounting for playback time elapsed
|
||||
Duration estimatedRemoteNow = remotePosition;
|
||||
if (_player!.state.playing && networkDelay > 0) {
|
||||
// If playing, account for time elapsed during network transit
|
||||
// Multiply by rate in case playback speed is different
|
||||
estimatedRemoteNow = remotePosition + Duration(milliseconds: (networkDelay * _player!.state.rate).round());
|
||||
}
|
||||
|
||||
final drift = (localPosition - estimatedRemoteNow).abs();
|
||||
|
||||
if (drift > excessiveDrift) {
|
||||
// Excessive drift - force sync with indicator
|
||||
appLogger.w('WatchTogether: Excessive drift (${drift.inSeconds}s), force syncing');
|
||||
_setSyncing(true);
|
||||
_applyRemoteSeek(estimatedRemoteNow);
|
||||
Future.delayed(const Duration(milliseconds: 500), () => _setSyncing(false));
|
||||
} else if (drift > maxAllowedDrift) {
|
||||
// Normal drift correction
|
||||
appLogger.d('WatchTogether: Drift correction (${drift.inMilliseconds}ms)');
|
||||
_setSyncing(true);
|
||||
_applyRemoteSeek(estimatedRemoteNow);
|
||||
Future.delayed(const Duration(milliseconds: 300), () => _setSyncing(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle peer join message
|
||||
void _handlePeerJoin(SyncMessage message) {
|
||||
appLogger.d('WatchTogether: Peer joined: ${message.displayName}');
|
||||
|
||||
// Assume new peer is buffering and not ready until they explicitly signal
|
||||
if (message.peerId != null) {
|
||||
_participantBuffering[message.peerId!] = true;
|
||||
_participantReady[message.peerId!] = false;
|
||||
}
|
||||
|
||||
// If we're the host, send session config AND our own join info to the new peer
|
||||
if (_session.isHost && message.peerId != null) {
|
||||
// Only send config if our video is loaded (we know the correct position)
|
||||
if (_hasAnnouncedReady) {
|
||||
_sendSessionConfig(toPeerId: message.peerId);
|
||||
}
|
||||
// Send host's join info so guest adds host to their participants list
|
||||
_peerService.sendTo(
|
||||
message.peerId!,
|
||||
SyncMessage.join(peerId: _peerService.myPeerId!, displayName: displayName, isHost: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle session config from host
|
||||
Future<void> _handleSessionConfig(SyncMessage message) async {
|
||||
if (_session.isHost) return; // Host doesn't need to process config
|
||||
|
||||
appLogger.d('WatchTogether: Received session config');
|
||||
|
||||
// Update control mode
|
||||
if (message.controlMode != null) {
|
||||
onSessionConfigReceived?.call(message.controlMode!);
|
||||
}
|
||||
|
||||
// Sync to host's current state
|
||||
if (_player == null) return;
|
||||
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
// Always seek to host's position first
|
||||
if (message.position != null) {
|
||||
await _player!.seek(message.position!);
|
||||
}
|
||||
|
||||
// Match playback rate
|
||||
if (message.rate != null) {
|
||||
await _player!.setRate(message.rate!);
|
||||
_lastKnownRate = message.rate!;
|
||||
}
|
||||
|
||||
// Match play/pause state (bufferingState is reused: false = playing)
|
||||
if (message.bufferingState == false) {
|
||||
// Host was playing - defer play until all ready
|
||||
_wasPlayingBeforeBuffering = true;
|
||||
_pendingPlayPosition = message.position;
|
||||
// Check if we can play now
|
||||
if (isAllReady && !isAnyBuffering) {
|
||||
await _applyRemotePlay(position: message.position);
|
||||
} else {
|
||||
appLogger.d('WatchTogether: Host was playing but deferring until all ready');
|
||||
}
|
||||
} else {
|
||||
await _player!.pause();
|
||||
_lastKnownPlaying = false;
|
||||
}
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during session config apply', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set syncing state and notify listeners
|
||||
void _setSyncing(bool isSyncing) {
|
||||
if (_isSyncing != isSyncing) {
|
||||
_isSyncing = isSyncing;
|
||||
onSyncStateChanged?.call(isSyncing);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send join announcement to all peers
|
||||
void announceJoin(String displayName) {
|
||||
_peerService.broadcast(
|
||||
SyncMessage.join(peerId: _peerService.myPeerId!, displayName: displayName, isHost: _session.isHost),
|
||||
);
|
||||
}
|
||||
|
||||
/// Send leave announcement to all peers
|
||||
void announceLeave() {
|
||||
if (_peerService.myPeerId != null) {
|
||||
_peerService.broadcast(SyncMessage.leave(peerId: _peerService.myPeerId!));
|
||||
}
|
||||
}
|
||||
|
||||
/// Send current session configuration to peers
|
||||
void _sendSessionConfig({String? toPeerId}) {
|
||||
if (!_session.isHost || _peerService.myPeerId == null) return;
|
||||
|
||||
final position = _player?.state.position ?? Duration.zero;
|
||||
final isPlaying = _player?.state.playing ?? false;
|
||||
final rate = _player?.state.rate ?? 1.0;
|
||||
|
||||
final configMessage = SyncMessage.sessionConfig(
|
||||
controlMode: _session.controlMode,
|
||||
currentPosition: position,
|
||||
isPlaying: isPlaying,
|
||||
playbackRate: rate,
|
||||
peerId: _peerService.myPeerId,
|
||||
);
|
||||
|
||||
if (toPeerId != null) {
|
||||
_peerService.sendTo(toPeerId, configMessage);
|
||||
} else {
|
||||
_peerService.broadcast(configMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
detachPlayer();
|
||||
_participantBuffering.clear();
|
||||
_participantReady.clear();
|
||||
_hasAnnouncedReady = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Models
|
||||
export 'models/watch_session.dart';
|
||||
export 'models/sync_message.dart';
|
||||
|
||||
// Services
|
||||
export 'services/watch_together_peer_service.dart';
|
||||
export 'services/watch_together_sync_manager.dart';
|
||||
|
||||
// Providers
|
||||
export 'providers/watch_together_provider.dart';
|
||||
|
||||
// Screens
|
||||
export 'screens/watch_together_screen.dart';
|
||||
|
||||
// Widgets
|
||||
export 'widgets/session_invite_dialog.dart';
|
||||
export 'widgets/join_session_dialog.dart';
|
||||
export 'widgets/watch_together_overlay.dart';
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
/// Dialog for joining a watch together session
|
||||
class JoinSessionDialog extends StatefulWidget {
|
||||
const JoinSessionDialog({super.key});
|
||||
|
||||
@override
|
||||
State<JoinSessionDialog> createState() => _JoinSessionDialogState();
|
||||
}
|
||||
|
||||
class _JoinSessionDialogState extends State<JoinSessionDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _sessionIdController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sessionIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.group_add, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text('Join Watch Session', style: theme.textTheme.titleLarge)),
|
||||
IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Session ID input
|
||||
TextFormField(
|
||||
controller: _sessionIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Session Code',
|
||||
hintText: 'Enter 8-character code',
|
||||
prefixIcon: const Icon(Symbols.tag),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: _pasteFromClipboard,
|
||||
icon: const Icon(Symbols.content_paste),
|
||||
tooltip: 'Paste from clipboard',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
maxLength: 8,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
|
||||
UpperCaseTextFormatter(),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a session code';
|
||||
}
|
||||
if (value.length != 8) {
|
||||
return 'Session code must be 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) => _join(),
|
||||
autofocus: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Instructions
|
||||
Text(
|
||||
'Enter the session code shared by the host to join their watch session.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Join button
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _join,
|
||||
icon: _isLoading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Symbols.group_add),
|
||||
label: Text(_isLoading ? 'Joining...' : 'Join Session'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data?.text != null) {
|
||||
// Clean the pasted text - extract alphanumeric characters and take first 8
|
||||
final cleaned = data!.text!.replaceAll(RegExp(r'[^A-Za-z0-9]'), '').toUpperCase();
|
||||
if (cleaned.isNotEmpty) {
|
||||
_sessionIdController.text = cleaned.substring(0, cleaned.length.clamp(0, 8));
|
||||
_sessionIdController.selection = TextSelection.collapsed(offset: _sessionIdController.text.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _join() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final sessionId = _sessionIdController.text.toUpperCase();
|
||||
Navigator.of(context).pop(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text formatter to convert input to uppercase
|
||||
class UpperCaseTextFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
return newValue.copyWith(text: newValue.text.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the join session dialog
|
||||
///
|
||||
/// Returns the session ID if user confirms, null if cancelled
|
||||
Future<String?> showJoinSessionDialog(BuildContext context) {
|
||||
return showDialog<String>(context: context, builder: (context) => const JoinSessionDialog());
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// Dialog for sharing a watch together session with others
|
||||
class SessionInviteDialog extends StatelessWidget {
|
||||
final String sessionId;
|
||||
final int participantCount;
|
||||
|
||||
const SessionInviteDialog({super.key, required this.sessionId, required this.participantCount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.group, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Watch Together', style: theme.textTheme.titleLarge),
|
||||
Text(
|
||||
'$participantCount ${participantCount == 1 ? 'participant' : 'participants'}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// QR Code
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
child: QrImageView(
|
||||
data: sessionId,
|
||||
version: QrVersions.auto,
|
||||
size: 180,
|
||||
backgroundColor: Colors.white,
|
||||
eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Session ID with copy button
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Session Code',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
sessionId,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _copyToClipboard(context),
|
||||
icon: const Icon(Symbols.content_copy),
|
||||
tooltip: 'Copy code',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Instructions
|
||||
Text(
|
||||
'Share this code with others to let them join your watch session.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Share button
|
||||
FilledButton.icon(
|
||||
onPressed: () => _share(context),
|
||||
icon: const Icon(Symbols.share),
|
||||
label: const Text('Share'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _copyToClipboard(BuildContext context) {
|
||||
Clipboard.setData(ClipboardData(text: sessionId));
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Session code copied to clipboard')));
|
||||
}
|
||||
|
||||
void _share(BuildContext context) {
|
||||
final text = 'Join my Watch Together session!\n\nSession Code: $sessionId';
|
||||
Share.share(text, subject: 'Watch Together Invite');
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the session invite dialog
|
||||
Future<void> showSessionInviteDialog(BuildContext context, {required String sessionId, required int participantCount}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => SessionInviteDialog(sessionId: sessionId, participantCount: participantCount),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/watch_session.dart';
|
||||
import '../providers/watch_together_provider.dart';
|
||||
import 'session_invite_dialog.dart';
|
||||
|
||||
/// Overlay shown on the video player when in a watch together session
|
||||
class WatchTogetherOverlay extends StatelessWidget {
|
||||
/// Callback when the user wants to leave the session
|
||||
final VoidCallback? onLeaveSession;
|
||||
|
||||
const WatchTogetherOverlay({super.key, this.onLeaveSession});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<WatchTogetherProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (!provider.isInSession) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: _SessionIndicator(
|
||||
participantCount: provider.participantCount,
|
||||
isHost: provider.isHost,
|
||||
isSyncing: provider.isSyncing,
|
||||
controlMode: provider.controlMode,
|
||||
sessionId: provider.sessionId,
|
||||
onTap: () => _showSessionMenu(context, provider),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small indicator showing session status
|
||||
class _SessionIndicator extends StatelessWidget {
|
||||
final int participantCount;
|
||||
final bool isHost;
|
||||
final bool isSyncing;
|
||||
final ControlMode controlMode;
|
||||
final String? sessionId;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SessionIndicator({
|
||||
required this.participantCount,
|
||||
required this.isHost,
|
||||
required this.isSyncing,
|
||||
required this.controlMode,
|
||||
required this.sessionId,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Sync indicator or group icon
|
||||
if (isSyncing)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
else
|
||||
Icon(Symbols.group, size: 18, color: isHost ? theme.colorScheme.primary : Colors.white),
|
||||
|
||||
const SizedBox(width: 6),
|
||||
|
||||
// Participant count
|
||||
Text(
|
||||
'$participantCount',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
|
||||
// Host badge
|
||||
if (isHost) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(4)),
|
||||
child: const Text(
|
||||
'HOST',
|
||||
style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet showing session details and actions
|
||||
class _SessionMenuSheet extends StatelessWidget {
|
||||
final WatchTogetherProvider provider;
|
||||
final VoidCallback? onLeaveSession;
|
||||
|
||||
const _SessionMenuSheet({required this.provider, this.onLeaveSession});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.group, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Watch Together', style: theme.textTheme.titleMedium),
|
||||
Text(
|
||||
provider.isHost ? 'You are the host' : 'Watching with others',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Control mode badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
provider.controlMode == ControlMode.hostOnly ? 'Host controls' : 'Anyone controls',
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Participants list
|
||||
Text('Participants', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
...provider.participants.map(
|
||||
(p) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: p.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
p.isHost ? Symbols.star : Symbols.person,
|
||||
color: p.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(p.displayName),
|
||||
subtitle: p.isHost ? const Text('Host') : null,
|
||||
trailing: p.isBuffering
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: null,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Actions
|
||||
if (provider.isHost && provider.sessionId != null)
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.share),
|
||||
title: const Text('Invite others'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showSessionInviteDialog(
|
||||
context,
|
||||
sessionId: provider.sessionId!,
|
||||
participantCount: provider.participantCount,
|
||||
);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Symbols.logout, color: theme.colorScheme.error),
|
||||
title: Text(
|
||||
provider.isHost ? 'End session' : 'Leave session',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_confirmLeave(context);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmLeave(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(provider.isHost ? 'End Session?' : 'Leave Session?'),
|
||||
content: Text(
|
||||
provider.isHost
|
||||
? 'This will end the watch session for all participants.'
|
||||
: 'You will be disconnected from the watch session.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
provider.leaveSession();
|
||||
onLeaveSession?.call();
|
||||
},
|
||||
child: Text(provider.isHost ? 'End Session' : 'Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact sync indicator for showing during drift correction
|
||||
class SyncingIndicator extends StatelessWidget {
|
||||
const SyncingIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<WatchTogetherProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (!provider.isSyncing) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
bottom: 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('Syncing...', style: TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user