feat: watch together

This commit is contained in:
edde746
2025-12-18 08:42:18 +01:00
parent 79ac2793e3
commit 63ba2bb3de
38 changed files with 3909 additions and 183 deletions
+5
View File
@@ -23,6 +23,7 @@ import 'providers/playback_state_provider.dart';
import 'providers/download_provider.dart';
import 'providers/offline_mode_provider.dart';
import 'providers/offline_watch_provider.dart';
import 'watch_together/watch_together.dart';
import 'services/multi_server_manager.dart';
import 'services/offline_watch_sync_service.dart';
import 'services/data_aggregation_service.dart';
@@ -205,6 +206,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true),
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
@@ -344,6 +346,9 @@ class _SetupScreenState extends State<SetupScreen> {
// Migrate from single-server to multi-server if needed
await registry.migrateFromSingleServer();
// Refresh servers from API to get updated connection info (IPs may change)
await registry.refreshServersFromApi();
// Load all configured servers
final servers = await registry.getServers();
+113 -4
View File
@@ -30,6 +30,7 @@ import '../utils/layout_constants.dart';
import '../theme/mono_tokens.dart';
import 'auth_screen.dart';
import 'libraries/state_messages.dart';
import '../watch_together/watch_together.dart';
class DiscoverScreen extends StatefulWidget {
final VoidCallback? onBecameVisible;
@@ -72,8 +73,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// Hero and app bar focus
late FocusNode _heroFocusNode;
late FocusNode _refreshButtonFocusNode;
late FocusNode _watchTogetherButtonFocusNode;
late FocusNode _userButtonFocusNode;
bool _isRefreshFocused = false;
bool _isWatchTogetherFocused = false;
bool _isUserFocused = false;
/// Get the correct PlexClient for an item's server
@@ -155,8 +158,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_indicatorAnimationController = AnimationController(vsync: this, duration: _heroAutoScrollDuration);
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
_watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button');
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange);
_userButtonFocusNode.addListener(_onUserFocusChange);
_loadContent();
_startAutoScroll();
@@ -170,6 +175,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
}
void _onWatchTogetherFocusChange() {
if (mounted) {
setState(() {
_isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus;
});
}
}
void _onUserFocusChange() {
if (mounted) {
setState(() {
@@ -242,9 +255,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
return KeyEventResult.handled;
}
// RIGHT: Move to user button
// RIGHT: Move to watch together button
if (key.isRightKey) {
_userButtonFocusNode.requestFocus();
_watchTogetherButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
@@ -262,6 +275,46 @@ class _DiscoverScreenState extends State<DiscoverScreen>
return KeyEventResult.ignored;
}
/// Handle key events for the watch together button in app bar
KeyEventResult _handleWatchTogetherKeyEvent(FocusNode node, KeyEvent event) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// DOWN: Return to hero
if (key.isDownKey) {
_heroFocusNode.requestFocus();
return KeyEventResult.handled;
}
// LEFT: Move to refresh button
if (key.isLeftKey) {
_refreshButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// RIGHT: Move to user button
if (key.isRightKey) {
_userButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// UP: Block at boundary
if (key.isUpKey) {
return KeyEventResult.handled;
}
// SELECT: Navigate to Watch Together screen
if (key.isSelectKey) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen()));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Handle key events for the user button in app bar
KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) {
if (!event.isActionable) {
@@ -276,9 +329,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
return KeyEventResult.handled;
}
// LEFT: Move to refresh button
// LEFT: Move to watch together button
if (key.isLeftKey) {
_refreshButtonFocusNode.requestFocus();
_watchTogetherButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
@@ -306,6 +359,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_heroFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
_watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange);
_watchTogetherButtonFocusNode.dispose();
_userButtonFocusNode.removeListener(_onUserFocusChange);
_userButtonFocusNode.dispose();
super.dispose();
@@ -748,6 +803,60 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: IconButton(icon: const AppIcon(Symbols.refresh_rounded, fill: 1), onPressed: _loadContent),
),
),
// Watch Together button
Consumer<WatchTogetherProvider>(
builder: (context, watchTogether, child) {
return Focus(
focusNode: _watchTogetherButtonFocusNode,
onKeyEvent: _handleWatchTogetherKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isWatchTogetherFocused
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.group_rounded,
fill: watchTogether.isInSession ? 1 : 0,
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : null,
),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
),
tooltip: 'Watch Together',
),
// Badge showing participant count when in session
if (watchTogether.isInSession && watchTogether.participantCount > 1)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${watchTogether.participantCount}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
},
),
Consumer<UserProfileProvider>(
builder: (context, userProvider, child) {
return Focus(
+68
View File
@@ -5,6 +5,7 @@ import '../../services/plex_client.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../utils/platform_detector.dart';
import '../utils/video_player_navigation.dart';
import '../main.dart';
import '../mixins/refreshable.dart';
import '../navigation/navigation_tabs.dart';
@@ -23,6 +24,8 @@ import 'libraries/libraries_screen.dart';
import 'search_screen.dart';
import 'downloads/downloads_screen.dart';
import 'settings/settings_screen.dart';
import 'video_player_screen.dart';
import '../watch_together/watch_together.dart';
/// Provides access to the main screen's focus control.
class MainScreenFocusScope extends InheritedWidget {
@@ -100,6 +103,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
_screens = _buildScreens(_isOffline);
// Set up Watch Together callbacks immediately (must be synchronous to catch early messages)
if (!_isOffline) {
_setupWatchTogetherCallback();
}
// Set up data invalidation callback for profile switching (skip in offline mode)
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!_isOffline) {
@@ -118,6 +126,66 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
});
}
/// Set up the Watch Together navigation callback for guests
void _setupWatchTogetherCallback() {
try {
final watchTogether = context.read<WatchTogetherProvider>();
watchTogether.onMediaSwitched = (ratingKey, serverId, mediaTitle) async {
appLogger.d('WatchTogether: Media switch received - navigating to $mediaTitle');
await _navigateToWatchTogetherMedia(ratingKey, serverId);
};
watchTogether.onHostExitedPlayer = () {
appLogger.d('WatchTogether: Host exited player - exiting player for guest');
// Use rootNavigator to ensure we pop the video player even if nested
if (!mounted) return;
final navigator = Navigator.of(context, rootNavigator: true);
bool isVideoPlayerOnTop = false;
navigator.popUntil((route) {
if (route.isCurrent) {
isVideoPlayerOnTop = route.settings.name == kVideoPlayerRouteName;
}
return true;
});
if (isVideoPlayerOnTop && navigator.canPop()) {
navigator.pop();
}
};
} catch (e) {
appLogger.w('Could not set up Watch Together callback', error: e);
}
}
/// Navigate to media when host switches content in Watch Together session
Future<void> _navigateToWatchTogetherMedia(String ratingKey, String serverId) async {
if (!mounted) return; // Check before any context usage
try {
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(serverId);
if (client == null) {
appLogger.w('WatchTogether: Server $serverId not available');
return;
}
// Fetch the metadata for the new media
final metadata = await client.getMetadataWithImages(ratingKey);
if (metadata == null || !mounted) return;
// Use push to preserve WatchTogetherScreen in navigation stack
// VideoPlayerScreen handles its own replacement via onPlayerMediaSwitched
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
settings: const RouteSettings(name: kVideoPlayerRouteName),
builder: (_) => VideoPlayerScreen(metadata: metadata),
),
);
} catch (e) {
appLogger.e('WatchTogether: Failed to navigate to media', error: e);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
+43 -30
View File
@@ -120,42 +120,55 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
/// Build action buttons row (play, shuffle, download, mark watched)
Widget _buildActionButtons(PlexMetadata metadata) {
final playButtonLabel = _getPlayButtonLabel(metadata);
final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20);
Future<void> onPlayPressed() async {
// For TV shows, play the OnDeck episode if available
// Otherwise, play the first episode of the first season
if (metadata.isShow) {
if (_onDeckEpisode != null) {
appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}');
await navigateToVideoPlayerWithRefresh(
context,
metadata: _onDeckEpisode!,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
} else {
// No on deck episode, fetch first episode of first season
await _playFirstEpisode();
}
} else {
appLogger.d('Playing: ${metadata.title}');
// For movies or episodes, play directly
await navigateToVideoPlayerWithRefresh(
context,
metadata: metadata,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
}
}
return Row(
children: [
SizedBox(
height: 48,
child: FilledButton.icon(
child: FilledButton(
autofocus: InputModeTracker.isKeyboardMode(context),
onPressed: () async {
// For TV shows, play the OnDeck episode if available
// Otherwise, play the first episode of the first season
if (metadata.isShow) {
if (_onDeckEpisode != null) {
appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}');
await navigateToVideoPlayerWithRefresh(
context,
metadata: _onDeckEpisode!,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
} else {
// No on deck episode, fetch first episode of first season
await _playFirstEpisode();
}
} else {
appLogger.d('Playing: ${metadata.title}');
// For movies or episodes, play directly
await navigateToVideoPlayerWithRefresh(
context,
metadata: metadata,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
}
},
icon: AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20),
label: Text(_getPlayButtonLabel(metadata), style: const TextStyle(fontSize: 16)),
onPressed: onPlayPressed,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16)),
child: playButtonLabel.isNotEmpty
? Row(
mainAxisSize: MainAxisSize.min,
children: [
playButtonIcon,
const SizedBox(width: 8),
Text(playButtonLabel, style: const TextStyle(fontSize: 16)),
],
)
: playButtonIcon,
),
),
const SizedBox(width: 12),
+198 -5
View File
@@ -18,6 +18,7 @@ import '../models/plex_metadata.dart';
import '../utils/content_utils.dart';
import '../models/plex_media_info.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../services/episode_navigation_service.dart';
import '../services/media_controls_manager.dart';
@@ -37,6 +38,7 @@ import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart';
import '../widgets/video_controls/video_controls.dart';
import '../i18n/strings.g.dart';
import '../watch_together/providers/watch_together_provider.dart';
class VideoPlayerScreen extends StatefulWidget {
final PlexMetadata metadata;
@@ -61,6 +63,13 @@ class VideoPlayerScreen extends StatefulWidget {
}
class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindingObserver {
// Track the currently active video to guard against duplicate navigation
static String? _activeRatingKey;
static int? _activeMediaIndex;
static String? get activeRatingKey => _activeRatingKey;
static int? get activeMediaIndex => _activeMediaIndex;
Player? player;
bool _isPlayerInitialized = false;
PlexMetadata? _nextEpisode;
@@ -89,6 +98,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
VideoFilterManager? _videoFilterManager;
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
// Watch Together provider reference (stored early to use in dispose)
WatchTogetherProvider? _watchTogetherProvider;
/// Get the correct PlexClient for this metadata's server
PlexClient _getClientForMetadata(BuildContext context) {
return context.getClientForServer(widget.metadata.serverId!);
@@ -100,6 +112,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void initState() {
super.initState();
_activeRatingKey = widget.metadata.ratingKey;
_activeMediaIndex = widget.selectedMediaIndex;
appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}');
if (widget.preferredAudioTrack != null) {
appLogger.d(
@@ -628,6 +643,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
? Duration(milliseconds: widget.metadata.viewOffset!)
: null;
await player!.open(Media(result.videoUrl!, start: resumePosition));
// Attach player to Watch Together session for sync (if in session)
if (mounted && !widget.isOffline) {
_attachToWatchTogetherSession();
_notifyWatchTogetherMediaChange();
}
}
// Update available versions from the playback data
@@ -719,11 +740,147 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
});
}
/// Attach player to Watch Together session for playback sync
void _attachToWatchTogetherSession() {
try {
final watchTogether = context.read<WatchTogetherProvider>();
_watchTogetherProvider = watchTogether; // Store reference for use in dispose
if (watchTogether.isInSession && player != null) {
watchTogether.attachPlayer(player!);
appLogger.d('WatchTogether: Player attached for sync');
// If guest, handle mediaSwitch internally for proper navigation context
if (!watchTogether.isHost) {
watchTogether.onPlayerMediaSwitched = _handlePlayerMediaSwitch;
}
}
} catch (e) {
// Watch together provider not available or not in session - non-critical
appLogger.d('Could not attach player to watch together', error: e);
}
}
/// Detach player from Watch Together session
void _detachFromWatchTogetherSession() {
try {
final watchTogether = _watchTogetherProvider ?? context.read<WatchTogetherProvider>();
if (watchTogether.isInSession) {
watchTogether.detachPlayer();
appLogger.d('WatchTogether: Player detached');
}
watchTogether.onPlayerMediaSwitched = null; // Always clear player callback
} catch (e) {
// Non-critical
appLogger.d('Could not detach player from watch together', error: e);
}
}
/// Check if episode navigation controls should be enabled
/// Returns true if not in Watch Together session, or if user is the host
bool _canNavigateEpisodes() {
if (_watchTogetherProvider == null) return true;
if (!_watchTogetherProvider!.isInSession) return true;
return _watchTogetherProvider!.isHost;
}
/// Notify watch together session of current media change (host only)
/// If [metadata] is provided, uses that instead of widget.metadata (for episode navigation)
void _notifyWatchTogetherMediaChange({PlexMetadata? metadata}) {
final targetMetadata = metadata ?? widget.metadata;
try {
final watchTogether = context.read<WatchTogetherProvider>();
if (watchTogether.isHost && watchTogether.isInSession) {
watchTogether.setCurrentMedia(
ratingKey: targetMetadata.ratingKey!,
serverId: targetMetadata.serverId!,
mediaTitle: targetMetadata.title!,
);
}
} catch (e) {
// Watch together provider not available or not in session - non-critical
appLogger.d('Could not notify watch together of media change', error: e);
}
}
/// Handle media switch from host (guest only)
/// Uses VideoPlayerScreen's context for proper navigation (pushReplacement)
Future<void> _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async {
if (!mounted) return;
appLogger.d('WatchTogether: Guest handling media switch to $title');
// Fetch metadata for the new episode
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(serverId);
if (client == null) {
appLogger.w('WatchTogether: Server $serverId not found for media switch');
return;
}
final metadata = await client.getMetadataWithImages(ratingKey);
if (metadata == null || !mounted) {
appLogger.w('WatchTogether: Could not fetch metadata for $ratingKey');
return;
}
// Detach and dispose current player before switching to avoid sync calls on a disposed instance
await disposePlayerForNavigation();
// Use same navigation as local episode change (pushReplacement from player context)
_isReplacingWithVideo = true;
navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true);
}
/// Handle back button press
/// For non-host participants in Watch Together, shows leave session confirmation
Future<void> _handleBackButton() async {
// For non-host participants, show leave session confirmation
if (_watchTogetherProvider != null && _watchTogetherProvider!.isInSession && !_watchTogetherProvider!.isHost) {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Leave Session?'),
content: const Text('You will be removed from the session.'),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(dialogContext).colorScheme.error),
child: const Text('Leave'),
),
],
),
);
if (confirmed == true && mounted) {
await _watchTogetherProvider!.leaveSession();
if (mounted) Navigator.of(context).pop(true);
}
return;
}
// Default behavior for hosts or non-session users
Navigator.of(context).pop(true);
}
@override
void dispose() {
// Unregister app lifecycle observer
WidgetsBinding.instance.removeObserver(this);
// Notify Watch Together guests that host is exiting the player
// Use stored reference since context.read() may fail in dispose
// Skip if replacing with another video (episode navigation)
if (!_isReplacingWithVideo &&
_watchTogetherProvider != null &&
_watchTogetherProvider!.isHost &&
_watchTogetherProvider!.isInSession) {
_watchTogetherProvider!.notifyHostExitedPlayer();
}
// Detach from Watch Together session
_detachFromWatchTogetherSession();
// Dispose value notifiers
_isBuffering.dispose();
@@ -776,6 +933,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
player?.dispose();
if (_activeRatingKey == widget.metadata.ratingKey) {
_activeRatingKey = null;
_activeMediaIndex = null;
}
super.dispose();
}
@@ -839,6 +1000,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void> _playNext() async {
if (_nextEpisode == null || _isLoadingNext) return;
// Notify Watch Together of episode change before navigating
_notifyWatchTogetherMediaChange(metadata: _nextEpisode);
setState(() {
_isLoadingNext = true;
_showPlayNextDialog = false;
@@ -849,6 +1013,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void> _playPrevious() async {
if (_previousEpisode == null) return;
// Notify Watch Together of episode change before navigating
_notifyWatchTogetherMediaChange(metadata: _previousEpisode);
await _navigateToEpisode(_previousEpisode!);
}
@@ -1127,6 +1295,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_isDisposingForNavigation = true;
try {
_detachFromWatchTogetherSession();
_progressTracker?.sendProgress('stopped');
_progressTracker?.stopTracking();
await player?.dispose();
@@ -1155,9 +1324,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing
onPopInvokedWithResult: (didPop, result) {
// Allow programmatic back navigation from UI controls
if (!didPop) {
Navigator.of(context).pop(true);
}
if (!didPop) _handleBackButton();
},
child: Scaffold(
// Use transparent background on macOS when native video layer is active
@@ -1204,19 +1371,45 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
});
// Compute canControl from Watch Together provider
bool canControl = true;
try {
final watchTogether = this.context.read<WatchTogetherProvider>();
if (watchTogether.isInSession) {
canControl = watchTogether.canControl();
}
} catch (e) {
// Watch Together not available, default to can control
}
return Video(
player: player!,
controls: (context) => plexVideoControlsBuilder(
player!,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null ? _playPrevious : null,
onNext: (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null,
onPrevious: (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSeekCompleted: (position) {
// Notify Watch Together of seek for sync
// Note: canControl() check is done in sync manager, not here
// This matches play/pause behavior and avoids timing issues
try {
final watchTogether = this.context.read<WatchTogetherProvider>();
if (watchTogether.isInSession) {
watchTogether.onLocalSeek(position);
}
} catch (e) {
// Watch Together not available, ignore
}
},
onBack: _handleBackButton,
canControl: canControl,
),
);
},
+44
View File
@@ -95,6 +95,50 @@ class ServerRegistry {
appLogger.i('Cleared all servers from registry');
}
/// Refresh servers from Plex API and update storage
/// This updates connection info (IPs, ports) that may have changed
Future<void> refreshServersFromApi() async {
final token = _storage.getPlexToken();
if (token == null || token.isEmpty) {
appLogger.d('No Plex token available, skipping server refresh');
return;
}
try {
appLogger.d('Refreshing servers from Plex API...');
final authService = await PlexAuthService.create();
final freshServers = await authService.fetchServers(token);
if (freshServers.isEmpty) {
appLogger.w('API returned no servers, keeping existing data');
return;
}
// Get existing servers to preserve any local-only data
final existingServers = await getServers();
final existingIds = existingServers.map((s) => s.clientIdentifier).toSet();
// Update existing servers with fresh connection info, add new ones
final updatedServers = <PlexServer>[];
for (final fresh in freshServers) {
if (existingIds.contains(fresh.clientIdentifier)) {
// Server exists - use fresh data (updated IPs, connections)
updatedServers.add(fresh);
} else {
// New server - add it
updatedServers.add(fresh);
appLogger.i('Discovered new server: ${fresh.name}');
}
}
await saveServers(updatedServers);
appLogger.i('Refreshed ${updatedServers.length} servers from API');
} catch (e, stackTrace) {
appLogger.w('Failed to refresh servers from API, using cached data', error: e, stackTrace: stackTrace);
// Don't rethrow - we can continue with cached servers
}
}
/// Migrate from single server storage to multi-server
/// This is called during app startup to migrate existing users
Future<void> migrateFromSingleServer() async {
+1 -5
View File
@@ -335,11 +335,7 @@ class StorageService extends BaseSharedPreferencesService {
/// Clear all multi-server data
Future<void> clearMultiServerData() async {
await Future.wait([
clearServersList(),
clearServerOrder(),
_clearKeysWithPrefix(_prefixServerEndpoint),
]);
await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]);
}
/// Server Order (stored as JSON list of server IDs)
+4 -2
View File
@@ -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;
}
+14
View File
@@ -6,6 +6,8 @@ import '../screens/video_player_screen.dart';
import '../services/settings_service.dart';
import 'app_logger.dart';
const String kVideoPlayerRouteName = '/video_player';
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
///
/// This utility function provides a consistent way to navigate to the video player
@@ -54,7 +56,19 @@ Future<bool?> navigateToVideoPlayer(
}
}
// Prevent stacking an identical video player when already active
if (!usePushReplacement &&
metadata.ratingKey != null &&
VideoPlayerScreenState.activeRatingKey == metadata.ratingKey &&
VideoPlayerScreenState.activeMediaIndex == mediaIndex) {
appLogger.d(
'Video player already active for ${metadata.ratingKey} (mediaIndex=$mediaIndex), skipping duplicate navigation',
);
return null;
}
final route = PageRouteBuilder<bool>(
settings: const RouteSettings(name: kVideoPlayerRouteName),
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: metadata,
preferredAudioTrack: preferredAudioTrack,
+307
View File
@@ -0,0 +1,307 @@
import 'dart:convert';
import 'watch_session.dart';
/// Types of sync messages sent over the WebRTC data channel
enum SyncMessageType {
/// Start playback
play,
/// Pause playback
pause,
/// Seek to position
seek,
/// Buffering state changed
buffering,
/// Periodic position update (for drift correction)
positionSync,
/// Playback rate changed
rate,
/// Participant joined the session
join,
/// Participant left the session
leave,
/// Session configuration (sent by host on join)
sessionConfig,
/// Ping for latency measurement
ping,
/// Pong response
pong,
/// Media switch (host changed content)
mediaSwitch,
/// Host exited the video player
hostExitedPlayer,
/// Player is ready (attached and loaded)
playerReady,
}
/// A message sent over the WebRTC data channel for synchronization
class SyncMessage {
/// Type of this message
final SyncMessageType type;
/// Timestamp when this message was created (Unix ms)
final int timestamp;
/// Position in milliseconds (for seek, positionSync)
final int? positionMs;
/// Buffering state (for buffering message)
final bool? bufferingState;
/// Playback rate (for rate message)
final double? rate;
/// Peer ID of the sender
final String? peerId;
/// Display name of the sender (for join message)
final String? displayName;
/// Whether the sender is the host (for join message)
final bool? isHost;
/// Control mode (for sessionConfig message)
final ControlMode? controlMode;
/// Ping ID for matching pong responses
final int? pingId;
/// Rating key of the media (for mediaSwitch message)
final String? ratingKey;
/// Server ID of the media (for mediaSwitch message)
final String? serverId;
/// Title of the media (for mediaSwitch message)
final String? mediaTitle;
const SyncMessage({
required this.type,
required this.timestamp,
this.positionMs,
this.bufferingState,
this.rate,
this.peerId,
this.displayName,
this.isHost,
this.controlMode,
this.pingId,
this.ratingKey,
this.serverId,
this.mediaTitle,
});
/// Create a PLAY message
factory SyncMessage.play({String? peerId, Duration? position}) {
return SyncMessage(
type: SyncMessageType.play,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
positionMs: position?.inMilliseconds,
);
}
/// Create a PAUSE message
factory SyncMessage.pause({String? peerId}) {
return SyncMessage(type: SyncMessageType.pause, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId);
}
/// Create a SEEK message
factory SyncMessage.seek(Duration position, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.seek,
timestamp: DateTime.now().millisecondsSinceEpoch,
positionMs: position.inMilliseconds,
peerId: peerId,
);
}
/// Create a BUFFERING message
factory SyncMessage.buffering(bool isBuffering, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.buffering,
timestamp: DateTime.now().millisecondsSinceEpoch,
bufferingState: isBuffering,
peerId: peerId,
);
}
/// Create a POSITION_SYNC message
factory SyncMessage.positionSync(Duration position, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.positionSync,
timestamp: DateTime.now().millisecondsSinceEpoch,
positionMs: position.inMilliseconds,
peerId: peerId,
);
}
/// Create a RATE message
factory SyncMessage.rate(double playbackRate, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.rate,
timestamp: DateTime.now().millisecondsSinceEpoch,
rate: playbackRate,
peerId: peerId,
);
}
/// Create a JOIN message
factory SyncMessage.join({required String peerId, required String displayName, required bool isHost}) {
return SyncMessage(
type: SyncMessageType.join,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
displayName: displayName,
isHost: isHost,
);
}
/// Create a LEAVE message
factory SyncMessage.leave({required String peerId}) {
return SyncMessage(type: SyncMessageType.leave, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId);
}
/// Create a SESSION_CONFIG message (sent by host to new guests)
factory SyncMessage.sessionConfig({
required ControlMode controlMode,
required Duration currentPosition,
required bool isPlaying,
required double playbackRate,
String? peerId,
}) {
return SyncMessage(
type: SyncMessageType.sessionConfig,
timestamp: DateTime.now().millisecondsSinceEpoch,
controlMode: controlMode,
positionMs: currentPosition.inMilliseconds,
bufferingState: !isPlaying, // Reuse field: false = playing, true = paused
rate: playbackRate,
peerId: peerId,
);
}
/// Create a PING message
factory SyncMessage.ping(int pingId, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.ping,
timestamp: DateTime.now().millisecondsSinceEpoch,
pingId: pingId,
peerId: peerId,
);
}
/// Create a PONG message
factory SyncMessage.pong(int pingId, {String? peerId}) {
return SyncMessage(
type: SyncMessageType.pong,
timestamp: DateTime.now().millisecondsSinceEpoch,
pingId: pingId,
peerId: peerId,
);
}
/// Create a MEDIA_SWITCH message (sent by host when changing content)
factory SyncMessage.mediaSwitch({
required String ratingKey,
required String serverId,
required String mediaTitle,
String? peerId,
}) {
return SyncMessage(
type: SyncMessageType.mediaSwitch,
timestamp: DateTime.now().millisecondsSinceEpoch,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
peerId: peerId,
);
}
/// Create a HOST_EXITED_PLAYER message (sent by host when exiting video player)
factory SyncMessage.hostExitedPlayer({String? peerId}) {
return SyncMessage(
type: SyncMessageType.hostExitedPlayer,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
);
}
/// Create a PLAYER_READY message (sent when player is attached and ready)
factory SyncMessage.playerReady({required String peerId, required bool ready}) {
return SyncMessage(
type: SyncMessageType.playerReady,
timestamp: DateTime.now().millisecondsSinceEpoch,
peerId: peerId,
bufferingState: ready, // Reuse bufferingState field for ready status
);
}
/// Position as Duration (convenience getter)
Duration? get position => positionMs != null ? Duration(milliseconds: positionMs!) : null;
/// Serialize to JSON string for sending over data channel
String toJson() {
final map = <String, dynamic>{'t': type.name, 'ts': timestamp};
if (positionMs != null) map['pos'] = positionMs;
if (bufferingState != null) map['buf'] = bufferingState;
if (rate != null) map['r'] = rate;
if (peerId != null) map['pid'] = peerId;
if (displayName != null) map['name'] = displayName;
if (isHost != null) map['host'] = isHost;
if (controlMode != null) map['ctrl'] = controlMode!.index;
if (pingId != null) map['ping'] = pingId;
if (ratingKey != null) map['rk'] = ratingKey;
if (serverId != null) map['sid'] = serverId;
if (mediaTitle != null) map['title'] = mediaTitle;
return jsonEncode(map);
}
/// Parse from JSON string received from data channel
factory SyncMessage.fromJson(String jsonString) {
final map = jsonDecode(jsonString) as Map<String, dynamic>;
final typeString = map['t'] as String;
final type = SyncMessageType.values.firstWhere(
(t) => t.name == typeString,
orElse: () => throw FormatException('Unknown message type: $typeString'),
);
return SyncMessage(
type: type,
timestamp: map['ts'] as int,
positionMs: map['pos'] as int?,
bufferingState: map['buf'] as bool?,
rate: (map['r'] as num?)?.toDouble(),
peerId: map['pid'] as String?,
displayName: map['name'] as String?,
isHost: map['host'] as bool?,
controlMode: map['ctrl'] != null ? ControlMode.values[map['ctrl'] as int] : null,
pingId: map['ping'] as int?,
ratingKey: map['rk'] as String?,
serverId: map['sid'] as String?,
mediaTitle: map['title'] as String?,
);
}
@override
String toString() {
return 'SyncMessage(type: $type, timestamp: $timestamp, positionMs: $positionMs, '
'bufferingState: $bufferingState, rate: $rate, peerId: $peerId)';
}
}
@@ -0,0 +1,180 @@
/// Session role - whether this device is the host or a guest
enum SessionRole { host, guest }
/// Control mode - who can control playback
enum ControlMode {
/// Only the host can control playback
hostOnly,
/// Anyone in the session can control playback
anyone,
}
/// Current state of the watch together session
enum SessionState {
/// Not connected to any session
disconnected,
/// Attempting to connect/create session
connecting,
/// Successfully connected to session
connected,
/// Connection error occurred
error,
}
/// Represents a participant in a watch together session
class Participant {
final String peerId;
final String displayName;
final bool isHost;
Duration lastKnownPosition;
bool isBuffering;
Participant({
required this.peerId,
required this.displayName,
required this.isHost,
this.lastKnownPosition = Duration.zero,
this.isBuffering = false,
});
Participant copyWith({
String? peerId,
String? displayName,
bool? isHost,
Duration? lastKnownPosition,
bool? isBuffering,
}) {
return Participant(
peerId: peerId ?? this.peerId,
displayName: displayName ?? this.displayName,
isHost: isHost ?? this.isHost,
lastKnownPosition: lastKnownPosition ?? this.lastKnownPosition,
isBuffering: isBuffering ?? this.isBuffering,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) || other is Participant && runtimeType == other.runtimeType && peerId == other.peerId;
@override
int get hashCode => peerId.hashCode;
}
/// Represents a watch together session
class WatchSession {
/// Unique identifier for this session (used for joining)
final String sessionId;
/// This device's role in the session
final SessionRole role;
/// Who can control playback
final ControlMode controlMode;
/// Current connection state
final SessionState state;
/// List of participants in the session
final List<Participant> participants;
/// Error message if state is error
final String? errorMessage;
/// Rating key of the media being watched (for validation)
final String? mediaRatingKey;
/// Server ID of the media being watched (same-server requirement)
final String? mediaServerId;
/// Title of the media being watched
final String? mediaTitle;
/// The host's peer ID (used to identify host messages)
final String? hostPeerId;
const WatchSession({
required this.sessionId,
required this.role,
required this.controlMode,
required this.state,
this.participants = const [],
this.errorMessage,
this.mediaRatingKey,
this.mediaServerId,
this.mediaTitle,
this.hostPeerId,
});
/// Whether this device is the host
bool get isHost => role == SessionRole.host;
/// Whether the session is currently connected
bool get isConnected => state == SessionState.connected;
/// Number of participants (including self)
int get participantCount => participants.length;
WatchSession copyWith({
String? sessionId,
SessionRole? role,
ControlMode? controlMode,
SessionState? state,
List<Participant>? participants,
String? errorMessage,
String? mediaRatingKey,
String? mediaServerId,
String? mediaTitle,
String? hostPeerId,
}) {
return WatchSession(
sessionId: sessionId ?? this.sessionId,
role: role ?? this.role,
controlMode: controlMode ?? this.controlMode,
state: state ?? this.state,
participants: participants ?? this.participants,
errorMessage: errorMessage ?? this.errorMessage,
mediaRatingKey: mediaRatingKey ?? this.mediaRatingKey,
mediaServerId: mediaServerId ?? this.mediaServerId,
mediaTitle: mediaTitle ?? this.mediaTitle,
hostPeerId: hostPeerId ?? this.hostPeerId,
);
}
/// Create a new session as host
factory WatchSession.createAsHost({
required String sessionId,
required String hostPeerId,
required ControlMode controlMode,
String? mediaRatingKey,
String? mediaServerId,
String? mediaTitle,
}) {
return WatchSession(
sessionId: sessionId,
role: SessionRole.host,
controlMode: controlMode,
state: SessionState.connecting,
hostPeerId: hostPeerId,
mediaRatingKey: mediaRatingKey,
mediaServerId: mediaServerId,
mediaTitle: mediaTitle,
participants: [],
);
}
/// Create a session as guest (joining)
factory WatchSession.joinAsGuest({required String sessionId}) {
return WatchSession(
sessionId: sessionId,
role: SessionRole.guest,
controlMode: ControlMode.hostOnly, // Will be updated when connected
state: SessionState.connecting,
participants: [],
);
}
}
@@ -0,0 +1,481 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../../mpv/mpv.dart';
import '../../utils/app_logger.dart';
import '../models/sync_message.dart';
import '../models/watch_session.dart';
import '../services/watch_together_peer_service.dart';
import '../services/watch_together_sync_manager.dart';
/// Callback type for when media switches (for guest navigation)
typedef MediaSwitchCallback = void Function(String ratingKey, String serverId, String mediaTitle);
/// Provider for Watch Together functionality
///
/// This provider manages:
/// - Session creation/joining
/// - Peer connections
/// - Playback synchronization
/// - Participant list
/// - Media switching across the session
class WatchTogetherProvider with ChangeNotifier {
WatchSession? _session;
WatchTogetherPeerService? _peerService;
WatchTogetherSyncManager? _syncManager;
final List<Participant> _participants = [];
bool _isSyncing = false;
String _displayName = 'User';
/// Generate a random display name for this session
static String _generateDisplayName() {
const adjectives = ['Happy', 'Sleepy', 'Sunny', 'Cozy', 'Chill', 'Swift', 'Brave', 'Calm', 'Jolly', 'Lucky'];
const nouns = ['Panda', 'Koala', 'Fox', 'Owl', 'Cat', 'Dog', 'Bear', 'Bunny', 'Duck', 'Penguin'];
final random = Random();
return '${adjectives[random.nextInt(adjectives.length)]} ${nouns[random.nextInt(nouns.length)]}';
}
/// Callback for when host switches media (guests should navigate)
/// Used by MainScreen when VideoPlayerScreen is not active
MediaSwitchCallback? onMediaSwitched;
/// Callback for VideoPlayerScreen to handle media switch internally (guest only)
/// When set, takes priority over onMediaSwitched for proper navigation context
MediaSwitchCallback? onPlayerMediaSwitched;
/// Callback for when host exits the video player (guests should exit too)
VoidCallback? onHostExitedPlayer;
// Stream subscriptions
StreamSubscription<String>? _peerConnectedSubscription;
StreamSubscription<String>? _peerDisconnectedSubscription;
StreamSubscription<SyncMessage>? _messageSubscription;
StreamSubscription<PeerError>? _errorSubscription;
// Getters
bool get isInSession => _session != null && _session!.state != SessionState.disconnected;
bool get isHost => _session?.isHost ?? false;
bool get isConnected => _session?.isConnected ?? false;
bool get isSyncing => _isSyncing;
WatchSession? get session => _session;
List<Participant> get participants => List.unmodifiable(_participants);
int get participantCount => _participants.length;
ControlMode get controlMode => _session?.controlMode ?? ControlMode.hostOnly;
String? get sessionId => _session?.sessionId;
WatchTogetherSyncManager? get syncManager => _syncManager;
// Current media getters
String? get currentMediaRatingKey => _session?.mediaRatingKey;
String? get currentMediaServerId => _session?.mediaServerId;
String? get currentMediaTitle => _session?.mediaTitle;
/// Set the display name for this user
void setDisplayName(String name) {
_displayName = name;
}
/// Create a new watch together session as host
Future<String> createSession({
required ControlMode controlMode,
String? mediaRatingKey,
String? mediaServerId,
String? mediaTitle,
}) async {
// Clean up any existing session
await leaveSession();
appLogger.d('WatchTogether: Creating session with control mode: $controlMode');
_peerService = WatchTogetherPeerService();
_setupPeerServiceListeners();
try {
final sessionId = await _peerService!.createSession();
_session = WatchSession.createAsHost(
sessionId: sessionId,
hostPeerId: _peerService!.myPeerId!,
controlMode: controlMode,
mediaRatingKey: mediaRatingKey,
mediaServerId: mediaServerId,
mediaTitle: mediaTitle,
).copyWith(state: SessionState.connected);
// Generate a random display name and add self to participants
_displayName = _generateDisplayName();
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true));
_syncManager = WatchTogetherSyncManager(
peerService: _peerService!,
session: _session!,
displayName: _displayName,
);
_syncManager!.onSyncStateChanged = (isSyncing) {
_isSyncing = isSyncing;
notifyListeners();
};
notifyListeners();
appLogger.d('WatchTogether: Session created: $sessionId');
return sessionId;
} catch (e) {
appLogger.e('WatchTogether: Failed to create session', error: e);
_session = _session?.copyWith(state: SessionState.error, errorMessage: e.toString());
notifyListeners();
rethrow;
}
}
/// Join an existing session as guest
Future<void> joinSession(String sessionId) async {
// Clean up any existing session
await leaveSession();
appLogger.d('WatchTogether: Joining session: $sessionId');
_peerService = WatchTogetherPeerService();
_setupPeerServiceListeners();
_session = WatchSession.joinAsGuest(sessionId: sessionId);
notifyListeners();
try {
await _peerService!.joinSession(sessionId);
// Session will be fully configured when we receive sessionConfig from host
_session = _session!.copyWith(state: SessionState.connected, hostPeerId: 'wt-${sessionId.toUpperCase()}');
// Generate a random display name for this session
_displayName = _generateDisplayName();
_syncManager = WatchTogetherSyncManager(
peerService: _peerService!,
session: _session!,
displayName: _displayName,
);
_syncManager!.onSessionConfigReceived = (controlMode) {
_session = _session!.copyWith(controlMode: controlMode);
_syncManager!.updateSession(_session!);
notifyListeners();
};
_syncManager!.onSyncStateChanged = (isSyncing) {
_isSyncing = isSyncing;
notifyListeners();
};
// Add self to participants
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false));
// Announce join to other participants
_syncManager!.announceJoin(_displayName);
notifyListeners();
appLogger.d('WatchTogether: Joined session successfully');
} catch (e) {
appLogger.e('WatchTogether: Failed to join session', error: e);
_session = _session?.copyWith(state: SessionState.error, errorMessage: e.toString());
notifyListeners();
rethrow;
}
}
/// Leave the current session
Future<void> leaveSession() async {
if (_session == null) return;
appLogger.d('WatchTogether: Leaving session');
// Announce leave if connected
_syncManager?.announceLeave();
// Clean up subscriptions
_peerConnectedSubscription?.cancel();
_peerDisconnectedSubscription?.cancel();
_messageSubscription?.cancel();
_errorSubscription?.cancel();
_peerConnectedSubscription = null;
_peerDisconnectedSubscription = null;
_messageSubscription = null;
_errorSubscription = null;
// Clean up services
_syncManager?.dispose();
_syncManager = null;
await _peerService?.disconnect();
_peerService?.dispose();
_peerService = null;
_session = null;
_participants.clear();
_isSyncing = false;
notifyListeners();
appLogger.d('WatchTogether: Session left');
}
/// Attach a player to the sync manager
void attachPlayer(Player player) {
if (_syncManager == null) {
appLogger.w('WatchTogether: Cannot attach player - no sync manager');
return;
}
// Initialize sync manager with existing participants (may have joined before player attached)
final peerIds = _participants.map((p) => p.peerId).toList();
_syncManager!.initializeParticipants(peerIds);
_syncManager!.attachPlayer(player);
appLogger.d('WatchTogether: Player attached to sync manager');
}
/// Detach the player from the sync manager
void detachPlayer() {
_syncManager?.detachPlayer();
appLogger.d('WatchTogether: Player detached from sync manager');
}
/// Set up listeners for peer service events
void _setupPeerServiceListeners() {
_peerConnectedSubscription = _peerService!.onPeerConnected.listen((peerId) {
appLogger.d('WatchTogether: Peer connected: $peerId');
// Peer will announce themselves with a join message
notifyListeners();
});
_peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((peerId) {
appLogger.d('WatchTogether: Peer disconnected: $peerId');
_participants.removeWhere((p) => p.peerId == peerId);
// If host disconnected, end session for guests
if (!isHost && peerId == _session?.hostPeerId) {
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session');
// Ensure guests exit the player if host disappears
onHostExitedPlayer?.call();
}
notifyListeners();
});
_messageSubscription = _peerService!.onMessageReceived.listen((message) {
_handleSyncMessage(message);
});
_errorSubscription = _peerService!.onError.listen((error) {
appLogger.e('WatchTogether: Peer error: ${error.message}');
// Update session state on error
if (_session != null && _session!.state == SessionState.connected) {
_session = _session!.copyWith(state: SessionState.error, errorMessage: error.message);
notifyListeners();
}
});
}
/// Handle incoming sync messages for participant management
void _handleSyncMessage(SyncMessage message) {
switch (message.type) {
case SyncMessageType.join:
if (message.peerId != null && message.displayName != null) {
// Check if participant already exists
final existingIndex = _participants.indexWhere((p) => p.peerId == message.peerId);
if (existingIndex >= 0) {
// Update existing participant
_participants[existingIndex] = Participant(
peerId: message.peerId!,
displayName: message.displayName!,
isHost: message.isHost ?? false,
);
} else {
// Add new participant
_participants.add(
Participant(peerId: message.peerId!, displayName: message.displayName!, isHost: message.isHost ?? false),
);
}
// If we're the host and this is a guest joining, send our join info back
// so they add us to their participants list
if (isHost && message.peerId != _peerService?.myPeerId && !(message.isHost ?? false)) {
_peerService?.sendTo(
message.peerId!,
SyncMessage.join(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true),
);
}
notifyListeners();
}
break;
case SyncMessageType.leave:
if (message.peerId != null) {
_participants.removeWhere((p) => p.peerId == message.peerId);
notifyListeners();
}
break;
case SyncMessageType.buffering:
if (message.peerId != null) {
final index = _participants.indexWhere((p) => p.peerId == message.peerId);
if (index >= 0) {
_participants[index] = _participants[index].copyWith(isBuffering: message.bufferingState ?? false);
notifyListeners();
}
}
break;
case SyncMessageType.positionSync:
if (message.peerId != null && message.position != null) {
final index = _participants.indexWhere((p) => p.peerId == message.peerId);
if (index >= 0) {
_participants[index] = _participants[index].copyWith(lastKnownPosition: message.position);
// Don't notify for position updates - too frequent
}
}
break;
case SyncMessageType.mediaSwitch:
_handleMediaSwitch(message);
break;
case SyncMessageType.hostExitedPlayer:
_handleHostExitedPlayer(message);
break;
case SyncMessageType.sessionConfig:
_handleSessionConfig(message);
break;
default:
break;
}
}
/// Handle session config from host (guest only)
/// This is handled at provider level so it's processed even before player is attached
void _handleSessionConfig(SyncMessage message) {
if (isHost) return; // Host doesn't need to process config
if (message.controlMode != null) {
appLogger.d('WatchTogether: Received session config, controlMode: ${message.controlMode}');
_session = _session!.copyWith(controlMode: message.controlMode);
_syncManager?.updateSession(_session!); // Update sync manager if it exists
notifyListeners();
}
}
/// Called when user seeks locally (to broadcast to peers)
void onLocalSeek(Duration position) {
_syncManager?.onLocalSeek(position);
}
/// Whether the current user can control playback
bool canControl() {
if (_session == null) return true; // Not in session, can control
if (_session!.controlMode == ControlMode.anyone) return true;
return isHost;
}
/// Set the current media (host only) and broadcast to guests
///
/// Call this when the host starts playing new content.
/// Guests will receive a media switch notification and should navigate.
void setCurrentMedia({required String ratingKey, required String serverId, required String mediaTitle}) {
if (!isHost || _session == null || _peerService == null) {
appLogger.w('WatchTogether: Cannot set media - not host or not in session');
return;
}
appLogger.d('WatchTogether: Host setting current media: $mediaTitle (ratingKey: $ratingKey)');
// Update session with new media info
_session = _session!.copyWith(mediaRatingKey: ratingKey, mediaServerId: serverId, mediaTitle: mediaTitle);
// Broadcast media switch to all guests
_peerService!.broadcast(
SyncMessage.mediaSwitch(
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
peerId: _peerService!.myPeerId,
),
);
notifyListeners();
}
/// Handle media switch message from host (guest only)
void _handleMediaSwitch(SyncMessage message) {
if (isHost) return; // Host doesn't need to handle their own switch
// Skip if already playing this media (prevents duplicate navigation from duplicate messages)
if (_session?.mediaRatingKey == message.ratingKey) {
appLogger.d('WatchTogether: Ignoring duplicate media switch for ${message.ratingKey}');
return;
}
if (message.ratingKey == null || message.serverId == null || message.mediaTitle == null) {
appLogger.w('WatchTogether: Received incomplete media switch message');
return;
}
appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}');
// Update local session state
_session = _session?.copyWith(
mediaRatingKey: message.ratingKey,
mediaServerId: message.serverId,
mediaTitle: message.mediaTitle,
);
notifyListeners();
// If player handler is set (VideoPlayerScreen is active), use that for proper navigation context
if (onPlayerMediaSwitched != null) {
onPlayerMediaSwitched!(message.ratingKey!, message.serverId!, message.mediaTitle!);
return;
}
// Otherwise, trigger app-level navigation callback (MainScreen handles it)
onMediaSwitched?.call(message.ratingKey!, message.serverId!, message.mediaTitle!);
}
/// Notify guests that host is exiting the video player
///
/// Call this from video player dispose when host exits.
void notifyHostExitedPlayer() {
if (!isHost || _session == null || _peerService == null) {
return;
}
appLogger.d('WatchTogether: Host exiting player, notifying guests');
_peerService!.broadcast(SyncMessage.hostExitedPlayer(peerId: _peerService!.myPeerId));
}
/// Handle host exited player message (guest only)
void _handleHostExitedPlayer(SyncMessage message) {
if (isHost) return; // Host doesn't need to handle their own exit
appLogger.d('WatchTogether: Host exited player, callback set: ${onHostExitedPlayer != null}');
// Trigger callback for the app to navigate guest out of player
if (onHostExitedPlayer != null) {
onHostExitedPlayer!.call();
} else {
appLogger.w('WatchTogether: onHostExitedPlayer callback not set!');
}
}
@override
void dispose() {
leaveSession();
super.dispose();
}
}
@@ -0,0 +1,356 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../utils/app_logger.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../models/watch_session.dart';
import '../providers/watch_together_provider.dart';
import '../widgets/join_session_dialog.dart';
/// Main screen for Watch Together functionality
///
/// Allows users to:
/// - Create a new watch session
/// - Join an existing session
/// - View active session info and participants
/// - Leave/end session
class WatchTogetherScreen extends StatelessWidget {
const WatchTogetherScreen({super.key});
@override
Widget build(BuildContext context) {
return Consumer<WatchTogetherProvider>(
builder: (context, watchTogether, child) {
// Non-hosts must use "Leave Session" button - disable back navigation and hide button
final canGoBack = watchTogether.isHost || !watchTogether.isInSession;
return PopScope(
canPop: canGoBack,
child: FocusedScrollScaffold(
title: const Text('Watch Together'),
automaticallyImplyLeading: canGoBack,
slivers: watchTogether.isInSession
? _buildActiveSessionSlivers(watchTogether)
: [SliverFillRemaining(hasScrollBody: false, child: _NotInSessionView(watchTogether: watchTogether))],
),
);
},
);
}
List<Widget> _buildActiveSessionSlivers(WatchTogetherProvider watchTogether) {
return [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: _ActiveSessionContent(watchTogether: watchTogether),
),
),
),
),
];
}
}
/// View shown when not in a session
class _NotInSessionView extends StatefulWidget {
final WatchTogetherProvider watchTogether;
const _NotInSessionView({required this.watchTogether});
@override
State<_NotInSessionView> createState() => _NotInSessionViewState();
}
class _NotInSessionViewState extends State<_NotInSessionView> {
bool _isCreating = false;
bool _isJoining = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.group_rounded, size: 80, color: theme.colorScheme.primary),
const SizedBox(height: 24),
Text('Watch Together', style: theme.textTheme.headlineMedium, textAlign: TextAlign.center),
const SizedBox(height: 8),
Text(
'Watch content in sync with friends and family',
style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isCreating || _isJoining ? null : _createSession,
icon: _isCreating
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Symbols.add_rounded),
label: Text(_isCreating ? 'Creating...' : 'Create Session'),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isCreating || _isJoining ? null : _joinSession,
icon: _isJoining
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Symbols.group_add_rounded),
label: Text(_isJoining ? 'Joining...' : 'Join Session'),
),
),
],
),
),
),
);
}
Future<void> _createSession() async {
final controlMode = await _showControlModeDialog();
if (controlMode == null || !mounted) return;
setState(() => _isCreating = true);
try {
await widget.watchTogether.createSession(controlMode: controlMode);
} catch (e) {
appLogger.e('Failed to create session', error: e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to create session: $e')));
}
} finally {
if (mounted) {
setState(() => _isCreating = false);
}
}
}
Future<ControlMode?> _showControlModeDialog() {
return showDialog<ControlMode>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Control Mode'),
content: const Text('Who can control playback?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, ControlMode.hostOnly), child: const Text('Host Only')),
FilledButton(onPressed: () => Navigator.pop(context, ControlMode.anyone), child: const Text('Anyone')),
],
),
);
}
Future<void> _joinSession() async {
final sessionId = await showJoinSessionDialog(context);
if (sessionId == null || !mounted) return;
setState(() => _isJoining = true);
try {
await widget.watchTogether.joinSession(sessionId);
} catch (e) {
appLogger.e('Failed to join session', error: e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to join session: $e')));
}
} finally {
if (mounted) {
setState(() => _isJoining = false);
}
}
}
}
/// Content shown when in an active session (without scroll wrapper)
class _ActiveSessionContent extends StatelessWidget {
final WatchTogetherProvider watchTogether;
const _ActiveSessionContent({required this.watchTogether});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final session = watchTogether.session!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Session Info Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
watchTogether.isHost ? Symbols.star_rounded : Symbols.group_rounded,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
watchTogether.isHost ? 'Hosting Session' : 'In Session',
style: theme.textTheme.titleMedium,
),
Text(
'Code: ${session.sessionId}',
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
Icon(
session.controlMode == ControlMode.anyone
? Symbols.groups_rounded
: Symbols.admin_panel_settings_rounded,
size: 20,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
session.controlMode == ControlMode.anyone
? 'Anyone can control playback'
: 'Host controls playback',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Participants Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.people_rounded, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Text('Participants (${watchTogether.participantCount})', style: theme.textTheme.titleMedium),
],
),
const SizedBox(height: 12),
...watchTogether.participants.map(
(participant) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
participant.isHost ? Symbols.star_rounded : Symbols.person_rounded,
size: 20,
color: participant.isHost ? Colors.amber : theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Text(participant.displayName, style: theme.textTheme.bodyMedium),
if (participant.isHost) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Host',
style: theme.textTheme.labelSmall?.copyWith(color: Colors.amber.shade700),
),
),
],
if (participant.isBuffering) ...[
const SizedBox(width: 8),
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
),
],
],
),
),
),
],
),
),
),
const SizedBox(height: 24),
// Leave/End Session Button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _leaveSession(context),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(color: theme.colorScheme.error),
),
icon: Icon(watchTogether.isHost ? Symbols.close_rounded : Symbols.logout_rounded),
label: Text(watchTogether.isHost ? 'End Session' : 'Leave Session'),
),
),
],
);
}
Future<void> _leaveSession(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(watchTogether.isHost ? 'End Session?' : 'Leave Session?'),
content: Text(
watchTogether.isHost
? 'This will end the session for all participants.'
: 'You will be removed from the session.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
child: Text(watchTogether.isHost ? 'End' : 'Leave'),
),
],
),
);
if (confirmed == true) {
await watchTogether.leaveSession();
}
}
}
@@ -0,0 +1,356 @@
import 'dart:async';
import 'package:peerdart/peerdart.dart';
import 'package:uuid/uuid.dart';
import '../../utils/app_logger.dart';
import '../models/sync_message.dart';
/// Error types that can occur in the peer service
enum PeerErrorType { connectionFailed, peerDisconnected, dataChannelError, serverError, timeout, unknown }
/// Represents an error in the peer service
class PeerError {
final PeerErrorType type;
final String message;
final dynamic originalError;
const PeerError({required this.type, required this.message, this.originalError});
@override
String toString() => 'PeerError($type): $message';
}
/// Service for managing WebRTC peer connections using PeerJS
///
/// This service handles:
/// - Creating sessions (as host)
/// - Joining sessions (as guest)
/// - Sending/receiving sync messages over data channels
/// - Managing multiple peer connections
class WatchTogetherPeerService {
Peer? _peer;
final Map<String, DataConnection> _connections = {};
String? _sessionId;
String? _myPeerId;
bool _isHost = false;
// Stream controllers for events
final _peerConnectedController = StreamController<String>.broadcast();
final _peerDisconnectedController = StreamController<String>.broadcast();
final _messageReceivedController = StreamController<SyncMessage>.broadcast();
final _errorController = StreamController<PeerError>.broadcast();
final _connectionStateController = StreamController<bool>.broadcast();
// Reconnection state
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 3;
Timer? _reconnectTimer;
/// Stream of peer IDs when a new peer connects
Stream<String> get onPeerConnected => _peerConnectedController.stream;
/// Stream of peer IDs when a peer disconnects
Stream<String> get onPeerDisconnected => _peerDisconnectedController.stream;
/// Stream of sync messages received from peers
Stream<SyncMessage> get onMessageReceived => _messageReceivedController.stream;
/// Stream of errors
Stream<PeerError> get onError => _errorController.stream;
/// Stream of connection state changes (true = connected, false = disconnected)
Stream<bool> get onConnectionStateChanged => _connectionStateController.stream;
/// Current session ID (null if not in a session)
String? get sessionId => _sessionId;
/// This peer's ID
String? get myPeerId => _myPeerId;
/// Whether this peer is the host
bool get isHost => _isHost;
/// Whether currently connected to a session
bool get isConnected => _peer != null && _connections.isNotEmpty;
/// List of connected peer IDs
List<String> get connectedPeers => _connections.keys.toList();
/// Generate a short, readable session ID
String _generateSessionId() {
// Use first 8 characters of UUID for readability
return const Uuid().v4().substring(0, 8).toUpperCase();
}
/// Create a new session as host
///
/// Returns the session ID that others can use to join
Future<String> createSession() async {
if (_peer != null) {
await disconnect();
}
_isHost = true;
_sessionId = _generateSessionId();
_reconnectAttempts = 0;
// Create peer with session ID as the peer ID so guests can connect directly
final completer = Completer<String>();
try {
_peer = Peer(id: 'wt-$_sessionId');
_peer!.on('open').listen((id) {
_myPeerId = id as String;
appLogger.d('WatchTogether: Host peer opened with ID: $_myPeerId');
_connectionStateController.add(true);
if (!completer.isCompleted) {
completer.complete(_sessionId);
}
});
_peer!.on('connection').listen((conn) {
final dataConn = conn as DataConnection;
_handleNewConnection(dataConn);
});
_peer!.on('error').listen((error) {
appLogger.e('WatchTogether: Peer error', error: error);
_errorController.add(
PeerError(type: PeerErrorType.serverError, message: error.toString(), originalError: error),
);
if (!completer.isCompleted) {
completer.completeError(error);
}
});
_peer!.on('disconnected').listen((_) {
appLogger.w('WatchTogether: Peer disconnected from server');
_handleDisconnectedFromServer();
});
_peer!.on('close').listen((_) {
appLogger.d('WatchTogether: Peer closed');
_connectionStateController.add(false);
});
} catch (e) {
appLogger.e('WatchTogether: Failed to create peer', error: e);
if (!completer.isCompleted) {
completer.completeError(e);
}
}
// Timeout after 10 seconds
return completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session');
},
);
}
/// Join an existing session as guest
Future<void> joinSession(String sessionId) async {
if (_peer != null) {
await disconnect();
}
_isHost = false;
_sessionId = sessionId.toUpperCase();
_reconnectAttempts = 0;
final completer = Completer<void>();
try {
// Create a random peer ID for guest
_peer = Peer();
_peer!.on('open').listen((id) {
_myPeerId = id as String;
appLogger.d('WatchTogether: Guest peer opened with ID: $_myPeerId');
// Connect to the host
final hostPeerId = 'wt-$_sessionId';
appLogger.d('WatchTogether: Connecting to host: $hostPeerId');
final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true));
_handleNewConnection(conn, isOutgoing: true, completer: completer);
});
_peer!.on('error').listen((error) {
appLogger.e('WatchTogether: Peer error', error: error);
_errorController.add(
PeerError(
type: PeerErrorType.connectionFailed,
message: 'Failed to connect to session: $error',
originalError: error,
),
);
if (!completer.isCompleted) {
completer.completeError(error);
}
});
_peer!.on('disconnected').listen((_) {
appLogger.w('WatchTogether: Peer disconnected from server');
_handleDisconnectedFromServer();
});
_peer!.on('close').listen((_) {
appLogger.d('WatchTogether: Peer closed');
_connectionStateController.add(false);
});
} catch (e) {
appLogger.e('WatchTogether: Failed to create peer for joining', error: e);
if (!completer.isCompleted) {
completer.completeError(e);
}
}
// Timeout after 15 seconds
return completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out joining session');
},
);
}
/// Handle a new data connection (incoming or outgoing)
void _handleNewConnection(DataConnection conn, {bool isOutgoing = false, Completer<void>? completer}) {
final peerId = conn.peer;
appLogger.d('WatchTogether: New connection ${isOutgoing ? "to" : "from"}: $peerId');
conn.on('open').listen((_) {
appLogger.d('WatchTogether: Data channel opened with: $peerId');
_connections[peerId] = conn;
_peerConnectedController.add(peerId);
_connectionStateController.add(true);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
});
conn.on('data').listen((data) {
try {
final message = SyncMessage.fromJson(data as String);
appLogger.d('WatchTogether: Received message: ${message.type} from $peerId');
_messageReceivedController.add(message);
} catch (e) {
appLogger.e('WatchTogether: Failed to parse message', error: e);
}
});
conn.on('close').listen((_) {
appLogger.d('WatchTogether: Connection closed with: $peerId');
_connections.remove(peerId);
_peerDisconnectedController.add(peerId);
if (_connections.isEmpty) {
_connectionStateController.add(false);
}
});
conn.on('error').listen((error) {
appLogger.e('WatchTogether: Connection error with $peerId', error: error);
_errorController.add(
PeerError(
type: PeerErrorType.dataChannelError,
message: 'Connection error with peer: $error',
originalError: error,
),
);
});
}
/// Handle disconnection from PeerJS server
void _handleDisconnectedFromServer() {
if (_reconnectAttempts < _maxReconnectAttempts) {
_reconnectAttempts++;
final delay = Duration(seconds: _reconnectAttempts * 2); // Exponential backoff
appLogger.d(
'WatchTogether: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s',
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
_peer?.reconnect();
});
} else {
appLogger.e('WatchTogether: Max reconnect attempts reached');
_errorController.add(
const PeerError(
type: PeerErrorType.connectionFailed,
message: 'Lost connection to server after multiple reconnect attempts',
),
);
}
}
/// Broadcast a message to all connected peers
void broadcast(SyncMessage message) {
final json = message.toJson();
appLogger.d('WatchTogether: Broadcasting ${message.type} to ${_connections.length} peers');
for (final conn in _connections.values) {
try {
conn.send(json);
} catch (e) {
appLogger.e('WatchTogether: Failed to send to ${conn.peer}', error: e);
}
}
}
/// Send a message to a specific peer
void sendTo(String peerId, SyncMessage message) {
final conn = _connections[peerId];
if (conn != null) {
try {
conn.send(message.toJson());
} catch (e) {
appLogger.e('WatchTogether: Failed to send to $peerId', error: e);
}
} else {
appLogger.w('WatchTogether: No connection to peer: $peerId');
}
}
/// Disconnect from all peers and close the session
Future<void> disconnect() async {
appLogger.d('WatchTogether: Disconnecting...');
_reconnectTimer?.cancel();
_reconnectTimer = null;
// Close all data connections
for (final conn in _connections.values) {
conn.close();
}
_connections.clear();
// Destroy the peer
_peer?.dispose();
_peer = null;
_sessionId = null;
_myPeerId = null;
_isHost = false;
_reconnectAttempts = 0;
_connectionStateController.add(false);
}
/// Dispose all resources
void dispose() {
disconnect();
_peerConnectedController.close();
_peerDisconnectedController.close();
_messageReceivedController.close();
_errorController.close();
_connectionStateController.close();
}
}
@@ -0,0 +1,670 @@
import 'dart:async';
import '../../mpv/mpv.dart';
import '../../utils/app_logger.dart';
import '../models/sync_message.dart';
import '../models/watch_session.dart';
import 'watch_together_peer_service.dart';
/// Callback type for when session configuration is received
typedef SessionConfigCallback = void Function(ControlMode controlMode);
/// Callback type for when sync state changes
typedef SyncStateCallback = void Function(bool isSyncing);
/// Manages playback synchronization between peers
///
/// This class:
/// - Subscribes to player stream events
/// - Broadcasts local playback actions to peers
/// - Applies remote playback actions to the local player
/// - Handles drift correction
class WatchTogetherSyncManager {
final WatchTogetherPeerService _peerService;
final String displayName;
WatchSession _session;
Player? _player;
bool _isRemoteAction = false; // Flag to prevent echo
bool _isSyncing = false; // Flag for UI indicator during sync
// Stream subscriptions
StreamSubscription<bool>? _playingSubscription;
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<bool>? _bufferingSubscription;
StreamSubscription<double>? _rateSubscription;
StreamSubscription<SyncMessage>? _messageSubscription;
// Position sync timer (host broadcasts position periodically)
Timer? _positionSyncTimer;
// Drift correction constants
static const Duration maxAllowedDrift = Duration(seconds: 2);
static const Duration positionSyncInterval = Duration(seconds: 5);
static const Duration excessiveDrift = Duration(seconds: 10);
// Track last known state to avoid duplicate broadcasts
bool _lastKnownPlaying = false;
double _lastKnownRate = 1.0;
// Track if we were playing before a peer started buffering (for auto-resume)
bool _wasPlayingBeforeBuffering = false;
// Position to seek to when auto-resuming deferred playback
Duration? _pendingPlayPosition;
// Whether we've announced our player as ready (first buffering: false)
bool _hasAnnouncedReady = false;
// Callbacks
SessionConfigCallback? onSessionConfigReceived;
SyncStateCallback? onSyncStateChanged;
/// Participants' buffering states (peer ID -> isBuffering)
final Map<String, bool> _participantBuffering = {};
/// Participants' ready states (peer ID -> hasPlayerReady)
final Map<String, bool> _participantReady = {};
WatchTogetherSyncManager({
required WatchTogetherPeerService peerService,
required WatchSession session,
required this.displayName,
}) : _peerService = peerService,
_session = session;
/// Update the session (e.g., when control mode changes)
void updateSession(WatchSession session) {
_session = session;
appLogger.d('WatchTogether: Sync manager session updated, controlMode: ${session.controlMode}');
}
/// Whether this manager has a player attached
bool get hasPlayer => _player != null;
/// Whether any participant (including local player) is currently buffering
bool get isAnyBuffering => _participantBuffering.values.any((b) => b) || (_player?.state.buffering ?? false);
/// Whether all participants have their player attached and ready
/// Returns true if:
/// - We're alone (no other peers tracked)
/// - All tracked participants have sent playerReady(true)
bool get isAllReady {
// If no other peers are tracked, we're ready (solo viewing)
if (_participantBuffering.isEmpty) {
return true;
}
// All peers in _participantBuffering must also be in _participantReady with value true
for (final peerId in _participantBuffering.keys) {
final ready = _participantReady[peerId];
if (ready != true) {
return false; // Peer hasn't sent ready yet or sent ready(false)
}
}
return true;
}
/// Whether sync is in progress (for UI indicator)
bool get isSyncing => _isSyncing;
/// Attach a player to sync
void attachPlayer(Player player) {
if (_player != null) {
detachPlayer();
}
_player = player;
_lastKnownPlaying = player.state.playing;
_lastKnownRate = player.state.rate;
_setupPlayerSubscriptions();
_setupMessageSubscription();
// If host, start broadcasting position periodically
if (_session.isHost) {
_startPositionSync();
// Note: sessionConfig is sent after video loads (with correct position) in buffering handler
}
// Note: playerReady will be announced when video loads (first buffering: false)
appLogger.d('WatchTogether: Player attached, isHost: ${_session.isHost}');
}
/// Initialize participant tracking from existing session participants
/// Call this before attachPlayer() to ensure we know about participants who joined before
void initializeParticipants(List<String> peerIds) {
for (final peerId in peerIds) {
if (peerId != _peerService.myPeerId) {
// Assume they're buffering until they tell us otherwise
_participantBuffering[peerId] = true;
// They're not ready until they send playerReady
_participantReady[peerId] = false;
}
}
final otherCount = peerIds.where((id) => id != _peerService.myPeerId).length;
appLogger.d('WatchTogether: Initialized $otherCount existing participants');
}
/// Detach the player and stop sync
void detachPlayer() {
// Announce that our player is no longer ready
if (_peerService.myPeerId != null) {
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: false));
_participantReady[_peerService.myPeerId!] = false;
}
_hasAnnouncedReady = false;
_playingSubscription?.cancel();
_positionSubscription?.cancel();
_bufferingSubscription?.cancel();
_rateSubscription?.cancel();
_messageSubscription?.cancel();
_positionSyncTimer?.cancel();
_playingSubscription = null;
_positionSubscription = null;
_bufferingSubscription = null;
_rateSubscription = null;
_messageSubscription = null;
_positionSyncTimer = null;
_player = null;
appLogger.d('WatchTogether: Player detached');
}
/// Set up subscriptions to player streams
void _setupPlayerSubscriptions() {
// Listen to playing state changes
_playingSubscription = _player!.streams.playing.listen((isPlaying) async {
if (_isRemoteAction) return; // Skip if this change was caused by a remote action
if (isPlaying != _lastKnownPlaying) {
_lastKnownPlaying = isPlaying;
// If trying to play, check if all peers are ready first
if (isPlaying && (!isAllReady || isAnyBuffering)) {
appLogger.d('WatchTogether: Deferring local play - waiting for all peers to be ready');
_wasPlayingBeforeBuffering = true;
_pendingPlayPosition = _player?.state.position;
_isRemoteAction = true;
try {
await _player!.pause();
_lastKnownPlaying = false;
} finally {
_isRemoteAction = false;
}
// Still broadcast so peers know we want to play
_broadcastPlayPause(true);
return;
}
_broadcastPlayPause(isPlaying);
}
});
// Listen to buffering state changes
_bufferingSubscription = _player!.streams.buffering.listen((isBuffering) {
if (_isRemoteAction) return;
// Announce ready when we stop buffering for the first time (video loaded)
if (!isBuffering && !_hasAnnouncedReady) {
_hasAnnouncedReady = true;
_participantReady[_peerService.myPeerId!] = true;
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true));
appLogger.d('WatchTogether: Video loaded, announcing player ready');
// If host, send session config now that video is loaded with correct position
if (_session.isHost) {
_sendSessionConfig();
}
}
_peerService.broadcast(SyncMessage.buffering(isBuffering, peerId: _peerService.myPeerId));
});
// Listen to rate changes
_rateSubscription = _player!.streams.rate.listen((rate) {
if (_isRemoteAction) return;
if (rate != _lastKnownRate) {
_lastKnownRate = rate;
if (_canControl()) {
_peerService.broadcast(SyncMessage.rate(rate, peerId: _peerService.myPeerId));
}
}
});
}
/// Set up subscription to incoming sync messages
void _setupMessageSubscription() {
_messageSubscription = _peerService.onMessageReceived.listen(_handleMessage);
}
/// Start periodic position sync (host only)
void _startPositionSync() {
_positionSyncTimer?.cancel();
_positionSyncTimer = Timer.periodic(positionSyncInterval, (_) {
if (_player != null && _session.isHost) {
_peerService.broadcast(SyncMessage.positionSync(_player!.state.position, peerId: _peerService.myPeerId));
}
});
}
/// Check if this peer can control playback
bool _canControl() {
if (_session.controlMode == ControlMode.anyone) {
return true;
}
return _session.isHost;
}
/// Broadcast play/pause state
void _broadcastPlayPause(bool isPlaying) {
if (!_canControl()) {
appLogger.d('WatchTogether: Cannot control playback in hostOnly mode');
return;
}
if (isPlaying) {
final position = _player?.state.position ?? Duration.zero;
_peerService.broadcast(SyncMessage.play(peerId: _peerService.myPeerId, position: position));
} else {
_peerService.broadcast(SyncMessage.pause(peerId: _peerService.myPeerId));
}
}
/// Called when user seeks locally
void onLocalSeek(Duration position) {
if (!_canControl()) {
appLogger.d('WatchTogether: Cannot control playback in hostOnly mode');
return;
}
_peerService.broadcast(SyncMessage.seek(position, peerId: _peerService.myPeerId));
}
/// Handle incoming sync messages
void _handleMessage(SyncMessage message) async {
// Ignore our own messages
if (message.peerId == _peerService.myPeerId) {
return;
}
// HOST RELAY: In "anyone" mode, host rebroadcasts control commands from guests
// This is needed because guests only connect to host (star topology), not to each other
if (_session.isHost && _session.controlMode == ControlMode.anyone) {
final isControlMessage =
message.type == SyncMessageType.play ||
message.type == SyncMessageType.pause ||
message.type == SyncMessageType.seek ||
message.type == SyncMessageType.rate;
if (isControlMessage) {
appLogger.d('WatchTogether: Host relaying ${message.type} from ${message.peerId}');
_peerService.broadcast(message);
}
}
// In hostOnly mode, only process messages from host (unless it's join/leave/sessionConfig)
if (_session.controlMode == ControlMode.hostOnly && !_session.isHost) {
final isHostMessage = message.peerId == _session.hostPeerId;
final isMetaMessage =
message.type == SyncMessageType.join ||
message.type == SyncMessageType.leave ||
message.type == SyncMessageType.sessionConfig ||
message.type == SyncMessageType.buffering ||
message.type == SyncMessageType.ping ||
message.type == SyncMessageType.pong ||
message.type == SyncMessageType.mediaSwitch;
if (!isHostMessage && !isMetaMessage) {
appLogger.d('WatchTogether: Ignoring non-host message in hostOnly mode');
return;
}
}
switch (message.type) {
case SyncMessageType.play:
await _applyRemotePlay(position: message.position);
break;
case SyncMessageType.pause:
_wasPlayingBeforeBuffering = false; // User intentionally paused, don't auto-resume
await _applyRemotePause();
break;
case SyncMessageType.seek:
if (message.position != null) {
await _applyRemoteSeek(message.position!);
}
break;
case SyncMessageType.buffering:
if (message.peerId != null && message.bufferingState != null) {
_participantBuffering[message.peerId!] = message.bufferingState!;
// Auto-pause when any peer starts buffering
if (isAnyBuffering && _player!.state.playing) {
_wasPlayingBeforeBuffering = true;
appLogger.d('WatchTogether: Peer buffering, pausing playback');
await _applyRemotePause();
}
// Auto-resume when all peers stop buffering AND all ready (if we were playing before)
else if (isAllReady && !isAnyBuffering && !_player!.state.playing && _wasPlayingBeforeBuffering) {
_wasPlayingBeforeBuffering = false;
appLogger.d('WatchTogether: All peers done buffering, resuming playback');
await _applyRemotePlay(position: _pendingPlayPosition);
_pendingPlayPosition = null;
}
}
break;
case SyncMessageType.positionSync:
if (message.position != null) {
_checkAndCorrectDrift(message.position!, message.timestamp);
}
break;
case SyncMessageType.rate:
if (message.rate != null) {
await _applyRemoteRate(message.rate!);
}
break;
case SyncMessageType.join:
_handlePeerJoin(message);
break;
case SyncMessageType.leave:
if (message.peerId != null) {
_participantBuffering.remove(message.peerId);
_participantReady.remove(message.peerId);
}
break;
case SyncMessageType.sessionConfig:
await _handleSessionConfig(message);
break;
case SyncMessageType.ping:
if (message.pingId != null) {
_peerService.broadcast(SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId));
}
break;
case SyncMessageType.pong:
// Could be used for latency measurement
break;
case SyncMessageType.mediaSwitch:
// Handled at the provider level, not in sync manager
break;
case SyncMessageType.hostExitedPlayer:
// Handled at the provider level, not in sync manager
break;
case SyncMessageType.playerReady:
if (message.peerId != null) {
_participantReady[message.peerId!] = message.bufferingState ?? false;
appLogger.d('WatchTogether: Peer ${message.peerId} player ready: ${message.bufferingState}');
// If we were waiting to play and all are now ready, start playback
if (isAllReady && !isAnyBuffering && _wasPlayingBeforeBuffering) {
_wasPlayingBeforeBuffering = false;
appLogger.d('WatchTogether: All players ready, starting playback');
await _applyRemotePlay(position: _pendingPlayPosition);
_pendingPlayPosition = null;
}
}
break;
}
}
/// Apply remote play command
Future<void> _applyRemotePlay({Duration? position}) async {
if (_player == null) return;
// If not all participants have their player ready, defer play
if (!isAllReady) {
appLogger.d('WatchTogether: Deferring play - waiting for all players to be ready');
_wasPlayingBeforeBuffering = true;
if (position != null) _pendingPlayPosition = position;
return; // Will trigger when all players send playerReady
}
// If anyone is buffering, defer play until all ready
if (isAnyBuffering) {
appLogger.d('WatchTogether: Deferring play - waiting for all peers to stop buffering');
_wasPlayingBeforeBuffering = true;
if (position != null) _pendingPlayPosition = position;
return; // Auto-resume will trigger when buffering clears
}
appLogger.d('WatchTogether: Applying remote PLAY${position != null ? ' at ${position.inSeconds}s' : ''}');
_isRemoteAction = true;
try {
// Seek to position first if provided
if (position != null) {
await _player!.seek(position);
}
await _player!.play();
_lastKnownPlaying = true;
} on StateError catch (e) {
appLogger.w('WatchTogether: Player disposed during remote PLAY', error: e);
detachPlayer();
} finally {
_isRemoteAction = false;
}
}
/// Apply remote pause command
Future<void> _applyRemotePause() async {
if (_player == null) return;
appLogger.d('WatchTogether: Applying remote PAUSE');
_isRemoteAction = true;
try {
await _player!.pause();
_lastKnownPlaying = false;
} on StateError catch (e) {
appLogger.w('WatchTogether: Player disposed during remote PAUSE', error: e);
detachPlayer();
} finally {
_isRemoteAction = false;
}
}
/// Apply remote seek command
Future<void> _applyRemoteSeek(Duration position) async {
if (_player == null) return;
appLogger.d('WatchTogether: Applying remote SEEK to ${position.inSeconds}s');
_isRemoteAction = true;
try {
await _player!.seek(position);
} on StateError catch (e) {
appLogger.w('WatchTogether: Player disposed during remote SEEK', error: e);
detachPlayer();
} finally {
_isRemoteAction = false;
}
}
/// Apply remote rate change
Future<void> _applyRemoteRate(double rate) async {
if (_player == null) return;
appLogger.d('WatchTogether: Applying remote RATE: $rate');
_isRemoteAction = true;
try {
await _player!.setRate(rate);
_lastKnownRate = rate;
} on StateError catch (e) {
appLogger.w('WatchTogether: Player disposed during remote RATE', error: e);
detachPlayer();
} finally {
_isRemoteAction = false;
}
}
/// Check and correct position drift
void _checkAndCorrectDrift(Duration remotePosition, int remoteTimestamp) {
if (_player == null || _session.isHost) return;
final localPosition = _player!.state.position;
final networkDelay = DateTime.now().millisecondsSinceEpoch - remoteTimestamp;
// Estimate where remote should be now, accounting for playback time elapsed
Duration estimatedRemoteNow = remotePosition;
if (_player!.state.playing && networkDelay > 0) {
// If playing, account for time elapsed during network transit
// Multiply by rate in case playback speed is different
estimatedRemoteNow = remotePosition + Duration(milliseconds: (networkDelay * _player!.state.rate).round());
}
final drift = (localPosition - estimatedRemoteNow).abs();
if (drift > excessiveDrift) {
// Excessive drift - force sync with indicator
appLogger.w('WatchTogether: Excessive drift (${drift.inSeconds}s), force syncing');
_setSyncing(true);
_applyRemoteSeek(estimatedRemoteNow);
Future.delayed(const Duration(milliseconds: 500), () => _setSyncing(false));
} else if (drift > maxAllowedDrift) {
// Normal drift correction
appLogger.d('WatchTogether: Drift correction (${drift.inMilliseconds}ms)');
_setSyncing(true);
_applyRemoteSeek(estimatedRemoteNow);
Future.delayed(const Duration(milliseconds: 300), () => _setSyncing(false));
}
}
/// Handle peer join message
void _handlePeerJoin(SyncMessage message) {
appLogger.d('WatchTogether: Peer joined: ${message.displayName}');
// Assume new peer is buffering and not ready until they explicitly signal
if (message.peerId != null) {
_participantBuffering[message.peerId!] = true;
_participantReady[message.peerId!] = false;
}
// If we're the host, send session config AND our own join info to the new peer
if (_session.isHost && message.peerId != null) {
// Only send config if our video is loaded (we know the correct position)
if (_hasAnnouncedReady) {
_sendSessionConfig(toPeerId: message.peerId);
}
// Send host's join info so guest adds host to their participants list
_peerService.sendTo(
message.peerId!,
SyncMessage.join(peerId: _peerService.myPeerId!, displayName: displayName, isHost: true),
);
}
}
/// Handle session config from host
Future<void> _handleSessionConfig(SyncMessage message) async {
if (_session.isHost) return; // Host doesn't need to process config
appLogger.d('WatchTogether: Received session config');
// Update control mode
if (message.controlMode != null) {
onSessionConfigReceived?.call(message.controlMode!);
}
// Sync to host's current state
if (_player == null) return;
_isRemoteAction = true;
try {
// Always seek to host's position first
if (message.position != null) {
await _player!.seek(message.position!);
}
// Match playback rate
if (message.rate != null) {
await _player!.setRate(message.rate!);
_lastKnownRate = message.rate!;
}
// Match play/pause state (bufferingState is reused: false = playing)
if (message.bufferingState == false) {
// Host was playing - defer play until all ready
_wasPlayingBeforeBuffering = true;
_pendingPlayPosition = message.position;
// Check if we can play now
if (isAllReady && !isAnyBuffering) {
await _applyRemotePlay(position: message.position);
} else {
appLogger.d('WatchTogether: Host was playing but deferring until all ready');
}
} else {
await _player!.pause();
_lastKnownPlaying = false;
}
} on StateError catch (e) {
appLogger.w('WatchTogether: Player disposed during session config apply', error: e);
detachPlayer();
} finally {
_isRemoteAction = false;
}
}
/// Set syncing state and notify listeners
void _setSyncing(bool isSyncing) {
if (_isSyncing != isSyncing) {
_isSyncing = isSyncing;
onSyncStateChanged?.call(isSyncing);
}
}
/// Send join announcement to all peers
void announceJoin(String displayName) {
_peerService.broadcast(
SyncMessage.join(peerId: _peerService.myPeerId!, displayName: displayName, isHost: _session.isHost),
);
}
/// Send leave announcement to all peers
void announceLeave() {
if (_peerService.myPeerId != null) {
_peerService.broadcast(SyncMessage.leave(peerId: _peerService.myPeerId!));
}
}
/// Send current session configuration to peers
void _sendSessionConfig({String? toPeerId}) {
if (!_session.isHost || _peerService.myPeerId == null) return;
final position = _player?.state.position ?? Duration.zero;
final isPlaying = _player?.state.playing ?? false;
final rate = _player?.state.rate ?? 1.0;
final configMessage = SyncMessage.sessionConfig(
controlMode: _session.controlMode,
currentPosition: position,
isPlaying: isPlaying,
playbackRate: rate,
peerId: _peerService.myPeerId,
);
if (toPeerId != null) {
_peerService.sendTo(toPeerId, configMessage);
} else {
_peerService.broadcast(configMessage);
}
}
/// Dispose resources
void dispose() {
detachPlayer();
_participantBuffering.clear();
_participantReady.clear();
_hasAnnouncedReady = false;
}
}
+18
View File
@@ -0,0 +1,18 @@
// Models
export 'models/watch_session.dart';
export 'models/sync_message.dart';
// Services
export 'services/watch_together_peer_service.dart';
export 'services/watch_together_sync_manager.dart';
// Providers
export 'providers/watch_together_provider.dart';
// Screens
export 'screens/watch_together_screen.dart';
// Widgets
export 'widgets/session_invite_dialog.dart';
export 'widgets/join_session_dialog.dart';
export 'widgets/watch_together_overlay.dart';
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
/// Dialog for joining a watch together session
class JoinSessionDialog extends StatefulWidget {
const JoinSessionDialog({super.key});
@override
State<JoinSessionDialog> createState() => _JoinSessionDialogState();
}
class _JoinSessionDialogState extends State<JoinSessionDialog> {
final _formKey = GlobalKey<FormState>();
final _sessionIdController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_sessionIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Icon(Symbols.group_add, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(child: Text('Join Watch Session', style: theme.textTheme.titleLarge)),
IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
],
),
const SizedBox(height: 24),
// Session ID input
TextFormField(
controller: _sessionIdController,
decoration: InputDecoration(
labelText: 'Session Code',
hintText: 'Enter 8-character code',
prefixIcon: const Icon(Symbols.tag),
suffixIcon: IconButton(
onPressed: _pasteFromClipboard,
icon: const Icon(Symbols.content_paste),
tooltip: 'Paste from clipboard',
),
border: const OutlineInputBorder(),
),
textCapitalization: TextCapitalization.characters,
maxLength: 8,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
UpperCaseTextFormatter(),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a session code';
}
if (value.length != 8) {
return 'Session code must be 8 characters';
}
return null;
},
onFieldSubmitted: (_) => _join(),
autofocus: true,
),
const SizedBox(height: 16),
// Instructions
Text(
'Enter the session code shared by the host to join their watch session.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
// Join button
FilledButton.icon(
onPressed: _isLoading ? null : _join,
icon: _isLoading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Symbols.group_add),
label: Text(_isLoading ? 'Joining...' : 'Join Session'),
),
],
),
),
),
),
);
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) {
// Clean the pasted text - extract alphanumeric characters and take first 8
final cleaned = data!.text!.replaceAll(RegExp(r'[^A-Za-z0-9]'), '').toUpperCase();
if (cleaned.isNotEmpty) {
_sessionIdController.text = cleaned.substring(0, cleaned.length.clamp(0, 8));
_sessionIdController.selection = TextSelection.collapsed(offset: _sessionIdController.text.length);
}
}
}
void _join() {
if (_formKey.currentState!.validate()) {
final sessionId = _sessionIdController.text.toUpperCase();
Navigator.of(context).pop(sessionId);
}
}
}
/// Text formatter to convert input to uppercase
class UpperCaseTextFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
return newValue.copyWith(text: newValue.text.toUpperCase());
}
}
/// Show the join session dialog
///
/// Returns the session ID if user confirms, null if cancelled
Future<String?> showJoinSessionDialog(BuildContext context) {
return showDialog<String>(context: context, builder: (context) => const JoinSessionDialog());
}
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
/// Dialog for sharing a watch together session with others
class SessionInviteDialog extends StatelessWidget {
final String sessionId;
final int participantCount;
const SessionInviteDialog({super.key, required this.sessionId, required this.participantCount});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Icon(Symbols.group, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Watch Together', style: theme.textTheme.titleLarge),
Text(
'$participantCount ${participantCount == 1 ? 'participant' : 'participants'}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
],
),
const SizedBox(height: 24),
// QR Code
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: QrImageView(
data: sessionId,
version: QrVersions.auto,
size: 180,
backgroundColor: Colors.white,
eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Colors.black,
),
),
),
),
const SizedBox(height: 24),
// Session ID with copy button
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Session Code',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 4),
SelectableText(
sessionId,
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
],
),
),
IconButton(
onPressed: () => _copyToClipboard(context),
icon: const Icon(Symbols.content_copy),
tooltip: 'Copy code',
),
],
),
),
const SizedBox(height: 16),
// Instructions
Text(
'Share this code with others to let them join your watch session.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Share button
FilledButton.icon(
onPressed: () => _share(context),
icon: const Icon(Symbols.share),
label: const Text('Share'),
),
],
),
),
),
);
}
void _copyToClipboard(BuildContext context) {
Clipboard.setData(ClipboardData(text: sessionId));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Session code copied to clipboard')));
}
void _share(BuildContext context) {
final text = 'Join my Watch Together session!\n\nSession Code: $sessionId';
Share.share(text, subject: 'Watch Together Invite');
}
}
/// Show the session invite dialog
Future<void> showSessionInviteDialog(BuildContext context, {required String sessionId, required int participantCount}) {
return showDialog(
context: context,
builder: (context) => SessionInviteDialog(sessionId: sessionId, participantCount: participantCount),
);
}
@@ -0,0 +1,298 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/watch_session.dart';
import '../providers/watch_together_provider.dart';
import 'session_invite_dialog.dart';
/// Overlay shown on the video player when in a watch together session
class WatchTogetherOverlay extends StatelessWidget {
/// Callback when the user wants to leave the session
final VoidCallback? onLeaveSession;
const WatchTogetherOverlay({super.key, this.onLeaveSession});
@override
Widget build(BuildContext context) {
return Consumer<WatchTogetherProvider>(
builder: (context, provider, child) {
if (!provider.isInSession) {
return const SizedBox.shrink();
}
return Positioned(
top: 16,
right: 16,
child: _SessionIndicator(
participantCount: provider.participantCount,
isHost: provider.isHost,
isSyncing: provider.isSyncing,
controlMode: provider.controlMode,
sessionId: provider.sessionId,
onTap: () => _showSessionMenu(context, provider),
),
);
},
);
}
void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) {
showModalBottomSheet(
context: context,
builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession),
);
}
}
/// Small indicator showing session status
class _SessionIndicator extends StatelessWidget {
final int participantCount;
final bool isHost;
final bool isSyncing;
final ControlMode controlMode;
final String? sessionId;
final VoidCallback onTap;
const _SessionIndicator({
required this.participantCount,
required this.isHost,
required this.isSyncing,
required this.controlMode,
required this.sessionId,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Sync indicator or group icon
if (isSyncing)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
else
Icon(Symbols.group, size: 18, color: isHost ? theme.colorScheme.primary : Colors.white),
const SizedBox(width: 6),
// Participant count
Text(
'$participantCount',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
),
// Host badge
if (isHost) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(4)),
child: const Text(
'HOST',
style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
),
],
],
),
),
),
);
}
}
/// Bottom sheet showing session details and actions
class _SessionMenuSheet extends StatelessWidget {
final WatchTogetherProvider provider;
final VoidCallback? onLeaveSession;
const _SessionMenuSheet({required this.provider, this.onLeaveSession});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Icon(Symbols.group, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Watch Together', style: theme.textTheme.titleMedium),
Text(
provider.isHost ? 'You are the host' : 'Watching with others',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
// Control mode badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
provider.controlMode == ControlMode.hostOnly ? 'Host controls' : 'Anyone controls',
style: theme.textTheme.labelSmall,
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Participants list
Text('Participants', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...provider.participants.map(
(p) => ListTile(
leading: CircleAvatar(
backgroundColor: p.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
child: Icon(
p.isHost ? Symbols.star : Symbols.person,
color: p.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
size: 20,
),
),
title: Text(p.displayName),
subtitle: p.isHost ? const Text('Host') : null,
trailing: p.isBuffering
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: null,
dense: true,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Actions
if (provider.isHost && provider.sessionId != null)
ListTile(
leading: const Icon(Symbols.share),
title: const Text('Invite others'),
onTap: () {
Navigator.pop(context);
showSessionInviteDialog(
context,
sessionId: provider.sessionId!,
participantCount: provider.participantCount,
);
},
contentPadding: EdgeInsets.zero,
),
ListTile(
leading: Icon(Symbols.logout, color: theme.colorScheme.error),
title: Text(
provider.isHost ? 'End session' : 'Leave session',
style: TextStyle(color: theme.colorScheme.error),
),
onTap: () {
Navigator.pop(context);
_confirmLeave(context);
},
contentPadding: EdgeInsets.zero,
),
],
),
),
);
}
void _confirmLeave(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(provider.isHost ? 'End Session?' : 'Leave Session?'),
content: Text(
provider.isHost
? 'This will end the watch session for all participants.'
: 'You will be disconnected from the watch session.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () {
Navigator.pop(context);
provider.leaveSession();
onLeaveSession?.call();
},
child: Text(provider.isHost ? 'End Session' : 'Leave'),
),
],
),
);
}
}
/// Compact sync indicator for showing during drift correction
class SyncingIndicator extends StatelessWidget {
const SyncingIndicator({super.key});
@override
Widget build(BuildContext context) {
return Consumer<WatchTogetherProvider>(
builder: (context, provider, child) {
if (!provider.isSyncing) {
return const SizedBox.shrink();
}
return Positioned(
bottom: 80,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
SizedBox(width: 8),
Text('Syncing...', style: TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
),
);
},
);
}
}
+3 -1
View File
@@ -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,
);
}
}
+11 -1
View File
@@ -25,12 +25,17 @@ class FocusedScrollScaffold extends StatelessWidget {
/// Defaults to true.
final bool pinned;
/// Whether to automatically add a back button.
/// Defaults to true.
final bool automaticallyImplyLeading;
const FocusedScrollScaffold({
super.key,
required this.title,
required this.slivers,
this.actions,
this.pinned = true,
this.automaticallyImplyLeading = true,
});
@override
@@ -40,7 +45,12 @@ class FocusedScrollScaffold extends StatelessWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(title: title, pinned: pinned, actions: actions),
CustomAppBar(
title: title,
pinned: pinned,
actions: actions,
automaticallyImplyLeading: automaticallyImplyLeading,
),
...slivers,
],
),
@@ -60,6 +60,10 @@ class DesktopVideoControls extends StatefulWidget {
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
final String serverId;
final VoidCallback? onBack;
/// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl;
const DesktopVideoControls({
super.key,
@@ -94,6 +98,8 @@ class DesktopVideoControls extends StatefulWidget {
this.onCancelAutoHide,
this.onStartAutoHide,
this.serverId = '',
this.onBack,
this.canControl = true,
});
@override
@@ -252,8 +258,11 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
return KeyEventResult.handled;
}
// LEFT/RIGHT for smooth scrubbing
// LEFT/RIGHT for smooth scrubbing - only if user can control
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
// Ignore seeking if user cannot control
if (!widget.canControl) return KeyEventResult.handled;
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
// Base step: 0.5% of duration, minimum 500ms
@@ -314,6 +323,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
child: VideoControlsHeader(
metadata: widget.metadata,
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
onBack: widget.onBack,
),
);
@@ -336,74 +346,92 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
enabled: widget.canControl,
),
const SizedBox(height: 4),
// Row 2: Playback controls and options
Row(
children: [
// Previous item
_buildFocusableButton(
focusNode: _prevItemFocusNode,
index: 0,
icon: Symbols.skip_previous_rounded,
color: widget.onPrevious != null ? Colors.white : Colors.white54,
onPressed: widget.onPrevious,
semanticLabel: t.videoControls.previousButton,
Opacity(
opacity: widget.canControl ? 1.0 : 0.5,
child: _buildFocusableButton(
focusNode: _prevItemFocusNode,
index: 0,
icon: Symbols.skip_previous_rounded,
color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54,
onPressed: widget.canControl ? widget.onPrevious : null,
semanticLabel: t.videoControls.previousButton,
),
),
// Previous chapter (or skip backward if no chapters)
_buildFocusableButton(
focusNode: _prevChapterFocusNode,
index: 1,
icon: widget.chapters.isEmpty
? widget.getReplayIcon(widget.seekTimeSmall)
: Symbols.fast_rewind_rounded,
onPressed: widget.onSeekToPreviousChapter,
semanticLabel: widget.chapters.isEmpty
? t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall)
: t.videoControls.previousChapterButton,
Opacity(
opacity: widget.canControl ? 1.0 : 0.5,
child: _buildFocusableButton(
focusNode: _prevChapterFocusNode,
index: 1,
icon: widget.chapters.isEmpty
? widget.getReplayIcon(widget.seekTimeSmall)
: Symbols.fast_rewind_rounded,
onPressed: widget.canControl ? widget.onSeekToPreviousChapter : null,
semanticLabel: widget.chapters.isEmpty
? t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall)
: t.videoControls.previousChapterButton,
),
),
// Play/Pause
StreamBuilder<bool>(
stream: widget.player.streams.playing,
initialData: widget.player.state.playing,
builder: (context, snapshot) {
final isPlaying = snapshot.data ?? false;
return _buildFocusableButton(
focusNode: _playPauseFocusNode,
index: 2,
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
iconSize: 32,
onPressed: () {
if (isPlaying) {
widget.player.pause();
} else {
widget.player.play();
}
},
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
);
},
Opacity(
opacity: widget.canControl ? 1.0 : 0.5,
child: StreamBuilder<bool>(
stream: widget.player.streams.playing,
initialData: widget.player.state.playing,
builder: (context, snapshot) {
final isPlaying = snapshot.data ?? false;
return _buildFocusableButton(
focusNode: _playPauseFocusNode,
index: 2,
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
iconSize: 32,
onPressed: widget.canControl
? () {
if (isPlaying) {
widget.player.pause();
} else {
widget.player.play();
}
}
: null,
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
);
},
),
),
// Next chapter (or skip forward if no chapters)
_buildFocusableButton(
focusNode: _nextChapterFocusNode,
index: 3,
icon: widget.chapters.isEmpty
? widget.getForwardIcon(widget.seekTimeSmall)
: Symbols.fast_forward_rounded,
onPressed: widget.onSeekToNextChapter,
semanticLabel: widget.chapters.isEmpty
? t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall)
: t.videoControls.nextChapterButton,
Opacity(
opacity: widget.canControl ? 1.0 : 0.5,
child: _buildFocusableButton(
focusNode: _nextChapterFocusNode,
index: 3,
icon: widget.chapters.isEmpty
? widget.getForwardIcon(widget.seekTimeSmall)
: Symbols.fast_forward_rounded,
onPressed: widget.canControl ? widget.onSeekToNextChapter : null,
semanticLabel: widget.chapters.isEmpty
? t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall)
: t.videoControls.nextChapterButton,
),
),
// Next item
_buildFocusableButton(
focusNode: _nextItemFocusNode,
index: 4,
icon: Symbols.skip_next_rounded,
color: widget.onNext != null ? Colors.white : Colors.white54,
onPressed: widget.onNext,
semanticLabel: t.videoControls.nextButton,
Opacity(
opacity: widget.canControl ? 1.0 : 0.5,
child: _buildFocusableButton(
focusNode: _nextItemFocusNode,
index: 4,
icon: Symbols.skip_next_rounded,
color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54,
onPressed: widget.canControl ? widget.onNext : null,
semanticLabel: t.videoControls.nextButton,
),
),
const Spacer(),
// Volume control
@@ -439,6 +467,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
focusNodes: _trackControlFocusNodes,
onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume,
canControl: widget.canControl,
),
],
),
@@ -27,9 +27,14 @@ class MobileVideoControls extends StatelessWidget {
final Widget trackChapterControls;
final Function(Duration) onSeek;
final Function(Duration) onSeekEnd;
final Function(Duration)? onSeekCompleted;
final VoidCallback onPlayPause;
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
final VoidCallback? onBack;
/// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl;
const MobileVideoControls({
super.key,
@@ -42,8 +47,11 @@ class MobileVideoControls extends StatelessWidget {
required this.onSeek,
required this.onSeekEnd,
required this.onPlayPause,
this.onSeekCompleted,
this.onCancelAutoHide,
this.onStartAutoHide,
this.onBack,
this.canControl = true,
});
@override
@@ -72,6 +80,7 @@ class MobileVideoControls extends StatelessWidget {
metadata: metadata,
style: VideoHeaderStyle.multiLine,
trailing: trackChapterControls,
onBack: onBack,
),
),
);
@@ -80,6 +89,11 @@ class MobileVideoControls extends StatelessWidget {
}
Widget _buildPlaybackControls(BuildContext context) {
// Hide all playback controls in host-only mode for non-host
if (!canControl) {
return const SizedBox.shrink();
}
return StreamBuilder<bool>(
stream: player.streams.playing,
initialData: player.state.playing,
@@ -93,7 +107,8 @@ class MobileVideoControls extends StatelessWidget {
icon: getReplayIcon(seekTimeSmall),
iconSize: 48,
onPressed: () {
seekWithClamping(player, Duration(seconds: -seekTimeSmall));
final newPosition = seekWithClamping(player, Duration(seconds: -seekTimeSmall));
onSeekCompleted?.call(newPosition);
},
),
const SizedBox(width: 48),
@@ -117,7 +132,8 @@ class MobileVideoControls extends StatelessWidget {
icon: getForwardIcon(seekTimeSmall),
iconSize: 48,
onPressed: () {
seekWithClamping(player, Duration(seconds: seekTimeSmall));
final newPosition = seekWithClamping(player, Duration(seconds: seekTimeSmall));
onSeekCompleted?.call(newPosition);
},
),
],
@@ -139,6 +155,7 @@ class MobileVideoControls extends StatelessWidget {
onSeek: onSeek,
onSeekEnd: onSeekEnd,
horizontalLayout: false,
enabled: canControl,
),
),
);
@@ -69,11 +69,15 @@ class VideoSettingsSheet extends StatefulWidget {
final int audioSyncOffset;
final int subtitleSyncOffset;
/// Whether the user can control playback (false hides speed option in host-only mode).
final bool canControl;
const VideoSettingsSheet({
super.key,
required this.player,
required this.audioSyncOffset,
required this.subtitleSyncOffset,
this.canControl = true,
});
static Future<void> show(
@@ -83,13 +87,18 @@ class VideoSettingsSheet extends StatefulWidget {
int subtitleSyncOffset, {
VoidCallback? onOpen,
VoidCallback? onClose,
bool canControl = true,
}) {
return BaseVideoControlSheet.showSheet(
context: context,
onOpen: onOpen,
onClose: onClose,
builder: (context) =>
VideoSettingsSheet(player: player, audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset),
builder: (context) => VideoSettingsSheet(
player: player,
audioSyncOffset: audioSyncOffset,
subtitleSyncOffset: subtitleSyncOffset,
canControl: canControl,
),
);
}
@@ -201,21 +210,22 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return ListView(
children: [
// Playback Speed
StreamBuilder<double>(
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
return _SettingsMenuItem(
focusNode: _initialFocusNode,
icon: Symbols.speed_rounded,
title: 'Playback Speed',
valueText: _formatSpeed(currentRate),
onTap: () => _navigateTo(_SettingsView.speed),
);
},
),
// Playback Speed - only show if user can control playback
if (widget.canControl)
StreamBuilder<double>(
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
return _SettingsMenuItem(
focusNode: _initialFocusNode,
icon: Symbols.speed_rounded,
title: 'Playback Speed',
valueText: _formatSpeed(currentRate),
onTap: () => _navigateTo(_SettingsView.speed),
);
},
),
// Sleep Timer
ListenableBuilder(
+165 -41
View File
@@ -4,8 +4,17 @@ import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation, LogicalKeyboardKey;
import 'package:flutter/services.dart'
show
SystemChrome,
DeviceOrientation,
LogicalKeyboardKey,
PhysicalKeyboardKey,
KeyEvent,
KeyDownEvent,
HardwareKeyboard;
import 'package:macos_window_utils/macos_window_utils.dart';
import 'package:window_manager/window_manager.dart';
@@ -30,6 +39,7 @@ import 'icons.dart';
import '../../utils/app_logger.dart';
import '../../i18n/strings.g.dart';
import '../../focus/input_mode_tracker.dart';
import '../../watch_together/watch_together.dart';
import 'widgets/track_chapter_controls.dart';
import 'mobile_video_controls.dart';
import 'desktop_video_controls.dart';
@@ -46,6 +56,9 @@ Widget plexVideoControlsBuilder(
VoidCallback? onCycleBoxFitMode,
Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged,
Function(Duration position)? onSeekCompleted,
VoidCallback? onBack,
bool canControl = true,
}) {
return PlexVideoControls(
player: player,
@@ -58,6 +71,9 @@ Widget plexVideoControlsBuilder(
onCycleBoxFitMode: onCycleBoxFitMode,
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
onSeekCompleted: onSeekCompleted,
onBack: onBack,
canControl: canControl,
);
}
@@ -73,6 +89,15 @@ class PlexVideoControls extends StatefulWidget {
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
/// Called when a seek operation completes (for Watch Together sync)
final Function(Duration position)? onSeekCompleted;
/// Called when back button is pressed (for Watch Together session leave confirmation)
final VoidCallback? onBack;
/// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl;
const PlexVideoControls({
super.key,
required this.player,
@@ -85,6 +110,9 @@ class PlexVideoControls extends StatefulWidget {
this.onCycleBoxFitMode,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSeekCompleted,
this.onBack,
this.canControl = true,
});
@override
@@ -167,6 +195,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusPlayPauseIfKeyboardMode();
});
// Register global key handler for focus-independent shortcuts (desktop only)
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
}
/// Focus play/pause button if we're in keyboard navigation mode (desktop only)
@@ -241,7 +271,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
void _skipMarker() {
if (_currentMarker != null) {
widget.player.seek(_currentMarker!.endTime);
final endTime = _currentMarker!.endTime;
widget.player.seek(endTime);
widget.onSeekCompleted?.call(endTime);
}
_cancelAutoSkipTimer();
}
@@ -365,6 +397,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
_hideTimer?.cancel();
_feedbackTimer?.cancel();
_resizeDebounceTimer?.cancel();
@@ -676,49 +709,55 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
serverId: widget.metadata.serverId ?? '',
canControl: widget.canControl,
);
}
void _seekToPreviousChapter() {
void _seekToPreviousChapter() => _seekToChapter(forward: false);
void _seekToNextChapter() => _seekToChapter(forward: true);
void _seekToChapter({required bool forward}) {
if (_chapters.isEmpty) {
// No chapters - seek backward by configured amount
seekWithClamping(widget.player, Duration(seconds: -_seekTimeSmall));
// No chapters - seek by configured amount
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
final duration = widget.player.state.duration;
final unclamped = widget.player.state.position + delta;
final newPosition = unclamped < Duration.zero ? Duration.zero : (unclamped > duration ? duration : unclamped);
seekWithClamping(widget.player, delta);
widget.onSeekCompleted?.call(newPosition);
return;
}
final currentPosition = widget.player.state.position.inMilliseconds;
final currentPositionMs = widget.player.state.position.inMilliseconds;
// Find current chapter
for (int i = _chapters.length - 1; i >= 0; i--) {
final chapterStart = _chapters[i].startTimeOffset ?? 0;
if (currentPosition > chapterStart + 3000) {
// If more than 3 seconds into chapter, go to start of current chapter
widget.player.seek(Duration(milliseconds: chapterStart));
return;
if (forward) {
// Find next chapter
for (final chapter in _chapters) {
final chapterStart = chapter.startTimeOffset ?? 0;
if (chapterStart > currentPositionMs) {
_seekToPosition(Duration(milliseconds: chapterStart));
return;
}
}
} else {
// Find previous/current chapter
for (int i = _chapters.length - 1; i >= 0; i--) {
final chapterStart = _chapters[i].startTimeOffset ?? 0;
if (currentPositionMs > chapterStart + 3000) {
// If more than 3 seconds into chapter, go to start of current chapter
_seekToPosition(Duration(milliseconds: chapterStart));
return;
}
}
// If at start of first chapter, go to beginning
_seekToPosition(Duration.zero);
}
// If at start of first chapter, go to beginning
widget.player.seek(Duration.zero);
}
void _seekToNextChapter() {
if (_chapters.isEmpty) {
// No chapters - seek forward by configured amount
seekWithClamping(widget.player, Duration(seconds: _seekTimeSmall));
return;
}
final currentPosition = widget.player.state.position.inMilliseconds;
// Find next chapter
for (int i = 0; i < _chapters.length; i++) {
final chapterStart = _chapters[i].startTimeOffset ?? 0;
if (chapterStart > currentPosition) {
widget.player.seek(Duration(milliseconds: chapterStart));
return;
}
}
void _seekToPosition(Duration position) {
widget.player.seek(position);
widget.onSeekCompleted?.call(position);
}
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms
@@ -728,12 +767,26 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
void _finalizeSeek(Duration position) {
_seekThrottle.cancel();
widget.player.seek(position);
widget.onSeekCompleted?.call(position);
}
/// Handle double-tap skip forward or backward
void _handleDoubleTapSkip({required bool isForward}) {
// Ignore if user cannot control playback
if (!widget.canControl) return;
// Calculate the new position (clamped to valid range)
final currentPosition = widget.player.state.position;
final duration = widget.player.state.duration;
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
final unclamped = currentPosition + delta;
final newPosition = unclamped < Duration.zero ? Duration.zero : (unclamped > duration ? duration : unclamped);
// Perform the seek
seekWithClamping(widget.player, Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall));
seekWithClamping(widget.player, delta);
// Notify Watch Together
widget.onSeekCompleted?.call(newPosition);
// Show visual feedback
_showSkipFeedback(isForward: isForward);
@@ -838,8 +891,48 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
key == LogicalKeyboardKey.gameButtonA;
}
/// Show controls and focus play/pause on keyboard input (desktop only)
void _showControlsWithFocus() {
/// Determine if the key event should toggle play/pause based on configured hotkeys.
bool _isPlayPauseKey(KeyEvent event) {
final physicalKey = event.physicalKey;
// When the shortcuts service is available, respect the configured play/pause hotkey
if (_keyboardService != null) {
final hotkey = _keyboardService!.hotkeys['play_pause'];
if (hotkey == null) return false;
return hotkey.key == physicalKey;
}
// Fallback to defaults while the service is loading
return physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause;
}
bool _isPlayPauseActivation(KeyEvent event) {
return event is KeyDownEvent && _isPlayPauseKey(event);
}
/// Global key event handler for focus-independent shortcuts (desktop only)
bool _handleGlobalKeyEvent(KeyEvent event) {
if (!mounted) return false;
// Only handle when video player navigation is disabled (desktop mode without D-pad nav)
if (_videoPlayerNavigationEnabled) return false;
// Skip on mobile (unless TV)
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
if (isMobile) return false;
// Handle play/pause globally - works regardless of focus
if (_isPlayPauseActivation(event)) {
widget.player.playOrPause();
_showControlsWithFocus(requestFocus: false);
return true; // Event handled, stop propagation
}
return false; // Let event continue to other handlers
}
/// Show controls and optionally focus play/pause on keyboard input (desktop only)
void _showControlsWithFocus({bool requestFocus = true}) {
if (!_showControls) {
setState(() {
_showControls = true;
@@ -853,9 +946,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
_startHideTimer();
// Request focus on play/pause button after controls are shown
WidgetsBinding.instance.addPostFrameCallback((_) {
_desktopControlsKey.currentState?.requestPlayPauseFocus();
});
if (requestFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_desktopControlsKey.currentState?.requestPlayPauseFocus();
});
} else {
// When not requesting focus on play/pause, ensure main focus node keeps focus
// This prevents focus from being lost when controls become visible
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
}
}
/// Hide controls when navigating up from timeline (keyboard mode)
@@ -893,6 +996,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
final key = event.logicalKey;
final isPlayPauseKey = _isPlayPauseKey(event);
// Handle play/pause via focus when navigation is enabled (TV/gamepad)
// When navigation is disabled, the global handler (_handleGlobalKeyEvent) handles it
if (_videoPlayerNavigationEnabled || isMobile) {
if (_isPlayPauseActivation(event)) {
widget.player.playOrPause();
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
return KeyEventResult.handled;
}
// Swallow other play/pause events (e.g., key up/repeat) to prevent focus side effects
if (isPlayPauseKey) {
return KeyEventResult.handled;
}
}
// Handle Back/Escape: show controls if hidden, navigate back if visible
if (_isBackKey(key)) {
@@ -929,7 +1047,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Pass other events to the keyboard shortcuts service
if (_keyboardService == null) return KeyEventResult.ignored;
return _keyboardService!.handleVideoPlayerKeyEvent(
final result = _keyboardService!.handleVideoPlayerKeyEvent(
event,
widget.player,
_toggleFullscreen,
@@ -938,8 +1056,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
_nextSubtitleTrack,
_nextChapter,
_previousChapter,
onBack: () => Navigator.of(context).pop(true),
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
);
return result;
},
child: Listener(
behavior: HitTestBehavior.translucent,
@@ -1000,9 +1119,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
trackChapterControls: _buildTrackChapterControlsWidget(),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onSeekCompleted: widget.onSeekCompleted,
onPlayPause: () {}, // Not used, handled internally
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onBack: widget.onBack,
canControl: widget.canControl,
),
)
: Listener(
@@ -1045,6 +1167,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
serverId: widget.metadata.serverId ?? '',
onBack: widget.onBack,
canControl: widget.canControl,
),
),
),
@@ -25,6 +25,9 @@ class TimelineSlider extends StatelessWidget {
/// Called when focus changes.
final ValueChanged<bool>? onFocusChange;
/// Whether the slider is enabled for interaction.
final bool enabled;
const TimelineSlider({
super.key,
required this.position,
@@ -36,6 +39,7 @@ class TimelineSlider extends StatelessWidget {
this.focusNode,
this.onKeyEvent,
this.onFocusChange,
this.enabled = true,
});
@override
@@ -68,22 +72,25 @@ class TimelineSlider extends StatelessWidget {
),
),
),
// Slider
Semantics(
label: t.videoControls.timelineSlider,
slider: true,
child: Slider(
value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0,
min: 0.0,
max: duration.inMilliseconds.toDouble(),
onChanged: (value) {
onSeek(Duration(milliseconds: value.toInt()));
},
onChangeEnd: (value) {
onSeekEnd(Duration(milliseconds: value.toInt()));
},
activeColor: Colors.white,
inactiveColor: Colors.white.withValues(alpha: 0.3),
// Slider - use IgnorePointer to block interaction while preserving visual style
IgnorePointer(
ignoring: !enabled,
child: Semantics(
label: t.videoControls.timelineSlider,
slider: true,
child: Slider(
value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0,
min: 0.0,
max: duration.inMilliseconds.toDouble(),
onChanged: (value) {
onSeek(Duration(milliseconds: value.toInt()));
},
onChangeEnd: (value) {
onSeekEnd(Duration(milliseconds: value.toInt()));
},
activeColor: Colors.white,
inactiveColor: Colors.white.withValues(alpha: 0.3),
),
),
),
// Chapter marker indicators
@@ -105,7 +112,7 @@ class TimelineSlider extends StatelessWidget {
if (focusNode != null) {
slider = FocusableWrapper(
focusNode: focusNode,
onKeyEvent: onKeyEvent,
onKeyEvent: enabled ? onKeyEvent : null,
onFocusChange: onFocusChange,
borderRadius: 8,
autoScroll: false,
@@ -51,6 +51,9 @@ class TrackChapterControls extends StatelessWidget {
/// Called to navigate left from the first button
final VoidCallback? onNavigateLeft;
/// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl;
const TrackChapterControls({
super.key,
required this.player,
@@ -76,6 +79,7 @@ class TrackChapterControls extends StatelessWidget {
this.focusNodes,
this.onFocusChange,
this.onNavigateLeft,
this.canControl = true,
});
/// Handle key event for button navigation
@@ -175,6 +179,7 @@ class TrackChapterControls extends StatelessWidget {
subtitleSyncOffset,
onOpen: onCancelAutoHide,
onClose: onStartAutoHide,
canControl: canControl,
);
onLoadSeekTimes?.call();
},
@@ -24,11 +24,15 @@ class VideoControlsHeader extends StatelessWidget {
/// Optional trailing widget (e.g., track/chapter controls)
final Widget? trailing;
/// Optional callback for back button. If null, defaults to Navigator.pop(true).
final VoidCallback? onBack;
const VideoControlsHeader({
super.key,
required this.metadata,
this.style = VideoHeaderStyle.multiLine,
this.trailing,
this.onBack,
});
@override
@@ -38,7 +42,7 @@ class VideoControlsHeader extends StatelessWidget {
AppBarBackButton(
style: BackButtonStyle.video,
semanticLabel: t.videoControls.backButton,
onPressed: () => Navigator.of(context).pop(true),
onPressed: onBack ?? () => Navigator.of(context).pop(true),
),
const SizedBox(width: 16),
Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()),
@@ -30,6 +30,9 @@ class VideoTimelineBar extends StatelessWidget {
/// Called when focus changes.
final ValueChanged<bool>? onFocusChange;
/// Whether the timeline is enabled for interaction.
final bool enabled;
const VideoTimelineBar({
super.key,
required this.player,
@@ -41,6 +44,7 @@ class VideoTimelineBar extends StatelessWidget {
this.focusNode,
this.onKeyEvent,
this.onFocusChange,
this.enabled = true,
});
@override
@@ -109,6 +113,7 @@ class VideoTimelineBar extends StatelessWidget {
focusNode: focusNode,
onKeyEvent: onKeyEvent,
onFocusChange: onFocusChange,
enabled: enabled,
);
}
}