From 63ba2bb3de20e538f0806319aaf0fc125e66613f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 18 Dec 2025 08:42:18 +0100 Subject: [PATCH] feat: watch together --- ios/Podfile.lock | 16 + lib/main.dart | 5 + lib/screens/discover_screen.dart | 117 ++- lib/screens/main_screen.dart | 68 ++ lib/screens/media_detail_screen.dart | 73 +- lib/screens/video_player_screen.dart | 203 +++++- lib/services/server_registry.dart | 44 ++ lib/services/storage_service.dart | 6 +- lib/utils/player_utils.dart | 6 +- lib/utils/video_player_navigation.dart | 14 + lib/watch_together/models/sync_message.dart | 307 ++++++++ lib/watch_together/models/watch_session.dart | 180 +++++ .../providers/watch_together_provider.dart | 481 +++++++++++++ .../screens/watch_together_screen.dart | 356 ++++++++++ .../services/watch_together_peer_service.dart | 356 ++++++++++ .../services/watch_together_sync_manager.dart | 670 ++++++++++++++++++ lib/watch_together/watch_together.dart | 18 + .../widgets/join_session_dialog.dart | 143 ++++ .../widgets/session_invite_dialog.dart | 150 ++++ .../widgets/watch_together_overlay.dart | 298 ++++++++ lib/widgets/desktop_app_bar.dart | 4 +- lib/widgets/focused_scroll_scaffold.dart | 12 +- .../desktop_video_controls.dart | 139 ++-- .../video_controls/mobile_video_controls.dart | 21 +- .../sheets/video_settings_sheet.dart | 44 +- .../video_controls/video_controls.dart | 206 ++++-- .../widgets/timeline_slider.dart | 41 +- .../widgets/track_chapter_controls.dart | 5 + .../widgets/video_controls_header.dart | 6 +- .../widgets/video_timeline_bar.dart | 5 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Podfile.lock | 16 + pubspec.lock | 60 +- pubspec.yaml | 5 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 38 files changed, 3909 insertions(+), 183 deletions(-) create mode 100644 lib/watch_together/models/sync_message.dart create mode 100644 lib/watch_together/models/watch_session.dart create mode 100644 lib/watch_together/providers/watch_together_provider.dart create mode 100644 lib/watch_together/screens/watch_together_screen.dart create mode 100644 lib/watch_together/services/watch_together_peer_service.dart create mode 100644 lib/watch_together/services/watch_together_sync_manager.dart create mode 100644 lib/watch_together/watch_together.dart create mode 100644 lib/watch_together/widgets/join_session_dialog.dart create mode 100644 lib/watch_together/widgets/session_invite_dialog.dart create mode 100644 lib/watch_together/widgets/watch_together_overlay.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1ee582a1..d077bbb7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -38,6 +38,9 @@ PODS: - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) + - flutter_webrtc (0.11.3): + - Flutter + - WebRTC-SDK (= 125.6422.04) - gamepads_ios (0.1.1): - Flutter - os_media_controls (0.0.1): @@ -50,6 +53,8 @@ PODS: - SDWebImage (5.21.3): - SDWebImage/Core (= 5.21.3) - SDWebImage/Core (5.21.3) + - share_plus (0.0.1): + - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -86,6 +91,7 @@ PODS: - Flutter - wakelock_plus (0.0.1): - Flutter + - WebRTC-SDK (125.6422.04) - workmanager_apple (0.0.1): - Flutter @@ -94,10 +100,12 @@ DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`) - os_media_controls (from `.symlinks/plugins/os_media_controls/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) @@ -112,6 +120,7 @@ SPEC REPOS: - SDWebImage - sqlite3 - SwiftyGif + - WebRTC-SDK EXTERNAL SOURCES: connectivity_plus: @@ -122,6 +131,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_webrtc: + :path: ".symlinks/plugins/flutter_webrtc/ios" gamepads_ios: :path: ".symlinks/plugins/gamepads_ios/ios" os_media_controls: @@ -130,6 +141,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: @@ -150,11 +163,13 @@ SPEC CHECKSUMS: DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_webrtc: 89f078a5050779eaa4a6a4f472954737bb91f39f gamepads_ios: c75c6d31377d275b0effb9174c619e2705678c09 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b @@ -162,6 +177,7 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + WebRTC-SDK: c3d69a87e7185fad3568f6f3cff7c9ac5890acf3 workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/main.dart b/lib/main.dart index a492da88..a0e69b1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 with WidgetsBindingObserver { ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true), ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), + ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ], child: Consumer( builder: (context, themeProvider, child) { @@ -344,6 +346,9 @@ class _SetupScreenState extends State { // 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(); diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 2b105f04..d4254e2b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 // 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 _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 } } + void _onWatchTogetherFocusChange() { + if (mounted) { + setState(() { + _isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus; + }); + } + } + void _onUserFocusChange() { if (mounted) { setState(() { @@ -242,9 +255,9 @@ class _DiscoverScreenState extends State 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 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 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 _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 child: IconButton(icon: const AppIcon(Symbols.refresh_rounded, fill: 1), onPressed: _loadContent), ), ), + // Watch Together button + Consumer( + 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( builder: (context, userProvider, child) { return Focus( diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index e5013505..1508fa2c 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -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 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 with RouteAware { }); } + /// Set up the Watch Together navigation callback for guests + void _setupWatchTogetherCallback() { + try { + final watchTogether = context.read(); + 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 _navigateToWatchTogetherMedia(String ratingKey, String serverId) async { + if (!mounted) return; // Check before any context usage + + try { + final multiServer = context.read(); + 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(); diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 8c3a5fea..75dba695 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -120,42 +120,55 @@ class _MediaDetailScreenState extends State { /// 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 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), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 858607d0..696186d7 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 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 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 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 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 with WidgetsBindin }); } + /// Attach player to Watch Together session for playback sync + void _attachToWatchTogetherSession() { + try { + final watchTogether = context.read(); + _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(); + 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(); + 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 _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(); + 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 _handleBackButton() async { + // For non-host participants, show leave session confirmation + if (_watchTogetherProvider != null && _watchTogetherProvider!.isInSession && !_watchTogetherProvider!.isHost) { + final confirmed = await showDialog( + 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 with WidgetsBindin } player?.dispose(); + if (_activeRatingKey == widget.metadata.ratingKey) { + _activeRatingKey = null; + _activeMediaIndex = null; + } super.dispose(); } @@ -839,6 +1000,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _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 with WidgetsBindin Future _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 with WidgetsBindin _isDisposingForNavigation = true; try { + _detachFromWatchTogetherSession(); _progressTracker?.sendProgress('stopped'); _progressTracker?.stopTracking(); await player?.dispose(); @@ -1155,9 +1324,7 @@ class VideoPlayerScreenState extends State 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 with WidgetsBindin } }); + // Compute canControl from Watch Together provider + bool canControl = true; + try { + final watchTogether = this.context.read(); + 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(); + if (watchTogether.isInSession) { + watchTogether.onLocalSeek(position); + } + } catch (e) { + // Watch Together not available, ignore + } + }, + onBack: _handleBackButton, + canControl: canControl, ), ); }, diff --git a/lib/services/server_registry.dart b/lib/services/server_registry.dart index 746aa12f..c2bab762 100644 --- a/lib/services/server_registry.dart +++ b/lib/services/server_registry.dart @@ -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 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 = []; + 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 migrateFromSingleServer() async { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index dbc57162..97290312 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -335,11 +335,7 @@ class StorageService extends BaseSharedPreferencesService { /// Clear all multi-server data Future 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) diff --git a/lib/utils/player_utils.dart b/lib/utils/player_utils.dart index 5a2e2481..d7456f82 100644 --- a/lib/utils/player_utils.dart +++ b/lib/utils/player_utils.dart @@ -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; } diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 0b9ccace..a4353312 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -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 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( + settings: const RouteSettings(name: kVideoPlayerRouteName), pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( metadata: metadata, preferredAudioTrack: preferredAudioTrack, diff --git a/lib/watch_together/models/sync_message.dart b/lib/watch_together/models/sync_message.dart new file mode 100644 index 00000000..dd4888df --- /dev/null +++ b/lib/watch_together/models/sync_message.dart @@ -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 = {'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; + + 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)'; + } +} diff --git a/lib/watch_together/models/watch_session.dart b/lib/watch_together/models/watch_session.dart new file mode 100644 index 00000000..72042805 --- /dev/null +++ b/lib/watch_together/models/watch_session.dart @@ -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 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? 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: [], + ); + } +} diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart new file mode 100644 index 00000000..140d8b6d --- /dev/null +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -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 _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? _peerConnectedSubscription; + StreamSubscription? _peerDisconnectedSubscription; + StreamSubscription? _messageSubscription; + StreamSubscription? _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 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 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 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 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(); + } +} diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart new file mode 100644 index 00000000..04d0deef --- /dev/null +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -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( + 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 _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 _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 _showControlModeDialog() { + return showDialog( + 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 _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 _leaveSession(BuildContext context) async { + final confirmed = await showDialog( + 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(); + } + } +} diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart new file mode 100644 index 00000000..c20e3f78 --- /dev/null +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -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 _connections = {}; + String? _sessionId; + String? _myPeerId; + bool _isHost = false; + + // Stream controllers for events + final _peerConnectedController = StreamController.broadcast(); + final _peerDisconnectedController = StreamController.broadcast(); + final _messageReceivedController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _connectionStateController = StreamController.broadcast(); + + // Reconnection state + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 3; + Timer? _reconnectTimer; + + /// Stream of peer IDs when a new peer connects + Stream get onPeerConnected => _peerConnectedController.stream; + + /// Stream of peer IDs when a peer disconnects + Stream get onPeerDisconnected => _peerDisconnectedController.stream; + + /// Stream of sync messages received from peers + Stream get onMessageReceived => _messageReceivedController.stream; + + /// Stream of errors + Stream get onError => _errorController.stream; + + /// Stream of connection state changes (true = connected, false = disconnected) + Stream 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 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 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(); + + 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 joinSession(String sessionId) async { + if (_peer != null) { + await disconnect(); + } + + _isHost = false; + _sessionId = sessionId.toUpperCase(); + _reconnectAttempts = 0; + + final completer = Completer(); + + 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? 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 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(); + } +} diff --git a/lib/watch_together/services/watch_together_sync_manager.dart b/lib/watch_together/services/watch_together_sync_manager.dart new file mode 100644 index 00000000..b7c3cf6a --- /dev/null +++ b/lib/watch_together/services/watch_together_sync_manager.dart @@ -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? _playingSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _bufferingSubscription; + StreamSubscription? _rateSubscription; + StreamSubscription? _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 _participantBuffering = {}; + + /// Participants' ready states (peer ID -> hasPlayerReady) + final Map _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 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 _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 _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 _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 _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 _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; + } +} diff --git a/lib/watch_together/watch_together.dart b/lib/watch_together/watch_together.dart new file mode 100644 index 00000000..a3734922 --- /dev/null +++ b/lib/watch_together/watch_together.dart @@ -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'; diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart new file mode 100644 index 00000000..7e4ce9a6 --- /dev/null +++ b/lib/watch_together/widgets/join_session_dialog.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 createState() => _JoinSessionDialogState(); +} + +class _JoinSessionDialogState extends State { + final _formKey = GlobalKey(); + 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 _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 showJoinSessionDialog(BuildContext context) { + return showDialog(context: context, builder: (context) => const JoinSessionDialog()); +} diff --git a/lib/watch_together/widgets/session_invite_dialog.dart b/lib/watch_together/widgets/session_invite_dialog.dart new file mode 100644 index 00000000..2a8378c8 --- /dev/null +++ b/lib/watch_together/widgets/session_invite_dialog.dart @@ -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 showSessionInviteDialog(BuildContext context, {required String sessionId, required int participantCount}) { + return showDialog( + context: context, + builder: (context) => SessionInviteDialog(sessionId: sessionId, participantCount: participantCount), + ); +} diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart new file mode 100644 index 00000000..0969250f --- /dev/null +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -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( + 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( + 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)), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index 9eeb75d4..f60debe8 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -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, ); } } diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index ff314d74..6f4a2fc1 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -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, ], ), diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 780d3c62..dca91de3 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -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 { 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 { child: VideoControlsHeader( metadata: widget.metadata, style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine, + onBack: widget.onBack, ), ); @@ -336,74 +346,92 @@ class DesktopVideoControlsState extends State { 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( - 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( + 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 { focusNodes: _trackControlFocusNodes, onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, + canControl: widget.canControl, ), ], ), diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index b0f439a4..15b3e81a 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -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( 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, ), ), ); diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 0105648c..ee07180e 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -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 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 { return ListView( children: [ - // Playback Speed - StreamBuilder( - 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( + 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( diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 7cb536c7..5635ff28 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 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 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 with WindowListen @override void dispose() { + HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); _hideTimer?.cancel(); _feedbackTimer?.cancel(); _resizeDebounceTimer?.cancel(); @@ -676,49 +709,55 @@ class _PlexVideoControlsState extends State 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 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 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 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 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 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 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 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 with WindowListen onCancelAutoHide: () => _hideTimer?.cancel(), onStartAutoHide: _startHideTimer, serverId: widget.metadata.serverId ?? '', + onBack: widget.onBack, + canControl: widget.canControl, ), ), ), diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 5b1781d9..68bc097a 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -25,6 +25,9 @@ class TimelineSlider extends StatelessWidget { /// Called when focus changes. final ValueChanged? 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, diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 127ce662..31d439bc 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -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(); }, diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index 15936368..894b793f 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -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()), diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index ce4b86b3..84403c31 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -30,6 +30,9 @@ class VideoTimelineBar extends StatelessWidget { /// Called when focus changes. final ValueChanged? 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, ); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 25bbda5c..2a1e7c30 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -15,6 +16,9 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); + flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); g_autoptr(FlPluginRegistrar) gamepads_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin"); gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 11f8fb02..d62b1d90 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_webrtc gamepads_linux hotkey_manager_linux os_media_controls diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 245595b6..56fa71cb 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import connectivity_plus import device_info_plus import file_picker +import flutter_webrtc import gamepads_darwin import hotkey_manager_macos import macos_window_utils @@ -15,6 +16,7 @@ import os_media_controls import package_info_plus import path_provider_foundation import screen_retriever_macos +import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs @@ -26,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) @@ -33,6 +36,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9c0801c5..d213948f 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,6 +5,9 @@ PODS: - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS + - flutter_webrtc (1.2.0): + - FlutterMacOS + - WebRTC-SDK (= 137.7151.04) - FlutterMacOS (1.0.0) - gamepads_darwin (0.1.1): - FlutterMacOS @@ -23,6 +26,8 @@ PODS: - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -58,6 +63,7 @@ PODS: - FlutterMacOS - wakelock_plus (0.0.1): - FlutterMacOS + - WebRTC-SDK (137.7151.04) - window_manager (0.2.0): - FlutterMacOS @@ -65,6 +71,7 @@ DEPENDENCIES: - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`) - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) @@ -73,6 +80,7 @@ DEPENDENCIES: - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) @@ -84,6 +92,7 @@ SPEC REPOS: trunk: - HotKey - sqlite3 + - WebRTC-SDK EXTERNAL SOURCES: connectivity_plus: @@ -92,6 +101,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + flutter_webrtc: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos FlutterMacOS: :path: Flutter/ephemeral gamepads_darwin: @@ -108,6 +119,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite_darwin: @@ -125,6 +138,7 @@ SPEC CHECKSUMS: connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 gamepads_darwin: 643b6a69e20ca678fae83781b7f7fc8f15f5d710 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 @@ -134,12 +148,14 @@ SPEC CHECKSUMS: package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b + WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d diff --git a/pubspec.lock b/pubspec.lock index 4b8bf258..9cd87caa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: "51bcda4ba5d7dd9e65a309244ce3ac0b58025e6e1f6d7442cee4cd02134ef65f" + url: "https://pub.dev" + source: hosted + version: "1.6.0" dbus: dependency: transitive description: @@ -353,6 +361,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.3" + events_emitter: + dependency: transitive + description: + name: events_emitter + sha256: a075477bdf9c8c0c31bb7c7b7bdd357b4486c34f30163119f96de4e7f54abeff + url: "https://pub.dev" + source: hosted + version: "0.5.2" fake_async: dependency: transitive description: @@ -456,6 +472,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_webrtc: + dependency: "direct overridden" + description: + name: flutter_webrtc + sha256: "71a38363a5b50603e405c275f30de2eb90f980b0cc94b0e1e9d8b9d6a6b03bf0" + url: "https://pub.dev" + source: hosted + version: "1.2.1" frontend_server_client: dependency: transitive description: @@ -881,6 +905,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + peerdart: + dependency: "direct main" + description: + name: peerdart + sha256: "1d0db041d42194f42e57d8889d029fb8d107610bf1062b39f43f4651de547e3c" + url: "https://pub.dev" + source: hosted + version: "0.5.6" petitparser: dependency: transitive description: @@ -997,10 +1029,10 @@ packages: dependency: transitive description: name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" url: "https://pub.dev" source: hosted - version: "0.28.0" + version: "0.27.7" saf_stream: dependency: "direct main" description: @@ -1057,6 +1089,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: "direct main" description: @@ -1462,6 +1510,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233" + url: "https://pub.dev" + source: hosted + version: "1.3.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7790670b..46656d81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: saf_util: ^0.11.0 saf_stream: ^0.12.2 material_symbols_icons: ^4.2892.0 + peerdart: ^0.5.6 + share_plus: ^10.0.0 dev_dependencies: flutter_test: @@ -57,6 +59,9 @@ dev_dependencies: dart_code_linter: ^3.1.1 drift_dev: ^2.14.0 +dependency_overrides: + flutter_webrtc: ^1.2.1 + flutter: uses-material-design: true assets: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 6f4f5bcb..dc365368 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,10 +7,12 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include #include +#include #include #include #include @@ -18,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FlutterWebRTCPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); GamepadsWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("GamepadsWindowsPluginCApi")); HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( @@ -26,6 +30,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bcb19105..9a4896ab 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,10 +4,12 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus + flutter_webrtc gamepads_windows hotkey_manager_windows os_media_controls screen_retriever_windows + share_plus sqlite3_flutter_libs url_launcher_windows window_manager