feat: watch together
This commit is contained in:
@@ -23,6 +23,7 @@ import 'providers/playback_state_provider.dart';
|
||||
import 'providers/download_provider.dart';
|
||||
import 'providers/offline_mode_provider.dart';
|
||||
import 'providers/offline_watch_provider.dart';
|
||||
import 'watch_together/watch_together.dart';
|
||||
import 'services/multi_server_manager.dart';
|
||||
import 'services/offline_watch_sync_service.dart';
|
||||
import 'services/data_aggregation_service.dart';
|
||||
@@ -205,6 +206,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true),
|
||||
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true),
|
||||
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
||||
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
@@ -344,6 +346,9 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
// Migrate from single-server to multi-server if needed
|
||||
await registry.migrateFromSingleServer();
|
||||
|
||||
// Refresh servers from API to get updated connection info (IPs may change)
|
||||
await registry.refreshServersFromApi();
|
||||
|
||||
// Load all configured servers
|
||||
final servers = await registry.getServers();
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import '../utils/layout_constants.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'auth_screen.dart';
|
||||
import 'libraries/state_messages.dart';
|
||||
import '../watch_together/watch_together.dart';
|
||||
|
||||
class DiscoverScreen extends StatefulWidget {
|
||||
final VoidCallback? onBecameVisible;
|
||||
@@ -72,8 +73,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// Hero and app bar focus
|
||||
late FocusNode _heroFocusNode;
|
||||
late FocusNode _refreshButtonFocusNode;
|
||||
late FocusNode _watchTogetherButtonFocusNode;
|
||||
late FocusNode _userButtonFocusNode;
|
||||
bool _isRefreshFocused = false;
|
||||
bool _isWatchTogetherFocused = false;
|
||||
bool _isUserFocused = false;
|
||||
|
||||
/// Get the correct PlexClient for an item's server
|
||||
@@ -155,8 +158,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_indicatorAnimationController = AnimationController(vsync: this, duration: _heroAutoScrollDuration);
|
||||
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
|
||||
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
|
||||
_watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button');
|
||||
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
|
||||
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
|
||||
_watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange);
|
||||
_userButtonFocusNode.addListener(_onUserFocusChange);
|
||||
_loadContent();
|
||||
_startAutoScroll();
|
||||
@@ -170,6 +175,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _onWatchTogetherFocusChange() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onUserFocusChange() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -242,9 +255,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to user button
|
||||
// RIGHT: Move to watch together button
|
||||
if (key.isRightKey) {
|
||||
_userButtonFocusNode.requestFocus();
|
||||
_watchTogetherButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -262,6 +275,46 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events for the watch together button in app bar
|
||||
KeyEventResult _handleWatchTogetherKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Return to hero
|
||||
if (key.isDownKey) {
|
||||
_heroFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to refresh button
|
||||
if (key.isLeftKey) {
|
||||
_refreshButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to user button
|
||||
if (key.isRightKey) {
|
||||
_userButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP: Block at boundary
|
||||
if (key.isUpKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT: Navigate to Watch Together screen
|
||||
if (key.isSelectKey) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen()));
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events for the user button in app bar
|
||||
KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
@@ -276,9 +329,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to refresh button
|
||||
// LEFT: Move to watch together button
|
||||
if (key.isLeftKey) {
|
||||
_refreshButtonFocusNode.requestFocus();
|
||||
_watchTogetherButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -306,6 +359,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_heroFocusNode.dispose();
|
||||
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
|
||||
_refreshButtonFocusNode.dispose();
|
||||
_watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange);
|
||||
_watchTogetherButtonFocusNode.dispose();
|
||||
_userButtonFocusNode.removeListener(_onUserFocusChange);
|
||||
_userButtonFocusNode.dispose();
|
||||
super.dispose();
|
||||
@@ -748,6 +803,60 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: IconButton(icon: const AppIcon(Symbols.refresh_rounded, fill: 1), onPressed: _loadContent),
|
||||
),
|
||||
),
|
||||
// Watch Together button
|
||||
Consumer<WatchTogetherProvider>(
|
||||
builder: (context, watchTogether, child) {
|
||||
return Focus(
|
||||
focusNode: _watchTogetherButtonFocusNode,
|
||||
onKeyEvent: _handleWatchTogetherKeyEvent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isWatchTogetherFocused
|
||||
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: AppIcon(
|
||||
Symbols.group_rounded,
|
||||
fill: watchTogether.isInSession ? 1 : 0,
|
||||
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
|
||||
),
|
||||
tooltip: 'Watch Together',
|
||||
),
|
||||
// Badge showing participant count when in session
|
||||
if (watchTogether.isInSession && watchTogether.participantCount > 1)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${watchTogether.participantCount}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<UserProfileProvider>(
|
||||
builder: (context, userProvider, child) {
|
||||
return Focus(
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../main.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../navigation/navigation_tabs.dart';
|
||||
@@ -23,6 +24,8 @@ import 'libraries/libraries_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'downloads/downloads_screen.dart';
|
||||
import 'settings/settings_screen.dart';
|
||||
import 'video_player_screen.dart';
|
||||
import '../watch_together/watch_together.dart';
|
||||
|
||||
/// Provides access to the main screen's focus control.
|
||||
class MainScreenFocusScope extends InheritedWidget {
|
||||
@@ -100,6 +103,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
|
||||
_screens = _buildScreens(_isOffline);
|
||||
|
||||
// Set up Watch Together callbacks immediately (must be synchronous to catch early messages)
|
||||
if (!_isOffline) {
|
||||
_setupWatchTogetherCallback();
|
||||
}
|
||||
|
||||
// Set up data invalidation callback for profile switching (skip in offline mode)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!_isOffline) {
|
||||
@@ -118,6 +126,66 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
});
|
||||
}
|
||||
|
||||
/// Set up the Watch Together navigation callback for guests
|
||||
void _setupWatchTogetherCallback() {
|
||||
try {
|
||||
final watchTogether = context.read<WatchTogetherProvider>();
|
||||
watchTogether.onMediaSwitched = (ratingKey, serverId, mediaTitle) async {
|
||||
appLogger.d('WatchTogether: Media switch received - navigating to $mediaTitle');
|
||||
await _navigateToWatchTogetherMedia(ratingKey, serverId);
|
||||
};
|
||||
watchTogether.onHostExitedPlayer = () {
|
||||
appLogger.d('WatchTogether: Host exited player - exiting player for guest');
|
||||
// Use rootNavigator to ensure we pop the video player even if nested
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
bool isVideoPlayerOnTop = false;
|
||||
navigator.popUntil((route) {
|
||||
if (route.isCurrent) {
|
||||
isVideoPlayerOnTop = route.settings.name == kVideoPlayerRouteName;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (isVideoPlayerOnTop && navigator.canPop()) {
|
||||
navigator.pop();
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
appLogger.w('Could not set up Watch Together callback', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to media when host switches content in Watch Together session
|
||||
Future<void> _navigateToWatchTogetherMedia(String ratingKey, String serverId) async {
|
||||
if (!mounted) return; // Check before any context usage
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(serverId);
|
||||
|
||||
if (client == null) {
|
||||
appLogger.w('WatchTogether: Server $serverId not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the metadata for the new media
|
||||
final metadata = await client.getMetadataWithImages(ratingKey);
|
||||
|
||||
if (metadata == null || !mounted) return;
|
||||
|
||||
// Use push to preserve WatchTogetherScreen in navigation stack
|
||||
// VideoPlayerScreen handles its own replacement via onPlayerMediaSwitched
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: kVideoPlayerRouteName),
|
||||
builder: (_) => VideoPlayerScreen(metadata: metadata),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('WatchTogether: Failed to navigate to media', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
@@ -120,42 +120,55 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
/// Build action buttons row (play, shuffle, download, mark watched)
|
||||
Widget _buildActionButtons(PlexMetadata metadata) {
|
||||
final playButtonLabel = _getPlayButtonLabel(metadata);
|
||||
final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20);
|
||||
|
||||
Future<void> onPlayPressed() async {
|
||||
// For TV shows, play the OnDeck episode if available
|
||||
// Otherwise, play the first episode of the first season
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}');
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
metadata: _onDeckEpisode!,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _loadFullMetadata,
|
||||
);
|
||||
} else {
|
||||
// No on deck episode, fetch first episode of first season
|
||||
await _playFirstEpisode();
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Playing: ${metadata.title}');
|
||||
// For movies or episodes, play directly
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
metadata: metadata,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _loadFullMetadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
child: FilledButton(
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onPressed: () async {
|
||||
// For TV shows, play the OnDeck episode if available
|
||||
// Otherwise, play the first episode of the first season
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}');
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
metadata: _onDeckEpisode!,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _loadFullMetadata,
|
||||
);
|
||||
} else {
|
||||
// No on deck episode, fetch first episode of first season
|
||||
await _playFirstEpisode();
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Playing: ${metadata.title}');
|
||||
// For movies or episodes, play directly
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
metadata: metadata,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _loadFullMetadata,
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20),
|
||||
label: Text(_getPlayButtonLabel(metadata), style: const TextStyle(fontSize: 16)),
|
||||
onPressed: onPlayPressed,
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16)),
|
||||
child: playButtonLabel.isNotEmpty
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
playButtonIcon,
|
||||
const SizedBox(width: 8),
|
||||
Text(playButtonLabel, style: const TextStyle(fontSize: 16)),
|
||||
],
|
||||
)
|
||||
: playButtonIcon,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../models/plex_metadata.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../services/episode_navigation_service.dart';
|
||||
import '../services/media_controls_manager.dart';
|
||||
@@ -37,6 +38,7 @@ import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../watch_together/providers/watch_together_provider.dart';
|
||||
|
||||
class VideoPlayerScreen extends StatefulWidget {
|
||||
final PlexMetadata metadata;
|
||||
@@ -61,6 +63,13 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindingObserver {
|
||||
// Track the currently active video to guard against duplicate navigation
|
||||
static String? _activeRatingKey;
|
||||
static int? _activeMediaIndex;
|
||||
|
||||
static String? get activeRatingKey => _activeRatingKey;
|
||||
static int? get activeMediaIndex => _activeMediaIndex;
|
||||
|
||||
Player? player;
|
||||
bool _isPlayerInitialized = false;
|
||||
PlexMetadata? _nextEpisode;
|
||||
@@ -89,6 +98,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
VideoFilterManager? _videoFilterManager;
|
||||
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
|
||||
|
||||
// Watch Together provider reference (stored early to use in dispose)
|
||||
WatchTogetherProvider? _watchTogetherProvider;
|
||||
|
||||
/// Get the correct PlexClient for this metadata's server
|
||||
PlexClient _getClientForMetadata(BuildContext context) {
|
||||
return context.getClientForServer(widget.metadata.serverId!);
|
||||
@@ -100,6 +112,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_activeRatingKey = widget.metadata.ratingKey;
|
||||
_activeMediaIndex = widget.selectedMediaIndex;
|
||||
|
||||
appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}');
|
||||
if (widget.preferredAudioTrack != null) {
|
||||
appLogger.d(
|
||||
@@ -628,6 +643,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
? Duration(milliseconds: widget.metadata.viewOffset!)
|
||||
: null;
|
||||
await player!.open(Media(result.videoUrl!, start: resumePosition));
|
||||
|
||||
// Attach player to Watch Together session for sync (if in session)
|
||||
if (mounted && !widget.isOffline) {
|
||||
_attachToWatchTogetherSession();
|
||||
_notifyWatchTogetherMediaChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Update available versions from the playback data
|
||||
@@ -719,11 +740,147 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
});
|
||||
}
|
||||
|
||||
/// Attach player to Watch Together session for playback sync
|
||||
void _attachToWatchTogetherSession() {
|
||||
try {
|
||||
final watchTogether = context.read<WatchTogetherProvider>();
|
||||
_watchTogetherProvider = watchTogether; // Store reference for use in dispose
|
||||
if (watchTogether.isInSession && player != null) {
|
||||
watchTogether.attachPlayer(player!);
|
||||
appLogger.d('WatchTogether: Player attached for sync');
|
||||
|
||||
// If guest, handle mediaSwitch internally for proper navigation context
|
||||
if (!watchTogether.isHost) {
|
||||
watchTogether.onPlayerMediaSwitched = _handlePlayerMediaSwitch;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Watch together provider not available or not in session - non-critical
|
||||
appLogger.d('Could not attach player to watch together', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detach player from Watch Together session
|
||||
void _detachFromWatchTogetherSession() {
|
||||
try {
|
||||
final watchTogether = _watchTogetherProvider ?? context.read<WatchTogetherProvider>();
|
||||
if (watchTogether.isInSession) {
|
||||
watchTogether.detachPlayer();
|
||||
appLogger.d('WatchTogether: Player detached');
|
||||
}
|
||||
watchTogether.onPlayerMediaSwitched = null; // Always clear player callback
|
||||
} catch (e) {
|
||||
// Non-critical
|
||||
appLogger.d('Could not detach player from watch together', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if episode navigation controls should be enabled
|
||||
/// Returns true if not in Watch Together session, or if user is the host
|
||||
bool _canNavigateEpisodes() {
|
||||
if (_watchTogetherProvider == null) return true;
|
||||
if (!_watchTogetherProvider!.isInSession) return true;
|
||||
return _watchTogetherProvider!.isHost;
|
||||
}
|
||||
|
||||
/// Notify watch together session of current media change (host only)
|
||||
/// If [metadata] is provided, uses that instead of widget.metadata (for episode navigation)
|
||||
void _notifyWatchTogetherMediaChange({PlexMetadata? metadata}) {
|
||||
final targetMetadata = metadata ?? widget.metadata;
|
||||
try {
|
||||
final watchTogether = context.read<WatchTogetherProvider>();
|
||||
if (watchTogether.isHost && watchTogether.isInSession) {
|
||||
watchTogether.setCurrentMedia(
|
||||
ratingKey: targetMetadata.ratingKey!,
|
||||
serverId: targetMetadata.serverId!,
|
||||
mediaTitle: targetMetadata.title!,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Watch together provider not available or not in session - non-critical
|
||||
appLogger.d('Could not notify watch together of media change', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle media switch from host (guest only)
|
||||
/// Uses VideoPlayerScreen's context for proper navigation (pushReplacement)
|
||||
Future<void> _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.d('WatchTogether: Guest handling media switch to $title');
|
||||
|
||||
// Fetch metadata for the new episode
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(serverId);
|
||||
if (client == null) {
|
||||
appLogger.w('WatchTogether: Server $serverId not found for media switch');
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = await client.getMetadataWithImages(ratingKey);
|
||||
if (metadata == null || !mounted) {
|
||||
appLogger.w('WatchTogether: Could not fetch metadata for $ratingKey');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detach and dispose current player before switching to avoid sync calls on a disposed instance
|
||||
await disposePlayerForNavigation();
|
||||
|
||||
// Use same navigation as local episode change (pushReplacement from player context)
|
||||
_isReplacingWithVideo = true;
|
||||
navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true);
|
||||
}
|
||||
|
||||
/// Handle back button press
|
||||
/// For non-host participants in Watch Together, shows leave session confirmation
|
||||
Future<void> _handleBackButton() async {
|
||||
// For non-host participants, show leave session confirmation
|
||||
if (_watchTogetherProvider != null && _watchTogetherProvider!.isInSession && !_watchTogetherProvider!.isHost) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Leave Session?'),
|
||||
content: const Text('You will be removed from the session.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: Theme.of(dialogContext).colorScheme.error),
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
await _watchTogetherProvider!.leaveSession();
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behavior for hosts or non-session users
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unregister app lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
// Notify Watch Together guests that host is exiting the player
|
||||
// Use stored reference since context.read() may fail in dispose
|
||||
// Skip if replacing with another video (episode navigation)
|
||||
if (!_isReplacingWithVideo &&
|
||||
_watchTogetherProvider != null &&
|
||||
_watchTogetherProvider!.isHost &&
|
||||
_watchTogetherProvider!.isInSession) {
|
||||
_watchTogetherProvider!.notifyHostExitedPlayer();
|
||||
}
|
||||
|
||||
// Detach from Watch Together session
|
||||
_detachFromWatchTogetherSession();
|
||||
|
||||
// Dispose value notifiers
|
||||
_isBuffering.dispose();
|
||||
|
||||
@@ -776,6 +933,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
player?.dispose();
|
||||
if (_activeRatingKey == widget.metadata.ratingKey) {
|
||||
_activeRatingKey = null;
|
||||
_activeMediaIndex = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -839,6 +1000,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void> _playNext() async {
|
||||
if (_nextEpisode == null || _isLoadingNext) return;
|
||||
|
||||
// Notify Watch Together of episode change before navigating
|
||||
_notifyWatchTogetherMediaChange(metadata: _nextEpisode);
|
||||
|
||||
setState(() {
|
||||
_isLoadingNext = true;
|
||||
_showPlayNextDialog = false;
|
||||
@@ -849,6 +1013,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
Future<void> _playPrevious() async {
|
||||
if (_previousEpisode == null) return;
|
||||
|
||||
// Notify Watch Together of episode change before navigating
|
||||
_notifyWatchTogetherMediaChange(metadata: _previousEpisode);
|
||||
|
||||
await _navigateToEpisode(_previousEpisode!);
|
||||
}
|
||||
|
||||
@@ -1127,6 +1295,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_isDisposingForNavigation = true;
|
||||
|
||||
try {
|
||||
_detachFromWatchTogetherSession();
|
||||
_progressTracker?.sendProgress('stopped');
|
||||
_progressTracker?.stopTracking();
|
||||
await player?.dispose();
|
||||
@@ -1155,9 +1324,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
// Allow programmatic back navigation from UI controls
|
||||
if (!didPop) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
if (!didPop) _handleBackButton();
|
||||
},
|
||||
child: Scaffold(
|
||||
// Use transparent background on macOS when native video layer is active
|
||||
@@ -1204,19 +1371,45 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
});
|
||||
|
||||
// Compute canControl from Watch Together provider
|
||||
bool canControl = true;
|
||||
try {
|
||||
final watchTogether = this.context.read<WatchTogetherProvider>();
|
||||
if (watchTogether.isInSession) {
|
||||
canControl = watchTogether.canControl();
|
||||
}
|
||||
} catch (e) {
|
||||
// Watch Together not available, default to can control
|
||||
}
|
||||
|
||||
return Video(
|
||||
player: player!,
|
||||
controls: (context) => plexVideoControlsBuilder(
|
||||
player!,
|
||||
widget.metadata,
|
||||
onNext: _nextEpisode != null ? _playNext : null,
|
||||
onPrevious: _previousEpisode != null ? _playPrevious : null,
|
||||
onNext: (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null,
|
||||
onPrevious: (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
||||
onCycleBoxFitMode: _cycleBoxFitMode,
|
||||
onAudioTrackChanged: _onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||
onSeekCompleted: (position) {
|
||||
// Notify Watch Together of seek for sync
|
||||
// Note: canControl() check is done in sync manager, not here
|
||||
// This matches play/pause behavior and avoids timing issues
|
||||
try {
|
||||
final watchTogether = this.context.read<WatchTogetherProvider>();
|
||||
if (watchTogether.isInSession) {
|
||||
watchTogether.onLocalSeek(position);
|
||||
}
|
||||
} catch (e) {
|
||||
// Watch Together not available, ignore
|
||||
}
|
||||
},
|
||||
onBack: _handleBackButton,
|
||||
canControl: canControl,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -95,6 +95,50 @@ class ServerRegistry {
|
||||
appLogger.i('Cleared all servers from registry');
|
||||
}
|
||||
|
||||
/// Refresh servers from Plex API and update storage
|
||||
/// This updates connection info (IPs, ports) that may have changed
|
||||
Future<void> refreshServersFromApi() async {
|
||||
final token = _storage.getPlexToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
appLogger.d('No Plex token available, skipping server refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appLogger.d('Refreshing servers from Plex API...');
|
||||
final authService = await PlexAuthService.create();
|
||||
final freshServers = await authService.fetchServers(token);
|
||||
|
||||
if (freshServers.isEmpty) {
|
||||
appLogger.w('API returned no servers, keeping existing data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing servers to preserve any local-only data
|
||||
final existingServers = await getServers();
|
||||
final existingIds = existingServers.map((s) => s.clientIdentifier).toSet();
|
||||
|
||||
// Update existing servers with fresh connection info, add new ones
|
||||
final updatedServers = <PlexServer>[];
|
||||
for (final fresh in freshServers) {
|
||||
if (existingIds.contains(fresh.clientIdentifier)) {
|
||||
// Server exists - use fresh data (updated IPs, connections)
|
||||
updatedServers.add(fresh);
|
||||
} else {
|
||||
// New server - add it
|
||||
updatedServers.add(fresh);
|
||||
appLogger.i('Discovered new server: ${fresh.name}');
|
||||
}
|
||||
}
|
||||
|
||||
await saveServers(updatedServers);
|
||||
appLogger.i('Refreshed ${updatedServers.length} servers from API');
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.w('Failed to refresh servers from API, using cached data', error: e, stackTrace: stackTrace);
|
||||
// Don't rethrow - we can continue with cached servers
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate from single server storage to multi-server
|
||||
/// This is called during app startup to migrate existing users
|
||||
Future<void> migrateFromSingleServer() async {
|
||||
|
||||
@@ -335,11 +335,7 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
|
||||
/// Clear all multi-server data
|
||||
Future<void> clearMultiServerData() async {
|
||||
await Future.wait([
|
||||
clearServersList(),
|
||||
clearServerOrder(),
|
||||
_clearKeysWithPrefix(_prefixServerEndpoint),
|
||||
]);
|
||||
await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]);
|
||||
}
|
||||
|
||||
/// Server Order (stored as JSON list of server IDs)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import '../mpv/mpv.dart';
|
||||
|
||||
/// Seeks by the given offset (can be positive or negative) while clamping
|
||||
/// the result between 0 and the video duration
|
||||
void seekWithClamping(Player player, Duration offset) {
|
||||
/// the result between 0 and the video duration.
|
||||
/// Returns the clamped position that was seeked to.
|
||||
Duration seekWithClamping(Player player, Duration offset) {
|
||||
final currentPosition = player.state.position;
|
||||
final duration = player.state.duration;
|
||||
final newPosition = currentPosition + offset;
|
||||
@@ -11,4 +12,5 @@ void seekWithClamping(Player player, Duration offset) {
|
||||
final clampedPosition = newPosition.isNegative ? Duration.zero : (newPosition > duration ? duration : newPosition);
|
||||
|
||||
player.seek(clampedPosition);
|
||||
return clampedPosition;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import '../screens/video_player_screen.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import 'app_logger.dart';
|
||||
|
||||
const String kVideoPlayerRouteName = '/video_player';
|
||||
|
||||
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
|
||||
///
|
||||
/// This utility function provides a consistent way to navigate to the video player
|
||||
@@ -54,7 +56,19 @@ Future<bool?> navigateToVideoPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent stacking an identical video player when already active
|
||||
if (!usePushReplacement &&
|
||||
metadata.ratingKey != null &&
|
||||
VideoPlayerScreenState.activeRatingKey == metadata.ratingKey &&
|
||||
VideoPlayerScreenState.activeMediaIndex == mediaIndex) {
|
||||
appLogger.d(
|
||||
'Video player already active for ${metadata.ratingKey} (mediaIndex=$mediaIndex), skipping duplicate navigation',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final route = PageRouteBuilder<bool>(
|
||||
settings: const RouteSettings(name: kVideoPlayerRouteName),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||
metadata: metadata,
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,7 @@ class CustomAppBar extends StatelessWidget {
|
||||
final double? expandedHeight;
|
||||
final Widget? flexibleSpace;
|
||||
final PreferredSizeWidget? bottom;
|
||||
final bool automaticallyImplyLeading;
|
||||
|
||||
const CustomAppBar({
|
||||
super.key,
|
||||
@@ -242,6 +243,7 @@ class CustomAppBar extends StatelessWidget {
|
||||
this.expandedHeight,
|
||||
this.flexibleSpace,
|
||||
this.bottom,
|
||||
this.automaticallyImplyLeading = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -262,7 +264,7 @@ class CustomAppBar extends StatelessWidget {
|
||||
bottom: bottom,
|
||||
),
|
||||
onBackPressed: onBackPressed,
|
||||
automaticallyImplyLeading: true,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,17 @@ class FocusedScrollScaffold extends StatelessWidget {
|
||||
/// Defaults to true.
|
||||
final bool pinned;
|
||||
|
||||
/// Whether to automatically add a back button.
|
||||
/// Defaults to true.
|
||||
final bool automaticallyImplyLeading;
|
||||
|
||||
const FocusedScrollScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.slivers,
|
||||
this.actions,
|
||||
this.pinned = true,
|
||||
this.automaticallyImplyLeading = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,7 +45,12 @@ class FocusedScrollScaffold extends StatelessWidget {
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(title: title, pinned: pinned, actions: actions),
|
||||
CustomAppBar(
|
||||
title: title,
|
||||
pinned: pinned,
|
||||
actions: actions,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
),
|
||||
...slivers,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -60,6 +60,10 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
final VoidCallback? onCancelAutoHide;
|
||||
final VoidCallback? onStartAutoHide;
|
||||
final String serverId;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Whether the user can control playback (false in host-only mode for non-host).
|
||||
final bool canControl;
|
||||
|
||||
const DesktopVideoControls({
|
||||
super.key,
|
||||
@@ -94,6 +98,8 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
this.onCancelAutoHide,
|
||||
this.onStartAutoHide,
|
||||
this.serverId = '',
|
||||
this.onBack,
|
||||
this.canControl = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -252,8 +258,11 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT/RIGHT for smooth scrubbing
|
||||
// LEFT/RIGHT for smooth scrubbing - only if user can control
|
||||
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
|
||||
// Ignore seeking if user cannot control
|
||||
if (!widget.canControl) return KeyEventResult.handled;
|
||||
|
||||
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
|
||||
|
||||
// Base step: 0.5% of duration, minimum 500ms
|
||||
@@ -314,6 +323,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
child: VideoControlsHeader(
|
||||
metadata: widget.metadata,
|
||||
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -336,74 +346,92 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
focusNode: _timelineFocusNode,
|
||||
onKeyEvent: _handleTimelineKeyEvent,
|
||||
onFocusChange: _onFocusChange,
|
||||
enabled: widget.canControl,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Row 2: Playback controls and options
|
||||
Row(
|
||||
children: [
|
||||
// Previous item
|
||||
_buildFocusableButton(
|
||||
focusNode: _prevItemFocusNode,
|
||||
index: 0,
|
||||
icon: Symbols.skip_previous_rounded,
|
||||
color: widget.onPrevious != null ? Colors.white : Colors.white54,
|
||||
onPressed: widget.onPrevious,
|
||||
semanticLabel: t.videoControls.previousButton,
|
||||
Opacity(
|
||||
opacity: widget.canControl ? 1.0 : 0.5,
|
||||
child: _buildFocusableButton(
|
||||
focusNode: _prevItemFocusNode,
|
||||
index: 0,
|
||||
icon: Symbols.skip_previous_rounded,
|
||||
color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54,
|
||||
onPressed: widget.canControl ? widget.onPrevious : null,
|
||||
semanticLabel: t.videoControls.previousButton,
|
||||
),
|
||||
),
|
||||
// Previous chapter (or skip backward if no chapters)
|
||||
_buildFocusableButton(
|
||||
focusNode: _prevChapterFocusNode,
|
||||
index: 1,
|
||||
icon: widget.chapters.isEmpty
|
||||
? widget.getReplayIcon(widget.seekTimeSmall)
|
||||
: Symbols.fast_rewind_rounded,
|
||||
onPressed: widget.onSeekToPreviousChapter,
|
||||
semanticLabel: widget.chapters.isEmpty
|
||||
? t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall)
|
||||
: t.videoControls.previousChapterButton,
|
||||
Opacity(
|
||||
opacity: widget.canControl ? 1.0 : 0.5,
|
||||
child: _buildFocusableButton(
|
||||
focusNode: _prevChapterFocusNode,
|
||||
index: 1,
|
||||
icon: widget.chapters.isEmpty
|
||||
? widget.getReplayIcon(widget.seekTimeSmall)
|
||||
: Symbols.fast_rewind_rounded,
|
||||
onPressed: widget.canControl ? widget.onSeekToPreviousChapter : null,
|
||||
semanticLabel: widget.chapters.isEmpty
|
||||
? t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall)
|
||||
: t.videoControls.previousChapterButton,
|
||||
),
|
||||
),
|
||||
// Play/Pause
|
||||
StreamBuilder<bool>(
|
||||
stream: widget.player.streams.playing,
|
||||
initialData: widget.player.state.playing,
|
||||
builder: (context, snapshot) {
|
||||
final isPlaying = snapshot.data ?? false;
|
||||
return _buildFocusableButton(
|
||||
focusNode: _playPauseFocusNode,
|
||||
index: 2,
|
||||
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||
iconSize: 32,
|
||||
onPressed: () {
|
||||
if (isPlaying) {
|
||||
widget.player.pause();
|
||||
} else {
|
||||
widget.player.play();
|
||||
}
|
||||
},
|
||||
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
|
||||
);
|
||||
},
|
||||
Opacity(
|
||||
opacity: widget.canControl ? 1.0 : 0.5,
|
||||
child: StreamBuilder<bool>(
|
||||
stream: widget.player.streams.playing,
|
||||
initialData: widget.player.state.playing,
|
||||
builder: (context, snapshot) {
|
||||
final isPlaying = snapshot.data ?? false;
|
||||
return _buildFocusableButton(
|
||||
focusNode: _playPauseFocusNode,
|
||||
index: 2,
|
||||
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||
iconSize: 32,
|
||||
onPressed: widget.canControl
|
||||
? () {
|
||||
if (isPlaying) {
|
||||
widget.player.pause();
|
||||
} else {
|
||||
widget.player.play();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Next chapter (or skip forward if no chapters)
|
||||
_buildFocusableButton(
|
||||
focusNode: _nextChapterFocusNode,
|
||||
index: 3,
|
||||
icon: widget.chapters.isEmpty
|
||||
? widget.getForwardIcon(widget.seekTimeSmall)
|
||||
: Symbols.fast_forward_rounded,
|
||||
onPressed: widget.onSeekToNextChapter,
|
||||
semanticLabel: widget.chapters.isEmpty
|
||||
? t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall)
|
||||
: t.videoControls.nextChapterButton,
|
||||
Opacity(
|
||||
opacity: widget.canControl ? 1.0 : 0.5,
|
||||
child: _buildFocusableButton(
|
||||
focusNode: _nextChapterFocusNode,
|
||||
index: 3,
|
||||
icon: widget.chapters.isEmpty
|
||||
? widget.getForwardIcon(widget.seekTimeSmall)
|
||||
: Symbols.fast_forward_rounded,
|
||||
onPressed: widget.canControl ? widget.onSeekToNextChapter : null,
|
||||
semanticLabel: widget.chapters.isEmpty
|
||||
? t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall)
|
||||
: t.videoControls.nextChapterButton,
|
||||
),
|
||||
),
|
||||
// Next item
|
||||
_buildFocusableButton(
|
||||
focusNode: _nextItemFocusNode,
|
||||
index: 4,
|
||||
icon: Symbols.skip_next_rounded,
|
||||
color: widget.onNext != null ? Colors.white : Colors.white54,
|
||||
onPressed: widget.onNext,
|
||||
semanticLabel: t.videoControls.nextButton,
|
||||
Opacity(
|
||||
opacity: widget.canControl ? 1.0 : 0.5,
|
||||
child: _buildFocusableButton(
|
||||
focusNode: _nextItemFocusNode,
|
||||
index: 4,
|
||||
icon: Symbols.skip_next_rounded,
|
||||
color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54,
|
||||
onPressed: widget.canControl ? widget.onNext : null,
|
||||
semanticLabel: t.videoControls.nextButton,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Volume control
|
||||
@@ -439,6 +467,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
focusNodes: _trackControlFocusNodes,
|
||||
onFocusChange: _onFocusChange,
|
||||
onNavigateLeft: navigateFromTrackToVolume,
|
||||
canControl: widget.canControl,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -27,9 +27,14 @@ class MobileVideoControls extends StatelessWidget {
|
||||
final Widget trackChapterControls;
|
||||
final Function(Duration) onSeek;
|
||||
final Function(Duration) onSeekEnd;
|
||||
final Function(Duration)? onSeekCompleted;
|
||||
final VoidCallback onPlayPause;
|
||||
final VoidCallback? onCancelAutoHide;
|
||||
final VoidCallback? onStartAutoHide;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Whether the user can control playback (false in host-only mode for non-host).
|
||||
final bool canControl;
|
||||
|
||||
const MobileVideoControls({
|
||||
super.key,
|
||||
@@ -42,8 +47,11 @@ class MobileVideoControls extends StatelessWidget {
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
required this.onPlayPause,
|
||||
this.onSeekCompleted,
|
||||
this.onCancelAutoHide,
|
||||
this.onStartAutoHide,
|
||||
this.onBack,
|
||||
this.canControl = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -72,6 +80,7 @@ class MobileVideoControls extends StatelessWidget {
|
||||
metadata: metadata,
|
||||
style: VideoHeaderStyle.multiLine,
|
||||
trailing: trackChapterControls,
|
||||
onBack: onBack,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -80,6 +89,11 @@ class MobileVideoControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildPlaybackControls(BuildContext context) {
|
||||
// Hide all playback controls in host-only mode for non-host
|
||||
if (!canControl) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return StreamBuilder<bool>(
|
||||
stream: player.streams.playing,
|
||||
initialData: player.state.playing,
|
||||
@@ -93,7 +107,8 @@ class MobileVideoControls extends StatelessWidget {
|
||||
icon: getReplayIcon(seekTimeSmall),
|
||||
iconSize: 48,
|
||||
onPressed: () {
|
||||
seekWithClamping(player, Duration(seconds: -seekTimeSmall));
|
||||
final newPosition = seekWithClamping(player, Duration(seconds: -seekTimeSmall));
|
||||
onSeekCompleted?.call(newPosition);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
@@ -117,7 +132,8 @@ class MobileVideoControls extends StatelessWidget {
|
||||
icon: getForwardIcon(seekTimeSmall),
|
||||
iconSize: 48,
|
||||
onPressed: () {
|
||||
seekWithClamping(player, Duration(seconds: seekTimeSmall));
|
||||
final newPosition = seekWithClamping(player, Duration(seconds: seekTimeSmall));
|
||||
onSeekCompleted?.call(newPosition);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -139,6 +155,7 @@ class MobileVideoControls extends StatelessWidget {
|
||||
onSeek: onSeek,
|
||||
onSeekEnd: onSeekEnd,
|
||||
horizontalLayout: false,
|
||||
enabled: canControl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -69,11 +69,15 @@ class VideoSettingsSheet extends StatefulWidget {
|
||||
final int audioSyncOffset;
|
||||
final int subtitleSyncOffset;
|
||||
|
||||
/// Whether the user can control playback (false hides speed option in host-only mode).
|
||||
final bool canControl;
|
||||
|
||||
const VideoSettingsSheet({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.audioSyncOffset,
|
||||
required this.subtitleSyncOffset,
|
||||
this.canControl = true,
|
||||
});
|
||||
|
||||
static Future<void> show(
|
||||
@@ -83,13 +87,18 @@ class VideoSettingsSheet extends StatefulWidget {
|
||||
int subtitleSyncOffset, {
|
||||
VoidCallback? onOpen,
|
||||
VoidCallback? onClose,
|
||||
bool canControl = true,
|
||||
}) {
|
||||
return BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
onOpen: onOpen,
|
||||
onClose: onClose,
|
||||
builder: (context) =>
|
||||
VideoSettingsSheet(player: player, audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset),
|
||||
builder: (context) => VideoSettingsSheet(
|
||||
player: player,
|
||||
audioSyncOffset: audioSyncOffset,
|
||||
subtitleSyncOffset: subtitleSyncOffset,
|
||||
canControl: canControl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,21 +210,22 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
// Playback Speed
|
||||
StreamBuilder<double>(
|
||||
stream: widget.player.streams.rate,
|
||||
initialData: widget.player.state.rate,
|
||||
builder: (context, snapshot) {
|
||||
final currentRate = snapshot.data ?? 1.0;
|
||||
return _SettingsMenuItem(
|
||||
focusNode: _initialFocusNode,
|
||||
icon: Symbols.speed_rounded,
|
||||
title: 'Playback Speed',
|
||||
valueText: _formatSpeed(currentRate),
|
||||
onTap: () => _navigateTo(_SettingsView.speed),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Playback Speed - only show if user can control playback
|
||||
if (widget.canControl)
|
||||
StreamBuilder<double>(
|
||||
stream: widget.player.streams.rate,
|
||||
initialData: widget.player.state.rate,
|
||||
builder: (context, snapshot) {
|
||||
final currentRate = snapshot.data ?? 1.0;
|
||||
return _SettingsMenuItem(
|
||||
focusNode: _initialFocusNode,
|
||||
icon: Symbols.speed_rounded,
|
||||
title: 'Playback Speed',
|
||||
valueText: _formatSpeed(currentRate),
|
||||
onTap: () => _navigateTo(_SettingsView.speed),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Sleep Timer
|
||||
ListenableBuilder(
|
||||
|
||||
@@ -4,8 +4,17 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rate_limiter/rate_limiter.dart';
|
||||
import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation, LogicalKeyboardKey;
|
||||
import 'package:flutter/services.dart'
|
||||
show
|
||||
SystemChrome,
|
||||
DeviceOrientation,
|
||||
LogicalKeyboardKey,
|
||||
PhysicalKeyboardKey,
|
||||
KeyEvent,
|
||||
KeyDownEvent,
|
||||
HardwareKeyboard;
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
@@ -30,6 +39,7 @@ import 'icons.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../watch_together/watch_together.dart';
|
||||
import 'widgets/track_chapter_controls.dart';
|
||||
import 'mobile_video_controls.dart';
|
||||
import 'desktop_video_controls.dart';
|
||||
@@ -46,6 +56,9 @@ Widget plexVideoControlsBuilder(
|
||||
VoidCallback? onCycleBoxFitMode,
|
||||
Function(AudioTrack)? onAudioTrackChanged,
|
||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
||||
Function(Duration position)? onSeekCompleted,
|
||||
VoidCallback? onBack,
|
||||
bool canControl = true,
|
||||
}) {
|
||||
return PlexVideoControls(
|
||||
player: player,
|
||||
@@ -58,6 +71,9 @@ Widget plexVideoControlsBuilder(
|
||||
onCycleBoxFitMode: onCycleBoxFitMode,
|
||||
onAudioTrackChanged: onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
||||
onSeekCompleted: onSeekCompleted,
|
||||
onBack: onBack,
|
||||
canControl: canControl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +89,15 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final Function(AudioTrack)? onAudioTrackChanged;
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
|
||||
/// Called when a seek operation completes (for Watch Together sync)
|
||||
final Function(Duration position)? onSeekCompleted;
|
||||
|
||||
/// Called when back button is pressed (for Watch Together session leave confirmation)
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Whether the user can control playback (false in host-only mode for non-host).
|
||||
final bool canControl;
|
||||
|
||||
const PlexVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -85,6 +110,9 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.onCycleBoxFitMode,
|
||||
this.onAudioTrackChanged,
|
||||
this.onSubtitleTrackChanged,
|
||||
this.onSeekCompleted,
|
||||
this.onBack,
|
||||
this.canControl = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -167,6 +195,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusPlayPauseIfKeyboardMode();
|
||||
});
|
||||
// Register global key handler for focus-independent shortcuts (desktop only)
|
||||
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
|
||||
}
|
||||
|
||||
/// Focus play/pause button if we're in keyboard navigation mode (desktop only)
|
||||
@@ -241,7 +271,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
|
||||
void _skipMarker() {
|
||||
if (_currentMarker != null) {
|
||||
widget.player.seek(_currentMarker!.endTime);
|
||||
final endTime = _currentMarker!.endTime;
|
||||
widget.player.seek(endTime);
|
||||
widget.onSeekCompleted?.call(endTime);
|
||||
}
|
||||
_cancelAutoSkipTimer();
|
||||
}
|
||||
@@ -365,6 +397,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
|
||||
_hideTimer?.cancel();
|
||||
_feedbackTimer?.cancel();
|
||||
_resizeDebounceTimer?.cancel();
|
||||
@@ -676,49 +709,55 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
onCancelAutoHide: () => _hideTimer?.cancel(),
|
||||
onStartAutoHide: _startHideTimer,
|
||||
serverId: widget.metadata.serverId ?? '',
|
||||
canControl: widget.canControl,
|
||||
);
|
||||
}
|
||||
|
||||
void _seekToPreviousChapter() {
|
||||
void _seekToPreviousChapter() => _seekToChapter(forward: false);
|
||||
|
||||
void _seekToNextChapter() => _seekToChapter(forward: true);
|
||||
|
||||
void _seekToChapter({required bool forward}) {
|
||||
if (_chapters.isEmpty) {
|
||||
// No chapters - seek backward by configured amount
|
||||
seekWithClamping(widget.player, Duration(seconds: -_seekTimeSmall));
|
||||
// No chapters - seek by configured amount
|
||||
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
|
||||
final duration = widget.player.state.duration;
|
||||
final unclamped = widget.player.state.position + delta;
|
||||
final newPosition = unclamped < Duration.zero ? Duration.zero : (unclamped > duration ? duration : unclamped);
|
||||
seekWithClamping(widget.player, delta);
|
||||
widget.onSeekCompleted?.call(newPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
final currentPosition = widget.player.state.position.inMilliseconds;
|
||||
final currentPositionMs = widget.player.state.position.inMilliseconds;
|
||||
|
||||
// Find current chapter
|
||||
for (int i = _chapters.length - 1; i >= 0; i--) {
|
||||
final chapterStart = _chapters[i].startTimeOffset ?? 0;
|
||||
if (currentPosition > chapterStart + 3000) {
|
||||
// If more than 3 seconds into chapter, go to start of current chapter
|
||||
widget.player.seek(Duration(milliseconds: chapterStart));
|
||||
return;
|
||||
if (forward) {
|
||||
// Find next chapter
|
||||
for (final chapter in _chapters) {
|
||||
final chapterStart = chapter.startTimeOffset ?? 0;
|
||||
if (chapterStart > currentPositionMs) {
|
||||
_seekToPosition(Duration(milliseconds: chapterStart));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Find previous/current chapter
|
||||
for (int i = _chapters.length - 1; i >= 0; i--) {
|
||||
final chapterStart = _chapters[i].startTimeOffset ?? 0;
|
||||
if (currentPositionMs > chapterStart + 3000) {
|
||||
// If more than 3 seconds into chapter, go to start of current chapter
|
||||
_seekToPosition(Duration(milliseconds: chapterStart));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If at start of first chapter, go to beginning
|
||||
_seekToPosition(Duration.zero);
|
||||
}
|
||||
|
||||
// If at start of first chapter, go to beginning
|
||||
widget.player.seek(Duration.zero);
|
||||
}
|
||||
|
||||
void _seekToNextChapter() {
|
||||
if (_chapters.isEmpty) {
|
||||
// No chapters - seek forward by configured amount
|
||||
seekWithClamping(widget.player, Duration(seconds: _seekTimeSmall));
|
||||
return;
|
||||
}
|
||||
|
||||
final currentPosition = widget.player.state.position.inMilliseconds;
|
||||
|
||||
// Find next chapter
|
||||
for (int i = 0; i < _chapters.length; i++) {
|
||||
final chapterStart = _chapters[i].startTimeOffset ?? 0;
|
||||
if (chapterStart > currentPosition) {
|
||||
widget.player.seek(Duration(milliseconds: chapterStart));
|
||||
return;
|
||||
}
|
||||
}
|
||||
void _seekToPosition(Duration position) {
|
||||
widget.player.seek(position);
|
||||
widget.onSeekCompleted?.call(position);
|
||||
}
|
||||
|
||||
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms
|
||||
@@ -728,12 +767,26 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
void _finalizeSeek(Duration position) {
|
||||
_seekThrottle.cancel();
|
||||
widget.player.seek(position);
|
||||
widget.onSeekCompleted?.call(position);
|
||||
}
|
||||
|
||||
/// Handle double-tap skip forward or backward
|
||||
void _handleDoubleTapSkip({required bool isForward}) {
|
||||
// Ignore if user cannot control playback
|
||||
if (!widget.canControl) return;
|
||||
|
||||
// Calculate the new position (clamped to valid range)
|
||||
final currentPosition = widget.player.state.position;
|
||||
final duration = widget.player.state.duration;
|
||||
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
|
||||
final unclamped = currentPosition + delta;
|
||||
final newPosition = unclamped < Duration.zero ? Duration.zero : (unclamped > duration ? duration : unclamped);
|
||||
|
||||
// Perform the seek
|
||||
seekWithClamping(widget.player, Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall));
|
||||
seekWithClamping(widget.player, delta);
|
||||
|
||||
// Notify Watch Together
|
||||
widget.onSeekCompleted?.call(newPosition);
|
||||
|
||||
// Show visual feedback
|
||||
_showSkipFeedback(isForward: isForward);
|
||||
@@ -838,8 +891,48 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
key == LogicalKeyboardKey.gameButtonA;
|
||||
}
|
||||
|
||||
/// Show controls and focus play/pause on keyboard input (desktop only)
|
||||
void _showControlsWithFocus() {
|
||||
/// Determine if the key event should toggle play/pause based on configured hotkeys.
|
||||
bool _isPlayPauseKey(KeyEvent event) {
|
||||
final physicalKey = event.physicalKey;
|
||||
|
||||
// When the shortcuts service is available, respect the configured play/pause hotkey
|
||||
if (_keyboardService != null) {
|
||||
final hotkey = _keyboardService!.hotkeys['play_pause'];
|
||||
if (hotkey == null) return false;
|
||||
return hotkey.key == physicalKey;
|
||||
}
|
||||
|
||||
// Fallback to defaults while the service is loading
|
||||
return physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause;
|
||||
}
|
||||
|
||||
bool _isPlayPauseActivation(KeyEvent event) {
|
||||
return event is KeyDownEvent && _isPlayPauseKey(event);
|
||||
}
|
||||
|
||||
/// Global key event handler for focus-independent shortcuts (desktop only)
|
||||
bool _handleGlobalKeyEvent(KeyEvent event) {
|
||||
if (!mounted) return false;
|
||||
|
||||
// Only handle when video player navigation is disabled (desktop mode without D-pad nav)
|
||||
if (_videoPlayerNavigationEnabled) return false;
|
||||
|
||||
// Skip on mobile (unless TV)
|
||||
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
|
||||
if (isMobile) return false;
|
||||
|
||||
// Handle play/pause globally - works regardless of focus
|
||||
if (_isPlayPauseActivation(event)) {
|
||||
widget.player.playOrPause();
|
||||
_showControlsWithFocus(requestFocus: false);
|
||||
return true; // Event handled, stop propagation
|
||||
}
|
||||
|
||||
return false; // Let event continue to other handlers
|
||||
}
|
||||
|
||||
/// Show controls and optionally focus play/pause on keyboard input (desktop only)
|
||||
void _showControlsWithFocus({bool requestFocus = true}) {
|
||||
if (!_showControls) {
|
||||
setState(() {
|
||||
_showControls = true;
|
||||
@@ -853,9 +946,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
_startHideTimer();
|
||||
|
||||
// Request focus on play/pause button after controls are shown
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
||||
});
|
||||
if (requestFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
||||
});
|
||||
} else {
|
||||
// When not requesting focus on play/pause, ensure main focus node keeps focus
|
||||
// This prevents focus from being lost when controls become visible
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_focusNode.hasFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide controls when navigating up from timeline (keyboard mode)
|
||||
@@ -893,6 +996,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
final isPlayPauseKey = _isPlayPauseKey(event);
|
||||
|
||||
// Handle play/pause via focus when navigation is enabled (TV/gamepad)
|
||||
// When navigation is disabled, the global handler (_handleGlobalKeyEvent) handles it
|
||||
if (_videoPlayerNavigationEnabled || isMobile) {
|
||||
if (_isPlayPauseActivation(event)) {
|
||||
widget.player.playOrPause();
|
||||
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Swallow other play/pause events (e.g., key up/repeat) to prevent focus side effects
|
||||
if (isPlayPauseKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Back/Escape: show controls if hidden, navigate back if visible
|
||||
if (_isBackKey(key)) {
|
||||
@@ -929,7 +1047,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
// Pass other events to the keyboard shortcuts service
|
||||
if (_keyboardService == null) return KeyEventResult.ignored;
|
||||
|
||||
return _keyboardService!.handleVideoPlayerKeyEvent(
|
||||
final result = _keyboardService!.handleVideoPlayerKeyEvent(
|
||||
event,
|
||||
widget.player,
|
||||
_toggleFullscreen,
|
||||
@@ -938,8 +1056,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
_nextSubtitleTrack,
|
||||
_nextChapter,
|
||||
_previousChapter,
|
||||
onBack: () => Navigator.of(context).pop(true),
|
||||
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
@@ -1000,9 +1119,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
trackChapterControls: _buildTrackChapterControlsWidget(),
|
||||
onSeek: _throttledSeek,
|
||||
onSeekEnd: _finalizeSeek,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
onPlayPause: () {}, // Not used, handled internally
|
||||
onCancelAutoHide: () => _hideTimer?.cancel(),
|
||||
onStartAutoHide: _startHideTimer,
|
||||
onBack: widget.onBack,
|
||||
canControl: widget.canControl,
|
||||
),
|
||||
)
|
||||
: Listener(
|
||||
@@ -1045,6 +1167,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
onCancelAutoHide: () => _hideTimer?.cancel(),
|
||||
onStartAutoHide: _startHideTimer,
|
||||
serverId: widget.metadata.serverId ?? '',
|
||||
onBack: widget.onBack,
|
||||
canControl: widget.canControl,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -25,6 +25,9 @@ class TimelineSlider extends StatelessWidget {
|
||||
/// Called when focus changes.
|
||||
final ValueChanged<bool>? onFocusChange;
|
||||
|
||||
/// Whether the slider is enabled for interaction.
|
||||
final bool enabled;
|
||||
|
||||
const TimelineSlider({
|
||||
super.key,
|
||||
required this.position,
|
||||
@@ -36,6 +39,7 @@ class TimelineSlider extends StatelessWidget {
|
||||
this.focusNode,
|
||||
this.onKeyEvent,
|
||||
this.onFocusChange,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -68,22 +72,25 @@ class TimelineSlider extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Slider
|
||||
Semantics(
|
||||
label: t.videoControls.timelineSlider,
|
||||
slider: true,
|
||||
child: Slider(
|
||||
value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0,
|
||||
min: 0.0,
|
||||
max: duration.inMilliseconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
onSeek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
onSeekEnd(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
||||
// Slider - use IgnorePointer to block interaction while preserving visual style
|
||||
IgnorePointer(
|
||||
ignoring: !enabled,
|
||||
child: Semantics(
|
||||
label: t.videoControls.timelineSlider,
|
||||
slider: true,
|
||||
child: Slider(
|
||||
value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0,
|
||||
min: 0.0,
|
||||
max: duration.inMilliseconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
onSeek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
onSeekEnd(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Chapter marker indicators
|
||||
@@ -105,7 +112,7 @@ class TimelineSlider extends StatelessWidget {
|
||||
if (focusNode != null) {
|
||||
slider = FocusableWrapper(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: onKeyEvent,
|
||||
onKeyEvent: enabled ? onKeyEvent : null,
|
||||
onFocusChange: onFocusChange,
|
||||
borderRadius: 8,
|
||||
autoScroll: false,
|
||||
|
||||
@@ -51,6 +51,9 @@ class TrackChapterControls extends StatelessWidget {
|
||||
/// Called to navigate left from the first button
|
||||
final VoidCallback? onNavigateLeft;
|
||||
|
||||
/// Whether the user can control playback (false in host-only mode for non-host).
|
||||
final bool canControl;
|
||||
|
||||
const TrackChapterControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -76,6 +79,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
this.focusNodes,
|
||||
this.onFocusChange,
|
||||
this.onNavigateLeft,
|
||||
this.canControl = true,
|
||||
});
|
||||
|
||||
/// Handle key event for button navigation
|
||||
@@ -175,6 +179,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
subtitleSyncOffset,
|
||||
onOpen: onCancelAutoHide,
|
||||
onClose: onStartAutoHide,
|
||||
canControl: canControl,
|
||||
);
|
||||
onLoadSeekTimes?.call();
|
||||
},
|
||||
|
||||
@@ -24,11 +24,15 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
/// Optional trailing widget (e.g., track/chapter controls)
|
||||
final Widget? trailing;
|
||||
|
||||
/// Optional callback for back button. If null, defaults to Navigator.pop(true).
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const VideoControlsHeader({
|
||||
super.key,
|
||||
required this.metadata,
|
||||
this.style = VideoHeaderStyle.multiLine,
|
||||
this.trailing,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -38,7 +42,7 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
AppBarBackButton(
|
||||
style: BackButtonStyle.video,
|
||||
semanticLabel: t.videoControls.backButton,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
onPressed: onBack ?? () => Navigator.of(context).pop(true),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()),
|
||||
|
||||
@@ -30,6 +30,9 @@ class VideoTimelineBar extends StatelessWidget {
|
||||
/// Called when focus changes.
|
||||
final ValueChanged<bool>? onFocusChange;
|
||||
|
||||
/// Whether the timeline is enabled for interaction.
|
||||
final bool enabled;
|
||||
|
||||
const VideoTimelineBar({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -41,6 +44,7 @@ class VideoTimelineBar extends StatelessWidget {
|
||||
this.focusNode,
|
||||
this.onKeyEvent,
|
||||
this.onFocusChange,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -109,6 +113,7 @@ class VideoTimelineBar extends StatelessWidget {
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: onKeyEvent,
|
||||
onFocusChange: onFocusChange,
|
||||
enabled: enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user