feat: watch together
This commit is contained in:
@@ -38,6 +38,9 @@ PODS:
|
|||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- flutter_webrtc (0.11.3):
|
||||||
|
- Flutter
|
||||||
|
- WebRTC-SDK (= 125.6422.04)
|
||||||
- gamepads_ios (0.1.1):
|
- gamepads_ios (0.1.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- os_media_controls (0.0.1):
|
- os_media_controls (0.0.1):
|
||||||
@@ -50,6 +53,8 @@ PODS:
|
|||||||
- SDWebImage (5.21.3):
|
- SDWebImage (5.21.3):
|
||||||
- SDWebImage/Core (= 5.21.3)
|
- SDWebImage/Core (= 5.21.3)
|
||||||
- SDWebImage/Core (5.21.3)
|
- SDWebImage/Core (5.21.3)
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -86,6 +91,7 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- wakelock_plus (0.0.1):
|
- wakelock_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- WebRTC-SDK (125.6422.04)
|
||||||
- workmanager_apple (0.0.1):
|
- workmanager_apple (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
@@ -94,10 +100,12 @@ DEPENDENCIES:
|
|||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||||
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
|
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
|
||||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||||
@@ -112,6 +120,7 @@ SPEC REPOS:
|
|||||||
- SDWebImage
|
- SDWebImage
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
- WebRTC-SDK
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
@@ -122,6 +131,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_webrtc:
|
||||||
|
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
||||||
gamepads_ios:
|
gamepads_ios:
|
||||||
:path: ".symlinks/plugins/gamepads_ios/ios"
|
:path: ".symlinks/plugins/gamepads_ios/ios"
|
||||||
os_media_controls:
|
os_media_controls:
|
||||||
@@ -130,6 +141,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
share_plus:
|
||||||
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
@@ -150,11 +163,13 @@ SPEC CHECKSUMS:
|
|||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_webrtc: 89f078a5050779eaa4a6a4f472954737bb91f39f
|
||||||
gamepads_ios: c75c6d31377d275b0effb9174c619e2705678c09
|
gamepads_ios: c75c6d31377d275b0effb9174c619e2705678c09
|
||||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||||
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
|
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
|
||||||
@@ -162,6 +177,7 @@ SPEC CHECKSUMS:
|
|||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
|
WebRTC-SDK: c3d69a87e7185fad3568f6f3cff7c9ac5890acf3
|
||||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import 'providers/playback_state_provider.dart';
|
|||||||
import 'providers/download_provider.dart';
|
import 'providers/download_provider.dart';
|
||||||
import 'providers/offline_mode_provider.dart';
|
import 'providers/offline_mode_provider.dart';
|
||||||
import 'providers/offline_watch_provider.dart';
|
import 'providers/offline_watch_provider.dart';
|
||||||
|
import 'watch_together/watch_together.dart';
|
||||||
import 'services/multi_server_manager.dart';
|
import 'services/multi_server_manager.dart';
|
||||||
import 'services/offline_watch_sync_service.dart';
|
import 'services/offline_watch_sync_service.dart';
|
||||||
import 'services/data_aggregation_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) => SettingsProvider(), lazy: true),
|
||||||
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true),
|
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true),
|
||||||
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
||||||
|
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
|
||||||
],
|
],
|
||||||
child: Consumer<ThemeProvider>(
|
child: Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, child) {
|
builder: (context, themeProvider, child) {
|
||||||
@@ -344,6 +346,9 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
// Migrate from single-server to multi-server if needed
|
// Migrate from single-server to multi-server if needed
|
||||||
await registry.migrateFromSingleServer();
|
await registry.migrateFromSingleServer();
|
||||||
|
|
||||||
|
// Refresh servers from API to get updated connection info (IPs may change)
|
||||||
|
await registry.refreshServersFromApi();
|
||||||
|
|
||||||
// Load all configured servers
|
// Load all configured servers
|
||||||
final servers = await registry.getServers();
|
final servers = await registry.getServers();
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import '../utils/layout_constants.dart';
|
|||||||
import '../theme/mono_tokens.dart';
|
import '../theme/mono_tokens.dart';
|
||||||
import 'auth_screen.dart';
|
import 'auth_screen.dart';
|
||||||
import 'libraries/state_messages.dart';
|
import 'libraries/state_messages.dart';
|
||||||
|
import '../watch_together/watch_together.dart';
|
||||||
|
|
||||||
class DiscoverScreen extends StatefulWidget {
|
class DiscoverScreen extends StatefulWidget {
|
||||||
final VoidCallback? onBecameVisible;
|
final VoidCallback? onBecameVisible;
|
||||||
@@ -72,8 +73,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
// Hero and app bar focus
|
// Hero and app bar focus
|
||||||
late FocusNode _heroFocusNode;
|
late FocusNode _heroFocusNode;
|
||||||
late FocusNode _refreshButtonFocusNode;
|
late FocusNode _refreshButtonFocusNode;
|
||||||
|
late FocusNode _watchTogetherButtonFocusNode;
|
||||||
late FocusNode _userButtonFocusNode;
|
late FocusNode _userButtonFocusNode;
|
||||||
bool _isRefreshFocused = false;
|
bool _isRefreshFocused = false;
|
||||||
|
bool _isWatchTogetherFocused = false;
|
||||||
bool _isUserFocused = false;
|
bool _isUserFocused = false;
|
||||||
|
|
||||||
/// Get the correct PlexClient for an item's server
|
/// Get the correct PlexClient for an item's server
|
||||||
@@ -155,8 +158,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
_indicatorAnimationController = AnimationController(vsync: this, duration: _heroAutoScrollDuration);
|
_indicatorAnimationController = AnimationController(vsync: this, duration: _heroAutoScrollDuration);
|
||||||
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
|
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
|
||||||
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
|
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
|
||||||
|
_watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button');
|
||||||
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
|
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
|
||||||
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
|
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
|
||||||
|
_watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange);
|
||||||
_userButtonFocusNode.addListener(_onUserFocusChange);
|
_userButtonFocusNode.addListener(_onUserFocusChange);
|
||||||
_loadContent();
|
_loadContent();
|
||||||
_startAutoScroll();
|
_startAutoScroll();
|
||||||
@@ -170,6 +175,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onWatchTogetherFocusChange() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onUserFocusChange() {
|
void _onUserFocusChange() {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -242,9 +255,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RIGHT: Move to user button
|
// RIGHT: Move to watch together button
|
||||||
if (key.isRightKey) {
|
if (key.isRightKey) {
|
||||||
_userButtonFocusNode.requestFocus();
|
_watchTogetherButtonFocusNode.requestFocus();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +275,46 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
return KeyEventResult.ignored;
|
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
|
/// Handle key events for the user button in app bar
|
||||||
KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) {
|
KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) {
|
||||||
if (!event.isActionable) {
|
if (!event.isActionable) {
|
||||||
@@ -276,9 +329,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LEFT: Move to refresh button
|
// LEFT: Move to watch together button
|
||||||
if (key.isLeftKey) {
|
if (key.isLeftKey) {
|
||||||
_refreshButtonFocusNode.requestFocus();
|
_watchTogetherButtonFocusNode.requestFocus();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +359,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
_heroFocusNode.dispose();
|
_heroFocusNode.dispose();
|
||||||
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
|
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
|
||||||
_refreshButtonFocusNode.dispose();
|
_refreshButtonFocusNode.dispose();
|
||||||
|
_watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange);
|
||||||
|
_watchTogetherButtonFocusNode.dispose();
|
||||||
_userButtonFocusNode.removeListener(_onUserFocusChange);
|
_userButtonFocusNode.removeListener(_onUserFocusChange);
|
||||||
_userButtonFocusNode.dispose();
|
_userButtonFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -748,6 +803,60 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
child: IconButton(icon: const AppIcon(Symbols.refresh_rounded, fill: 1), onPressed: _loadContent),
|
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>(
|
Consumer<UserProfileProvider>(
|
||||||
builder: (context, userProvider, child) {
|
builder: (context, userProvider, child) {
|
||||||
return Focus(
|
return Focus(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../services/plex_client.dart';
|
|||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
|
import '../utils/video_player_navigation.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../mixins/refreshable.dart';
|
import '../mixins/refreshable.dart';
|
||||||
import '../navigation/navigation_tabs.dart';
|
import '../navigation/navigation_tabs.dart';
|
||||||
@@ -23,6 +24,8 @@ import 'libraries/libraries_screen.dart';
|
|||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'downloads/downloads_screen.dart';
|
import 'downloads/downloads_screen.dart';
|
||||||
import 'settings/settings_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.
|
/// Provides access to the main screen's focus control.
|
||||||
class MainScreenFocusScope extends InheritedWidget {
|
class MainScreenFocusScope extends InheritedWidget {
|
||||||
@@ -100,6 +103,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
|||||||
|
|
||||||
_screens = _buildScreens(_isOffline);
|
_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)
|
// Set up data invalidation callback for profile switching (skip in offline mode)
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
if (!_isOffline) {
|
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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|||||||
@@ -120,42 +120,55 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
|||||||
|
|
||||||
/// Build action buttons row (play, shuffle, download, mark watched)
|
/// Build action buttons row (play, shuffle, download, mark watched)
|
||||||
Widget _buildActionButtons(PlexMetadata metadata) {
|
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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: FilledButton.icon(
|
child: FilledButton(
|
||||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||||
onPressed: () async {
|
onPressed: onPlayPressed,
|
||||||
// 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)),
|
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16)),
|
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),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../models/plex_metadata.dart';
|
|||||||
import '../utils/content_utils.dart';
|
import '../utils/content_utils.dart';
|
||||||
import '../models/plex_media_info.dart';
|
import '../models/plex_media_info.dart';
|
||||||
import '../providers/download_provider.dart';
|
import '../providers/download_provider.dart';
|
||||||
|
import '../providers/multi_server_provider.dart';
|
||||||
import '../providers/playback_state_provider.dart';
|
import '../providers/playback_state_provider.dart';
|
||||||
import '../services/episode_navigation_service.dart';
|
import '../services/episode_navigation_service.dart';
|
||||||
import '../services/media_controls_manager.dart';
|
import '../services/media_controls_manager.dart';
|
||||||
@@ -37,6 +38,7 @@ import '../utils/snackbar_helper.dart';
|
|||||||
import '../utils/video_player_navigation.dart';
|
import '../utils/video_player_navigation.dart';
|
||||||
import '../widgets/video_controls/video_controls.dart';
|
import '../widgets/video_controls/video_controls.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../watch_together/providers/watch_together_provider.dart';
|
||||||
|
|
||||||
class VideoPlayerScreen extends StatefulWidget {
|
class VideoPlayerScreen extends StatefulWidget {
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
@@ -61,6 +63,13 @@ class VideoPlayerScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindingObserver {
|
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;
|
Player? player;
|
||||||
bool _isPlayerInitialized = false;
|
bool _isPlayerInitialized = false;
|
||||||
PlexMetadata? _nextEpisode;
|
PlexMetadata? _nextEpisode;
|
||||||
@@ -89,6 +98,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
VideoFilterManager? _videoFilterManager;
|
VideoFilterManager? _videoFilterManager;
|
||||||
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
|
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
|
/// Get the correct PlexClient for this metadata's server
|
||||||
PlexClient _getClientForMetadata(BuildContext context) {
|
PlexClient _getClientForMetadata(BuildContext context) {
|
||||||
return context.getClientForServer(widget.metadata.serverId!);
|
return context.getClientForServer(widget.metadata.serverId!);
|
||||||
@@ -100,6 +112,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
_activeRatingKey = widget.metadata.ratingKey;
|
||||||
|
_activeMediaIndex = widget.selectedMediaIndex;
|
||||||
|
|
||||||
appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}');
|
appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}');
|
||||||
if (widget.preferredAudioTrack != null) {
|
if (widget.preferredAudioTrack != null) {
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
@@ -628,6 +643,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
? Duration(milliseconds: widget.metadata.viewOffset!)
|
? Duration(milliseconds: widget.metadata.viewOffset!)
|
||||||
: null;
|
: null;
|
||||||
await player!.open(Media(result.videoUrl!, start: resumePosition));
|
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
|
// 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Unregister app lifecycle observer
|
// Unregister app lifecycle observer
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
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
|
// Dispose value notifiers
|
||||||
_isBuffering.dispose();
|
_isBuffering.dispose();
|
||||||
|
|
||||||
@@ -776,6 +933,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
|
|
||||||
player?.dispose();
|
player?.dispose();
|
||||||
|
if (_activeRatingKey == widget.metadata.ratingKey) {
|
||||||
|
_activeRatingKey = null;
|
||||||
|
_activeMediaIndex = null;
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,6 +1000,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
Future<void> _playNext() async {
|
Future<void> _playNext() async {
|
||||||
if (_nextEpisode == null || _isLoadingNext) return;
|
if (_nextEpisode == null || _isLoadingNext) return;
|
||||||
|
|
||||||
|
// Notify Watch Together of episode change before navigating
|
||||||
|
_notifyWatchTogetherMediaChange(metadata: _nextEpisode);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingNext = true;
|
_isLoadingNext = true;
|
||||||
_showPlayNextDialog = false;
|
_showPlayNextDialog = false;
|
||||||
@@ -849,6 +1013,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
Future<void> _playPrevious() async {
|
Future<void> _playPrevious() async {
|
||||||
if (_previousEpisode == null) return;
|
if (_previousEpisode == null) return;
|
||||||
|
|
||||||
|
// Notify Watch Together of episode change before navigating
|
||||||
|
_notifyWatchTogetherMediaChange(metadata: _previousEpisode);
|
||||||
|
|
||||||
await _navigateToEpisode(_previousEpisode!);
|
await _navigateToEpisode(_previousEpisode!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,6 +1295,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_isDisposingForNavigation = true;
|
_isDisposingForNavigation = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_detachFromWatchTogetherSession();
|
||||||
_progressTracker?.sendProgress('stopped');
|
_progressTracker?.sendProgress('stopped');
|
||||||
_progressTracker?.stopTracking();
|
_progressTracker?.stopTracking();
|
||||||
await player?.dispose();
|
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
|
canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
// Allow programmatic back navigation from UI controls
|
// Allow programmatic back navigation from UI controls
|
||||||
if (!didPop) {
|
if (!didPop) _handleBackButton();
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
// Use transparent background on macOS when native video layer is active
|
// 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(
|
return Video(
|
||||||
player: player!,
|
player: player!,
|
||||||
controls: (context) => plexVideoControlsBuilder(
|
controls: (context) => plexVideoControlsBuilder(
|
||||||
player!,
|
player!,
|
||||||
widget.metadata,
|
widget.metadata,
|
||||||
onNext: _nextEpisode != null ? _playNext : null,
|
onNext: (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null,
|
||||||
onPrevious: _previousEpisode != null ? _playPrevious : null,
|
onPrevious: (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null,
|
||||||
availableVersions: _availableVersions,
|
availableVersions: _availableVersions,
|
||||||
selectedMediaIndex: widget.selectedMediaIndex,
|
selectedMediaIndex: widget.selectedMediaIndex,
|
||||||
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
||||||
onCycleBoxFitMode: _cycleBoxFitMode,
|
onCycleBoxFitMode: _cycleBoxFitMode,
|
||||||
onAudioTrackChanged: _onAudioTrackChanged,
|
onAudioTrackChanged: _onAudioTrackChanged,
|
||||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||||
|
onSeekCompleted: (position) {
|
||||||
|
// Notify Watch Together of seek for sync
|
||||||
|
// Note: canControl() check is done in sync manager, not here
|
||||||
|
// This matches play/pause behavior and avoids timing issues
|
||||||
|
try {
|
||||||
|
final watchTogether = this.context.read<WatchTogetherProvider>();
|
||||||
|
if (watchTogether.isInSession) {
|
||||||
|
watchTogether.onLocalSeek(position);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Watch Together not available, ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack: _handleBackButton,
|
||||||
|
canControl: canControl,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,6 +95,50 @@ class ServerRegistry {
|
|||||||
appLogger.i('Cleared all servers from registry');
|
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
|
/// Migrate from single server storage to multi-server
|
||||||
/// This is called during app startup to migrate existing users
|
/// This is called during app startup to migrate existing users
|
||||||
Future<void> migrateFromSingleServer() async {
|
Future<void> migrateFromSingleServer() async {
|
||||||
|
|||||||
@@ -335,11 +335,7 @@ class StorageService extends BaseSharedPreferencesService {
|
|||||||
|
|
||||||
/// Clear all multi-server data
|
/// Clear all multi-server data
|
||||||
Future<void> clearMultiServerData() async {
|
Future<void> clearMultiServerData() async {
|
||||||
await Future.wait([
|
await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]);
|
||||||
clearServersList(),
|
|
||||||
clearServerOrder(),
|
|
||||||
_clearKeysWithPrefix(_prefixServerEndpoint),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server Order (stored as JSON list of server IDs)
|
/// Server Order (stored as JSON list of server IDs)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import '../mpv/mpv.dart';
|
import '../mpv/mpv.dart';
|
||||||
|
|
||||||
/// Seeks by the given offset (can be positive or negative) while clamping
|
/// Seeks by the given offset (can be positive or negative) while clamping
|
||||||
/// the result between 0 and the video duration
|
/// the result between 0 and the video duration.
|
||||||
void seekWithClamping(Player player, Duration offset) {
|
/// Returns the clamped position that was seeked to.
|
||||||
|
Duration seekWithClamping(Player player, Duration offset) {
|
||||||
final currentPosition = player.state.position;
|
final currentPosition = player.state.position;
|
||||||
final duration = player.state.duration;
|
final duration = player.state.duration;
|
||||||
final newPosition = currentPosition + offset;
|
final newPosition = currentPosition + offset;
|
||||||
@@ -11,4 +12,5 @@ void seekWithClamping(Player player, Duration offset) {
|
|||||||
final clampedPosition = newPosition.isNegative ? Duration.zero : (newPosition > duration ? duration : newPosition);
|
final clampedPosition = newPosition.isNegative ? Duration.zero : (newPosition > duration ? duration : newPosition);
|
||||||
|
|
||||||
player.seek(clampedPosition);
|
player.seek(clampedPosition);
|
||||||
|
return clampedPosition;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import '../screens/video_player_screen.dart';
|
|||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import 'app_logger.dart';
|
import 'app_logger.dart';
|
||||||
|
|
||||||
|
const String kVideoPlayerRouteName = '/video_player';
|
||||||
|
|
||||||
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
|
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
|
||||||
///
|
///
|
||||||
/// This utility function provides a consistent way to navigate to the video player
|
/// 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>(
|
final route = PageRouteBuilder<bool>(
|
||||||
|
settings: const RouteSettings(name: kVideoPlayerRouteName),
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
preferredAudioTrack: preferredAudioTrack,
|
preferredAudioTrack: preferredAudioTrack,
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'watch_session.dart';
|
||||||
|
|
||||||
|
/// Types of sync messages sent over the WebRTC data channel
|
||||||
|
enum SyncMessageType {
|
||||||
|
/// Start playback
|
||||||
|
play,
|
||||||
|
|
||||||
|
/// Pause playback
|
||||||
|
pause,
|
||||||
|
|
||||||
|
/// Seek to position
|
||||||
|
seek,
|
||||||
|
|
||||||
|
/// Buffering state changed
|
||||||
|
buffering,
|
||||||
|
|
||||||
|
/// Periodic position update (for drift correction)
|
||||||
|
positionSync,
|
||||||
|
|
||||||
|
/// Playback rate changed
|
||||||
|
rate,
|
||||||
|
|
||||||
|
/// Participant joined the session
|
||||||
|
join,
|
||||||
|
|
||||||
|
/// Participant left the session
|
||||||
|
leave,
|
||||||
|
|
||||||
|
/// Session configuration (sent by host on join)
|
||||||
|
sessionConfig,
|
||||||
|
|
||||||
|
/// Ping for latency measurement
|
||||||
|
ping,
|
||||||
|
|
||||||
|
/// Pong response
|
||||||
|
pong,
|
||||||
|
|
||||||
|
/// Media switch (host changed content)
|
||||||
|
mediaSwitch,
|
||||||
|
|
||||||
|
/// Host exited the video player
|
||||||
|
hostExitedPlayer,
|
||||||
|
|
||||||
|
/// Player is ready (attached and loaded)
|
||||||
|
playerReady,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message sent over the WebRTC data channel for synchronization
|
||||||
|
class SyncMessage {
|
||||||
|
/// Type of this message
|
||||||
|
final SyncMessageType type;
|
||||||
|
|
||||||
|
/// Timestamp when this message was created (Unix ms)
|
||||||
|
final int timestamp;
|
||||||
|
|
||||||
|
/// Position in milliseconds (for seek, positionSync)
|
||||||
|
final int? positionMs;
|
||||||
|
|
||||||
|
/// Buffering state (for buffering message)
|
||||||
|
final bool? bufferingState;
|
||||||
|
|
||||||
|
/// Playback rate (for rate message)
|
||||||
|
final double? rate;
|
||||||
|
|
||||||
|
/// Peer ID of the sender
|
||||||
|
final String? peerId;
|
||||||
|
|
||||||
|
/// Display name of the sender (for join message)
|
||||||
|
final String? displayName;
|
||||||
|
|
||||||
|
/// Whether the sender is the host (for join message)
|
||||||
|
final bool? isHost;
|
||||||
|
|
||||||
|
/// Control mode (for sessionConfig message)
|
||||||
|
final ControlMode? controlMode;
|
||||||
|
|
||||||
|
/// Ping ID for matching pong responses
|
||||||
|
final int? pingId;
|
||||||
|
|
||||||
|
/// Rating key of the media (for mediaSwitch message)
|
||||||
|
final String? ratingKey;
|
||||||
|
|
||||||
|
/// Server ID of the media (for mediaSwitch message)
|
||||||
|
final String? serverId;
|
||||||
|
|
||||||
|
/// Title of the media (for mediaSwitch message)
|
||||||
|
final String? mediaTitle;
|
||||||
|
|
||||||
|
const SyncMessage({
|
||||||
|
required this.type,
|
||||||
|
required this.timestamp,
|
||||||
|
this.positionMs,
|
||||||
|
this.bufferingState,
|
||||||
|
this.rate,
|
||||||
|
this.peerId,
|
||||||
|
this.displayName,
|
||||||
|
this.isHost,
|
||||||
|
this.controlMode,
|
||||||
|
this.pingId,
|
||||||
|
this.ratingKey,
|
||||||
|
this.serverId,
|
||||||
|
this.mediaTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create a PLAY message
|
||||||
|
factory SyncMessage.play({String? peerId, Duration? position}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.play,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
peerId: peerId,
|
||||||
|
positionMs: position?.inMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a PAUSE message
|
||||||
|
factory SyncMessage.pause({String? peerId}) {
|
||||||
|
return SyncMessage(type: SyncMessageType.pause, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a SEEK message
|
||||||
|
factory SyncMessage.seek(Duration position, {String? peerId}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.seek,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
positionMs: position.inMilliseconds,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a BUFFERING message
|
||||||
|
factory SyncMessage.buffering(bool isBuffering, {String? peerId}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.buffering,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
bufferingState: isBuffering,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a POSITION_SYNC message
|
||||||
|
factory SyncMessage.positionSync(Duration position, {String? peerId}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.positionSync,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
positionMs: position.inMilliseconds,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a RATE message
|
||||||
|
factory SyncMessage.rate(double playbackRate, {String? peerId}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.rate,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
rate: playbackRate,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a JOIN message
|
||||||
|
factory SyncMessage.join({required String peerId, required String displayName, required bool isHost}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.join,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
peerId: peerId,
|
||||||
|
displayName: displayName,
|
||||||
|
isHost: isHost,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a LEAVE message
|
||||||
|
factory SyncMessage.leave({required String peerId}) {
|
||||||
|
return SyncMessage(type: SyncMessageType.leave, timestamp: DateTime.now().millisecondsSinceEpoch, peerId: peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a SESSION_CONFIG message (sent by host to new guests)
|
||||||
|
factory SyncMessage.sessionConfig({
|
||||||
|
required ControlMode controlMode,
|
||||||
|
required Duration currentPosition,
|
||||||
|
required bool isPlaying,
|
||||||
|
required double playbackRate,
|
||||||
|
String? peerId,
|
||||||
|
}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.sessionConfig,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
controlMode: controlMode,
|
||||||
|
positionMs: currentPosition.inMilliseconds,
|
||||||
|
bufferingState: !isPlaying, // Reuse field: false = playing, true = paused
|
||||||
|
rate: playbackRate,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a PING message
|
||||||
|
factory SyncMessage.ping(int pingId, {String? peerId}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.ping,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
pingId: pingId,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a PONG message
|
||||||
|
factory SyncMessage.pong(int pingId, {String? peerId}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.pong,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
pingId: pingId,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a MEDIA_SWITCH message (sent by host when changing content)
|
||||||
|
factory SyncMessage.mediaSwitch({
|
||||||
|
required String ratingKey,
|
||||||
|
required String serverId,
|
||||||
|
required String mediaTitle,
|
||||||
|
String? peerId,
|
||||||
|
}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.mediaSwitch,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
ratingKey: ratingKey,
|
||||||
|
serverId: serverId,
|
||||||
|
mediaTitle: mediaTitle,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a HOST_EXITED_PLAYER message (sent by host when exiting video player)
|
||||||
|
factory SyncMessage.hostExitedPlayer({String? peerId}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.hostExitedPlayer,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
peerId: peerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a PLAYER_READY message (sent when player is attached and ready)
|
||||||
|
factory SyncMessage.playerReady({required String peerId, required bool ready}) {
|
||||||
|
return SyncMessage(
|
||||||
|
type: SyncMessageType.playerReady,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
peerId: peerId,
|
||||||
|
bufferingState: ready, // Reuse bufferingState field for ready status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position as Duration (convenience getter)
|
||||||
|
Duration? get position => positionMs != null ? Duration(milliseconds: positionMs!) : null;
|
||||||
|
|
||||||
|
/// Serialize to JSON string for sending over data channel
|
||||||
|
String toJson() {
|
||||||
|
final map = <String, dynamic>{'t': type.name, 'ts': timestamp};
|
||||||
|
|
||||||
|
if (positionMs != null) map['pos'] = positionMs;
|
||||||
|
if (bufferingState != null) map['buf'] = bufferingState;
|
||||||
|
if (rate != null) map['r'] = rate;
|
||||||
|
if (peerId != null) map['pid'] = peerId;
|
||||||
|
if (displayName != null) map['name'] = displayName;
|
||||||
|
if (isHost != null) map['host'] = isHost;
|
||||||
|
if (controlMode != null) map['ctrl'] = controlMode!.index;
|
||||||
|
if (pingId != null) map['ping'] = pingId;
|
||||||
|
if (ratingKey != null) map['rk'] = ratingKey;
|
||||||
|
if (serverId != null) map['sid'] = serverId;
|
||||||
|
if (mediaTitle != null) map['title'] = mediaTitle;
|
||||||
|
|
||||||
|
return jsonEncode(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from JSON string received from data channel
|
||||||
|
factory SyncMessage.fromJson(String jsonString) {
|
||||||
|
final map = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final typeString = map['t'] as String;
|
||||||
|
final type = SyncMessageType.values.firstWhere(
|
||||||
|
(t) => t.name == typeString,
|
||||||
|
orElse: () => throw FormatException('Unknown message type: $typeString'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SyncMessage(
|
||||||
|
type: type,
|
||||||
|
timestamp: map['ts'] as int,
|
||||||
|
positionMs: map['pos'] as int?,
|
||||||
|
bufferingState: map['buf'] as bool?,
|
||||||
|
rate: (map['r'] as num?)?.toDouble(),
|
||||||
|
peerId: map['pid'] as String?,
|
||||||
|
displayName: map['name'] as String?,
|
||||||
|
isHost: map['host'] as bool?,
|
||||||
|
controlMode: map['ctrl'] != null ? ControlMode.values[map['ctrl'] as int] : null,
|
||||||
|
pingId: map['ping'] as int?,
|
||||||
|
ratingKey: map['rk'] as String?,
|
||||||
|
serverId: map['sid'] as String?,
|
||||||
|
mediaTitle: map['title'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SyncMessage(type: $type, timestamp: $timestamp, positionMs: $positionMs, '
|
||||||
|
'bufferingState: $bufferingState, rate: $rate, peerId: $peerId)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/// Session role - whether this device is the host or a guest
|
||||||
|
enum SessionRole { host, guest }
|
||||||
|
|
||||||
|
/// Control mode - who can control playback
|
||||||
|
enum ControlMode {
|
||||||
|
/// Only the host can control playback
|
||||||
|
hostOnly,
|
||||||
|
|
||||||
|
/// Anyone in the session can control playback
|
||||||
|
anyone,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state of the watch together session
|
||||||
|
enum SessionState {
|
||||||
|
/// Not connected to any session
|
||||||
|
disconnected,
|
||||||
|
|
||||||
|
/// Attempting to connect/create session
|
||||||
|
connecting,
|
||||||
|
|
||||||
|
/// Successfully connected to session
|
||||||
|
connected,
|
||||||
|
|
||||||
|
/// Connection error occurred
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a participant in a watch together session
|
||||||
|
class Participant {
|
||||||
|
final String peerId;
|
||||||
|
final String displayName;
|
||||||
|
final bool isHost;
|
||||||
|
Duration lastKnownPosition;
|
||||||
|
bool isBuffering;
|
||||||
|
|
||||||
|
Participant({
|
||||||
|
required this.peerId,
|
||||||
|
required this.displayName,
|
||||||
|
required this.isHost,
|
||||||
|
this.lastKnownPosition = Duration.zero,
|
||||||
|
this.isBuffering = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Participant copyWith({
|
||||||
|
String? peerId,
|
||||||
|
String? displayName,
|
||||||
|
bool? isHost,
|
||||||
|
Duration? lastKnownPosition,
|
||||||
|
bool? isBuffering,
|
||||||
|
}) {
|
||||||
|
return Participant(
|
||||||
|
peerId: peerId ?? this.peerId,
|
||||||
|
displayName: displayName ?? this.displayName,
|
||||||
|
isHost: isHost ?? this.isHost,
|
||||||
|
lastKnownPosition: lastKnownPosition ?? this.lastKnownPosition,
|
||||||
|
isBuffering: isBuffering ?? this.isBuffering,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) || other is Participant && runtimeType == other.runtimeType && peerId == other.peerId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => peerId.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a watch together session
|
||||||
|
class WatchSession {
|
||||||
|
/// Unique identifier for this session (used for joining)
|
||||||
|
final String sessionId;
|
||||||
|
|
||||||
|
/// This device's role in the session
|
||||||
|
final SessionRole role;
|
||||||
|
|
||||||
|
/// Who can control playback
|
||||||
|
final ControlMode controlMode;
|
||||||
|
|
||||||
|
/// Current connection state
|
||||||
|
final SessionState state;
|
||||||
|
|
||||||
|
/// List of participants in the session
|
||||||
|
final List<Participant> participants;
|
||||||
|
|
||||||
|
/// Error message if state is error
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// Rating key of the media being watched (for validation)
|
||||||
|
final String? mediaRatingKey;
|
||||||
|
|
||||||
|
/// Server ID of the media being watched (same-server requirement)
|
||||||
|
final String? mediaServerId;
|
||||||
|
|
||||||
|
/// Title of the media being watched
|
||||||
|
final String? mediaTitle;
|
||||||
|
|
||||||
|
/// The host's peer ID (used to identify host messages)
|
||||||
|
final String? hostPeerId;
|
||||||
|
|
||||||
|
const WatchSession({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.role,
|
||||||
|
required this.controlMode,
|
||||||
|
required this.state,
|
||||||
|
this.participants = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
this.mediaRatingKey,
|
||||||
|
this.mediaServerId,
|
||||||
|
this.mediaTitle,
|
||||||
|
this.hostPeerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether this device is the host
|
||||||
|
bool get isHost => role == SessionRole.host;
|
||||||
|
|
||||||
|
/// Whether the session is currently connected
|
||||||
|
bool get isConnected => state == SessionState.connected;
|
||||||
|
|
||||||
|
/// Number of participants (including self)
|
||||||
|
int get participantCount => participants.length;
|
||||||
|
|
||||||
|
WatchSession copyWith({
|
||||||
|
String? sessionId,
|
||||||
|
SessionRole? role,
|
||||||
|
ControlMode? controlMode,
|
||||||
|
SessionState? state,
|
||||||
|
List<Participant>? participants,
|
||||||
|
String? errorMessage,
|
||||||
|
String? mediaRatingKey,
|
||||||
|
String? mediaServerId,
|
||||||
|
String? mediaTitle,
|
||||||
|
String? hostPeerId,
|
||||||
|
}) {
|
||||||
|
return WatchSession(
|
||||||
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
role: role ?? this.role,
|
||||||
|
controlMode: controlMode ?? this.controlMode,
|
||||||
|
state: state ?? this.state,
|
||||||
|
participants: participants ?? this.participants,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
mediaRatingKey: mediaRatingKey ?? this.mediaRatingKey,
|
||||||
|
mediaServerId: mediaServerId ?? this.mediaServerId,
|
||||||
|
mediaTitle: mediaTitle ?? this.mediaTitle,
|
||||||
|
hostPeerId: hostPeerId ?? this.hostPeerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new session as host
|
||||||
|
factory WatchSession.createAsHost({
|
||||||
|
required String sessionId,
|
||||||
|
required String hostPeerId,
|
||||||
|
required ControlMode controlMode,
|
||||||
|
String? mediaRatingKey,
|
||||||
|
String? mediaServerId,
|
||||||
|
String? mediaTitle,
|
||||||
|
}) {
|
||||||
|
return WatchSession(
|
||||||
|
sessionId: sessionId,
|
||||||
|
role: SessionRole.host,
|
||||||
|
controlMode: controlMode,
|
||||||
|
state: SessionState.connecting,
|
||||||
|
hostPeerId: hostPeerId,
|
||||||
|
mediaRatingKey: mediaRatingKey,
|
||||||
|
mediaServerId: mediaServerId,
|
||||||
|
mediaTitle: mediaTitle,
|
||||||
|
participants: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a session as guest (joining)
|
||||||
|
factory WatchSession.joinAsGuest({required String sessionId}) {
|
||||||
|
return WatchSession(
|
||||||
|
sessionId: sessionId,
|
||||||
|
role: SessionRole.guest,
|
||||||
|
controlMode: ControlMode.hostOnly, // Will be updated when connected
|
||||||
|
state: SessionState.connecting,
|
||||||
|
participants: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../mpv/mpv.dart';
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../models/sync_message.dart';
|
||||||
|
import '../models/watch_session.dart';
|
||||||
|
import '../services/watch_together_peer_service.dart';
|
||||||
|
import '../services/watch_together_sync_manager.dart';
|
||||||
|
|
||||||
|
/// Callback type for when media switches (for guest navigation)
|
||||||
|
typedef MediaSwitchCallback = void Function(String ratingKey, String serverId, String mediaTitle);
|
||||||
|
|
||||||
|
/// Provider for Watch Together functionality
|
||||||
|
///
|
||||||
|
/// This provider manages:
|
||||||
|
/// - Session creation/joining
|
||||||
|
/// - Peer connections
|
||||||
|
/// - Playback synchronization
|
||||||
|
/// - Participant list
|
||||||
|
/// - Media switching across the session
|
||||||
|
class WatchTogetherProvider with ChangeNotifier {
|
||||||
|
WatchSession? _session;
|
||||||
|
WatchTogetherPeerService? _peerService;
|
||||||
|
WatchTogetherSyncManager? _syncManager;
|
||||||
|
final List<Participant> _participants = [];
|
||||||
|
bool _isSyncing = false;
|
||||||
|
String _displayName = 'User';
|
||||||
|
|
||||||
|
/// Generate a random display name for this session
|
||||||
|
static String _generateDisplayName() {
|
||||||
|
const adjectives = ['Happy', 'Sleepy', 'Sunny', 'Cozy', 'Chill', 'Swift', 'Brave', 'Calm', 'Jolly', 'Lucky'];
|
||||||
|
const nouns = ['Panda', 'Koala', 'Fox', 'Owl', 'Cat', 'Dog', 'Bear', 'Bunny', 'Duck', 'Penguin'];
|
||||||
|
final random = Random();
|
||||||
|
return '${adjectives[random.nextInt(adjectives.length)]} ${nouns[random.nextInt(nouns.length)]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback for when host switches media (guests should navigate)
|
||||||
|
/// Used by MainScreen when VideoPlayerScreen is not active
|
||||||
|
MediaSwitchCallback? onMediaSwitched;
|
||||||
|
|
||||||
|
/// Callback for VideoPlayerScreen to handle media switch internally (guest only)
|
||||||
|
/// When set, takes priority over onMediaSwitched for proper navigation context
|
||||||
|
MediaSwitchCallback? onPlayerMediaSwitched;
|
||||||
|
|
||||||
|
/// Callback for when host exits the video player (guests should exit too)
|
||||||
|
VoidCallback? onHostExitedPlayer;
|
||||||
|
|
||||||
|
// Stream subscriptions
|
||||||
|
StreamSubscription<String>? _peerConnectedSubscription;
|
||||||
|
StreamSubscription<String>? _peerDisconnectedSubscription;
|
||||||
|
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||||
|
StreamSubscription<PeerError>? _errorSubscription;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
bool get isInSession => _session != null && _session!.state != SessionState.disconnected;
|
||||||
|
bool get isHost => _session?.isHost ?? false;
|
||||||
|
bool get isConnected => _session?.isConnected ?? false;
|
||||||
|
bool get isSyncing => _isSyncing;
|
||||||
|
WatchSession? get session => _session;
|
||||||
|
List<Participant> get participants => List.unmodifiable(_participants);
|
||||||
|
int get participantCount => _participants.length;
|
||||||
|
ControlMode get controlMode => _session?.controlMode ?? ControlMode.hostOnly;
|
||||||
|
String? get sessionId => _session?.sessionId;
|
||||||
|
WatchTogetherSyncManager? get syncManager => _syncManager;
|
||||||
|
|
||||||
|
// Current media getters
|
||||||
|
String? get currentMediaRatingKey => _session?.mediaRatingKey;
|
||||||
|
String? get currentMediaServerId => _session?.mediaServerId;
|
||||||
|
String? get currentMediaTitle => _session?.mediaTitle;
|
||||||
|
|
||||||
|
/// Set the display name for this user
|
||||||
|
void setDisplayName(String name) {
|
||||||
|
_displayName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new watch together session as host
|
||||||
|
Future<String> createSession({
|
||||||
|
required ControlMode controlMode,
|
||||||
|
String? mediaRatingKey,
|
||||||
|
String? mediaServerId,
|
||||||
|
String? mediaTitle,
|
||||||
|
}) async {
|
||||||
|
// Clean up any existing session
|
||||||
|
await leaveSession();
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Creating session with control mode: $controlMode');
|
||||||
|
|
||||||
|
_peerService = WatchTogetherPeerService();
|
||||||
|
_setupPeerServiceListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sessionId = await _peerService!.createSession();
|
||||||
|
|
||||||
|
_session = WatchSession.createAsHost(
|
||||||
|
sessionId: sessionId,
|
||||||
|
hostPeerId: _peerService!.myPeerId!,
|
||||||
|
controlMode: controlMode,
|
||||||
|
mediaRatingKey: mediaRatingKey,
|
||||||
|
mediaServerId: mediaServerId,
|
||||||
|
mediaTitle: mediaTitle,
|
||||||
|
).copyWith(state: SessionState.connected);
|
||||||
|
|
||||||
|
// Generate a random display name and add self to participants
|
||||||
|
_displayName = _generateDisplayName();
|
||||||
|
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true));
|
||||||
|
|
||||||
|
_syncManager = WatchTogetherSyncManager(
|
||||||
|
peerService: _peerService!,
|
||||||
|
session: _session!,
|
||||||
|
displayName: _displayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
_syncManager!.onSyncStateChanged = (isSyncing) {
|
||||||
|
_isSyncing = isSyncing;
|
||||||
|
notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
appLogger.d('WatchTogether: Session created: $sessionId');
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('WatchTogether: Failed to create session', error: e);
|
||||||
|
_session = _session?.copyWith(state: SessionState.error, errorMessage: e.toString());
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join an existing session as guest
|
||||||
|
Future<void> joinSession(String sessionId) async {
|
||||||
|
// Clean up any existing session
|
||||||
|
await leaveSession();
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Joining session: $sessionId');
|
||||||
|
|
||||||
|
_peerService = WatchTogetherPeerService();
|
||||||
|
_setupPeerServiceListeners();
|
||||||
|
|
||||||
|
_session = WatchSession.joinAsGuest(sessionId: sessionId);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _peerService!.joinSession(sessionId);
|
||||||
|
|
||||||
|
// Session will be fully configured when we receive sessionConfig from host
|
||||||
|
_session = _session!.copyWith(state: SessionState.connected, hostPeerId: 'wt-${sessionId.toUpperCase()}');
|
||||||
|
|
||||||
|
// Generate a random display name for this session
|
||||||
|
_displayName = _generateDisplayName();
|
||||||
|
|
||||||
|
_syncManager = WatchTogetherSyncManager(
|
||||||
|
peerService: _peerService!,
|
||||||
|
session: _session!,
|
||||||
|
displayName: _displayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
_syncManager!.onSessionConfigReceived = (controlMode) {
|
||||||
|
_session = _session!.copyWith(controlMode: controlMode);
|
||||||
|
_syncManager!.updateSession(_session!);
|
||||||
|
notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
_syncManager!.onSyncStateChanged = (isSyncing) {
|
||||||
|
_isSyncing = isSyncing;
|
||||||
|
notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add self to participants
|
||||||
|
_participants.add(Participant(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: false));
|
||||||
|
|
||||||
|
// Announce join to other participants
|
||||||
|
_syncManager!.announceJoin(_displayName);
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
appLogger.d('WatchTogether: Joined session successfully');
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('WatchTogether: Failed to join session', error: e);
|
||||||
|
_session = _session?.copyWith(state: SessionState.error, errorMessage: e.toString());
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave the current session
|
||||||
|
Future<void> leaveSession() async {
|
||||||
|
if (_session == null) return;
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Leaving session');
|
||||||
|
|
||||||
|
// Announce leave if connected
|
||||||
|
_syncManager?.announceLeave();
|
||||||
|
|
||||||
|
// Clean up subscriptions
|
||||||
|
_peerConnectedSubscription?.cancel();
|
||||||
|
_peerDisconnectedSubscription?.cancel();
|
||||||
|
_messageSubscription?.cancel();
|
||||||
|
_errorSubscription?.cancel();
|
||||||
|
|
||||||
|
_peerConnectedSubscription = null;
|
||||||
|
_peerDisconnectedSubscription = null;
|
||||||
|
_messageSubscription = null;
|
||||||
|
_errorSubscription = null;
|
||||||
|
|
||||||
|
// Clean up services
|
||||||
|
_syncManager?.dispose();
|
||||||
|
_syncManager = null;
|
||||||
|
|
||||||
|
await _peerService?.disconnect();
|
||||||
|
_peerService?.dispose();
|
||||||
|
_peerService = null;
|
||||||
|
|
||||||
|
_session = null;
|
||||||
|
_participants.clear();
|
||||||
|
_isSyncing = false;
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
appLogger.d('WatchTogether: Session left');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a player to the sync manager
|
||||||
|
void attachPlayer(Player player) {
|
||||||
|
if (_syncManager == null) {
|
||||||
|
appLogger.w('WatchTogether: Cannot attach player - no sync manager');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sync manager with existing participants (may have joined before player attached)
|
||||||
|
final peerIds = _participants.map((p) => p.peerId).toList();
|
||||||
|
_syncManager!.initializeParticipants(peerIds);
|
||||||
|
|
||||||
|
_syncManager!.attachPlayer(player);
|
||||||
|
appLogger.d('WatchTogether: Player attached to sync manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach the player from the sync manager
|
||||||
|
void detachPlayer() {
|
||||||
|
_syncManager?.detachPlayer();
|
||||||
|
appLogger.d('WatchTogether: Player detached from sync manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up listeners for peer service events
|
||||||
|
void _setupPeerServiceListeners() {
|
||||||
|
_peerConnectedSubscription = _peerService!.onPeerConnected.listen((peerId) {
|
||||||
|
appLogger.d('WatchTogether: Peer connected: $peerId');
|
||||||
|
// Peer will announce themselves with a join message
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
_peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((peerId) {
|
||||||
|
appLogger.d('WatchTogether: Peer disconnected: $peerId');
|
||||||
|
_participants.removeWhere((p) => p.peerId == peerId);
|
||||||
|
|
||||||
|
// If host disconnected, end session for guests
|
||||||
|
if (!isHost && peerId == _session?.hostPeerId) {
|
||||||
|
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session');
|
||||||
|
// Ensure guests exit the player if host disappears
|
||||||
|
onHostExitedPlayer?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
_messageSubscription = _peerService!.onMessageReceived.listen((message) {
|
||||||
|
_handleSyncMessage(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
_errorSubscription = _peerService!.onError.listen((error) {
|
||||||
|
appLogger.e('WatchTogether: Peer error: ${error.message}');
|
||||||
|
|
||||||
|
// Update session state on error
|
||||||
|
if (_session != null && _session!.state == SessionState.connected) {
|
||||||
|
_session = _session!.copyWith(state: SessionState.error, errorMessage: error.message);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle incoming sync messages for participant management
|
||||||
|
void _handleSyncMessage(SyncMessage message) {
|
||||||
|
switch (message.type) {
|
||||||
|
case SyncMessageType.join:
|
||||||
|
if (message.peerId != null && message.displayName != null) {
|
||||||
|
// Check if participant already exists
|
||||||
|
final existingIndex = _participants.indexWhere((p) => p.peerId == message.peerId);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Update existing participant
|
||||||
|
_participants[existingIndex] = Participant(
|
||||||
|
peerId: message.peerId!,
|
||||||
|
displayName: message.displayName!,
|
||||||
|
isHost: message.isHost ?? false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add new participant
|
||||||
|
_participants.add(
|
||||||
|
Participant(peerId: message.peerId!, displayName: message.displayName!, isHost: message.isHost ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're the host and this is a guest joining, send our join info back
|
||||||
|
// so they add us to their participants list
|
||||||
|
if (isHost && message.peerId != _peerService?.myPeerId && !(message.isHost ?? false)) {
|
||||||
|
_peerService?.sendTo(
|
||||||
|
message.peerId!,
|
||||||
|
SyncMessage.join(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.leave:
|
||||||
|
if (message.peerId != null) {
|
||||||
|
_participants.removeWhere((p) => p.peerId == message.peerId);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.buffering:
|
||||||
|
if (message.peerId != null) {
|
||||||
|
final index = _participants.indexWhere((p) => p.peerId == message.peerId);
|
||||||
|
if (index >= 0) {
|
||||||
|
_participants[index] = _participants[index].copyWith(isBuffering: message.bufferingState ?? false);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.positionSync:
|
||||||
|
if (message.peerId != null && message.position != null) {
|
||||||
|
final index = _participants.indexWhere((p) => p.peerId == message.peerId);
|
||||||
|
if (index >= 0) {
|
||||||
|
_participants[index] = _participants[index].copyWith(lastKnownPosition: message.position);
|
||||||
|
// Don't notify for position updates - too frequent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.mediaSwitch:
|
||||||
|
_handleMediaSwitch(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.hostExitedPlayer:
|
||||||
|
_handleHostExitedPlayer(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.sessionConfig:
|
||||||
|
_handleSessionConfig(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle session config from host (guest only)
|
||||||
|
/// This is handled at provider level so it's processed even before player is attached
|
||||||
|
void _handleSessionConfig(SyncMessage message) {
|
||||||
|
if (isHost) return; // Host doesn't need to process config
|
||||||
|
|
||||||
|
if (message.controlMode != null) {
|
||||||
|
appLogger.d('WatchTogether: Received session config, controlMode: ${message.controlMode}');
|
||||||
|
_session = _session!.copyWith(controlMode: message.controlMode);
|
||||||
|
_syncManager?.updateSession(_session!); // Update sync manager if it exists
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when user seeks locally (to broadcast to peers)
|
||||||
|
void onLocalSeek(Duration position) {
|
||||||
|
_syncManager?.onLocalSeek(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current user can control playback
|
||||||
|
bool canControl() {
|
||||||
|
if (_session == null) return true; // Not in session, can control
|
||||||
|
if (_session!.controlMode == ControlMode.anyone) return true;
|
||||||
|
return isHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current media (host only) and broadcast to guests
|
||||||
|
///
|
||||||
|
/// Call this when the host starts playing new content.
|
||||||
|
/// Guests will receive a media switch notification and should navigate.
|
||||||
|
void setCurrentMedia({required String ratingKey, required String serverId, required String mediaTitle}) {
|
||||||
|
if (!isHost || _session == null || _peerService == null) {
|
||||||
|
appLogger.w('WatchTogether: Cannot set media - not host or not in session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Host setting current media: $mediaTitle (ratingKey: $ratingKey)');
|
||||||
|
|
||||||
|
// Update session with new media info
|
||||||
|
_session = _session!.copyWith(mediaRatingKey: ratingKey, mediaServerId: serverId, mediaTitle: mediaTitle);
|
||||||
|
|
||||||
|
// Broadcast media switch to all guests
|
||||||
|
_peerService!.broadcast(
|
||||||
|
SyncMessage.mediaSwitch(
|
||||||
|
ratingKey: ratingKey,
|
||||||
|
serverId: serverId,
|
||||||
|
mediaTitle: mediaTitle,
|
||||||
|
peerId: _peerService!.myPeerId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle media switch message from host (guest only)
|
||||||
|
void _handleMediaSwitch(SyncMessage message) {
|
||||||
|
if (isHost) return; // Host doesn't need to handle their own switch
|
||||||
|
|
||||||
|
// Skip if already playing this media (prevents duplicate navigation from duplicate messages)
|
||||||
|
if (_session?.mediaRatingKey == message.ratingKey) {
|
||||||
|
appLogger.d('WatchTogether: Ignoring duplicate media switch for ${message.ratingKey}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.ratingKey == null || message.serverId == null || message.mediaTitle == null) {
|
||||||
|
appLogger.w('WatchTogether: Received incomplete media switch message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}');
|
||||||
|
|
||||||
|
// Update local session state
|
||||||
|
_session = _session?.copyWith(
|
||||||
|
mediaRatingKey: message.ratingKey,
|
||||||
|
mediaServerId: message.serverId,
|
||||||
|
mediaTitle: message.mediaTitle,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// If player handler is set (VideoPlayerScreen is active), use that for proper navigation context
|
||||||
|
if (onPlayerMediaSwitched != null) {
|
||||||
|
onPlayerMediaSwitched!(message.ratingKey!, message.serverId!, message.mediaTitle!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, trigger app-level navigation callback (MainScreen handles it)
|
||||||
|
onMediaSwitched?.call(message.ratingKey!, message.serverId!, message.mediaTitle!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notify guests that host is exiting the video player
|
||||||
|
///
|
||||||
|
/// Call this from video player dispose when host exits.
|
||||||
|
void notifyHostExitedPlayer() {
|
||||||
|
if (!isHost || _session == null || _peerService == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Host exiting player, notifying guests');
|
||||||
|
|
||||||
|
_peerService!.broadcast(SyncMessage.hostExitedPlayer(peerId: _peerService!.myPeerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle host exited player message (guest only)
|
||||||
|
void _handleHostExitedPlayer(SyncMessage message) {
|
||||||
|
if (isHost) return; // Host doesn't need to handle their own exit
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Host exited player, callback set: ${onHostExitedPlayer != null}');
|
||||||
|
|
||||||
|
// Trigger callback for the app to navigate guest out of player
|
||||||
|
if (onHostExitedPlayer != null) {
|
||||||
|
onHostExitedPlayer!.call();
|
||||||
|
} else {
|
||||||
|
appLogger.w('WatchTogether: onHostExitedPlayer callback not set!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
leaveSession();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
|
import '../models/watch_session.dart';
|
||||||
|
import '../providers/watch_together_provider.dart';
|
||||||
|
import '../widgets/join_session_dialog.dart';
|
||||||
|
|
||||||
|
/// Main screen for Watch Together functionality
|
||||||
|
///
|
||||||
|
/// Allows users to:
|
||||||
|
/// - Create a new watch session
|
||||||
|
/// - Join an existing session
|
||||||
|
/// - View active session info and participants
|
||||||
|
/// - Leave/end session
|
||||||
|
class WatchTogetherScreen extends StatelessWidget {
|
||||||
|
const WatchTogetherScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<WatchTogetherProvider>(
|
||||||
|
builder: (context, watchTogether, child) {
|
||||||
|
// Non-hosts must use "Leave Session" button - disable back navigation and hide button
|
||||||
|
final canGoBack = watchTogether.isHost || !watchTogether.isInSession;
|
||||||
|
return PopScope(
|
||||||
|
canPop: canGoBack,
|
||||||
|
child: FocusedScrollScaffold(
|
||||||
|
title: const Text('Watch Together'),
|
||||||
|
automaticallyImplyLeading: canGoBack,
|
||||||
|
slivers: watchTogether.isInSession
|
||||||
|
? _buildActiveSessionSlivers(watchTogether)
|
||||||
|
: [SliverFillRemaining(hasScrollBody: false, child: _NotInSessionView(watchTogether: watchTogether))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildActiveSessionSlivers(WatchTogetherProvider watchTogether) {
|
||||||
|
return [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 600),
|
||||||
|
child: _ActiveSessionContent(watchTogether: watchTogether),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown when not in a session
|
||||||
|
class _NotInSessionView extends StatefulWidget {
|
||||||
|
final WatchTogetherProvider watchTogether;
|
||||||
|
|
||||||
|
const _NotInSessionView({required this.watchTogether});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NotInSessionView> createState() => _NotInSessionViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotInSessionViewState extends State<_NotInSessionView> {
|
||||||
|
bool _isCreating = false;
|
||||||
|
bool _isJoining = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.group_rounded, size: 80, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text('Watch Together', style: theme.textTheme.headlineMedium, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Watch content in sync with friends and family',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _isCreating || _isJoining ? null : _createSession,
|
||||||
|
icon: _isCreating
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Symbols.add_rounded),
|
||||||
|
label: Text(_isCreating ? 'Creating...' : 'Create Session'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isCreating || _isJoining ? null : _joinSession,
|
||||||
|
icon: _isJoining
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Symbols.group_add_rounded),
|
||||||
|
label: Text(_isJoining ? 'Joining...' : 'Join Session'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createSession() async {
|
||||||
|
final controlMode = await _showControlModeDialog();
|
||||||
|
if (controlMode == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isCreating = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.watchTogether.createSession(controlMode: controlMode);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('Failed to create session', error: e);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to create session: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isCreating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ControlMode?> _showControlModeDialog() {
|
||||||
|
return showDialog<ControlMode>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Control Mode'),
|
||||||
|
content: const Text('Who can control playback?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, ControlMode.hostOnly), child: const Text('Host Only')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(context, ControlMode.anyone), child: const Text('Anyone')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _joinSession() async {
|
||||||
|
final sessionId = await showJoinSessionDialog(context);
|
||||||
|
if (sessionId == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isJoining = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.watchTogether.joinSession(sessionId);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('Failed to join session', error: e);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to join session: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isJoining = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content shown when in an active session (without scroll wrapper)
|
||||||
|
class _ActiveSessionContent extends StatelessWidget {
|
||||||
|
final WatchTogetherProvider watchTogether;
|
||||||
|
|
||||||
|
const _ActiveSessionContent({required this.watchTogether});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final session = watchTogether.session!;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Session Info Card
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
watchTogether.isHost ? Symbols.star_rounded : Symbols.group_rounded,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
watchTogether.isHost ? 'Hosting Session' : 'In Session',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Code: ${session.sessionId}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
session.controlMode == ControlMode.anyone
|
||||||
|
? Symbols.groups_rounded
|
||||||
|
: Symbols.admin_panel_settings_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
session.controlMode == ControlMode.anyone
|
||||||
|
? 'Anyone can control playback'
|
||||||
|
: 'Host controls playback',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Participants Card
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.people_rounded, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text('Participants (${watchTogether.participantCount})', style: theme.textTheme.titleMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...watchTogether.participants.map(
|
||||||
|
(participant) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
participant.isHost ? Symbols.star_rounded : Symbols.person_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: participant.isHost ? Colors.amber : theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(participant.displayName, style: theme.textTheme.bodyMedium),
|
||||||
|
if (participant.isHost) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Host',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(color: Colors.amber.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (participant.isBuffering) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Leave/End Session Button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _leaveSession(context),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: theme.colorScheme.error,
|
||||||
|
side: BorderSide(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
icon: Icon(watchTogether.isHost ? Symbols.close_rounded : Symbols.logout_rounded),
|
||||||
|
label: Text(watchTogether.isHost ? 'End Session' : 'Leave Session'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _leaveSession(BuildContext context) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(watchTogether.isHost ? 'End Session?' : 'Leave Session?'),
|
||||||
|
content: Text(
|
||||||
|
watchTogether.isHost
|
||||||
|
? 'This will end the session for all participants.'
|
||||||
|
: 'You will be removed from the session.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
|
||||||
|
child: Text(watchTogether.isHost ? 'End' : 'Leave'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await watchTogether.leaveSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:peerdart/peerdart.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../models/sync_message.dart';
|
||||||
|
|
||||||
|
/// Error types that can occur in the peer service
|
||||||
|
enum PeerErrorType { connectionFailed, peerDisconnected, dataChannelError, serverError, timeout, unknown }
|
||||||
|
|
||||||
|
/// Represents an error in the peer service
|
||||||
|
class PeerError {
|
||||||
|
final PeerErrorType type;
|
||||||
|
final String message;
|
||||||
|
final dynamic originalError;
|
||||||
|
|
||||||
|
const PeerError({required this.type, required this.message, this.originalError});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PeerError($type): $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for managing WebRTC peer connections using PeerJS
|
||||||
|
///
|
||||||
|
/// This service handles:
|
||||||
|
/// - Creating sessions (as host)
|
||||||
|
/// - Joining sessions (as guest)
|
||||||
|
/// - Sending/receiving sync messages over data channels
|
||||||
|
/// - Managing multiple peer connections
|
||||||
|
class WatchTogetherPeerService {
|
||||||
|
Peer? _peer;
|
||||||
|
final Map<String, DataConnection> _connections = {};
|
||||||
|
String? _sessionId;
|
||||||
|
String? _myPeerId;
|
||||||
|
bool _isHost = false;
|
||||||
|
|
||||||
|
// Stream controllers for events
|
||||||
|
final _peerConnectedController = StreamController<String>.broadcast();
|
||||||
|
final _peerDisconnectedController = StreamController<String>.broadcast();
|
||||||
|
final _messageReceivedController = StreamController<SyncMessage>.broadcast();
|
||||||
|
final _errorController = StreamController<PeerError>.broadcast();
|
||||||
|
final _connectionStateController = StreamController<bool>.broadcast();
|
||||||
|
|
||||||
|
// Reconnection state
|
||||||
|
int _reconnectAttempts = 0;
|
||||||
|
static const int _maxReconnectAttempts = 3;
|
||||||
|
Timer? _reconnectTimer;
|
||||||
|
|
||||||
|
/// Stream of peer IDs when a new peer connects
|
||||||
|
Stream<String> get onPeerConnected => _peerConnectedController.stream;
|
||||||
|
|
||||||
|
/// Stream of peer IDs when a peer disconnects
|
||||||
|
Stream<String> get onPeerDisconnected => _peerDisconnectedController.stream;
|
||||||
|
|
||||||
|
/// Stream of sync messages received from peers
|
||||||
|
Stream<SyncMessage> get onMessageReceived => _messageReceivedController.stream;
|
||||||
|
|
||||||
|
/// Stream of errors
|
||||||
|
Stream<PeerError> get onError => _errorController.stream;
|
||||||
|
|
||||||
|
/// Stream of connection state changes (true = connected, false = disconnected)
|
||||||
|
Stream<bool> get onConnectionStateChanged => _connectionStateController.stream;
|
||||||
|
|
||||||
|
/// Current session ID (null if not in a session)
|
||||||
|
String? get sessionId => _sessionId;
|
||||||
|
|
||||||
|
/// This peer's ID
|
||||||
|
String? get myPeerId => _myPeerId;
|
||||||
|
|
||||||
|
/// Whether this peer is the host
|
||||||
|
bool get isHost => _isHost;
|
||||||
|
|
||||||
|
/// Whether currently connected to a session
|
||||||
|
bool get isConnected => _peer != null && _connections.isNotEmpty;
|
||||||
|
|
||||||
|
/// List of connected peer IDs
|
||||||
|
List<String> get connectedPeers => _connections.keys.toList();
|
||||||
|
|
||||||
|
/// Generate a short, readable session ID
|
||||||
|
String _generateSessionId() {
|
||||||
|
// Use first 8 characters of UUID for readability
|
||||||
|
return const Uuid().v4().substring(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new session as host
|
||||||
|
///
|
||||||
|
/// Returns the session ID that others can use to join
|
||||||
|
Future<String> createSession() async {
|
||||||
|
if (_peer != null) {
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isHost = true;
|
||||||
|
_sessionId = _generateSessionId();
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// Create peer with session ID as the peer ID so guests can connect directly
|
||||||
|
final completer = Completer<String>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_peer = Peer(id: 'wt-$_sessionId');
|
||||||
|
|
||||||
|
_peer!.on('open').listen((id) {
|
||||||
|
_myPeerId = id as String;
|
||||||
|
appLogger.d('WatchTogether: Host peer opened with ID: $_myPeerId');
|
||||||
|
_connectionStateController.add(true);
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(_sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_peer!.on('connection').listen((conn) {
|
||||||
|
final dataConn = conn as DataConnection;
|
||||||
|
_handleNewConnection(dataConn);
|
||||||
|
});
|
||||||
|
|
||||||
|
_peer!.on('error').listen((error) {
|
||||||
|
appLogger.e('WatchTogether: Peer error', error: error);
|
||||||
|
_errorController.add(
|
||||||
|
PeerError(type: PeerErrorType.serverError, message: error.toString(), originalError: error),
|
||||||
|
);
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_peer!.on('disconnected').listen((_) {
|
||||||
|
appLogger.w('WatchTogether: Peer disconnected from server');
|
||||||
|
_handleDisconnectedFromServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
_peer!.on('close').listen((_) {
|
||||||
|
appLogger.d('WatchTogether: Peer closed');
|
||||||
|
_connectionStateController.add(false);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('WatchTogether: Failed to create peer', error: e);
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout after 10 seconds
|
||||||
|
return completer.future.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join an existing session as guest
|
||||||
|
Future<void> joinSession(String sessionId) async {
|
||||||
|
if (_peer != null) {
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isHost = false;
|
||||||
|
_sessionId = sessionId.toUpperCase();
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
|
final completer = Completer<void>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a random peer ID for guest
|
||||||
|
_peer = Peer();
|
||||||
|
|
||||||
|
_peer!.on('open').listen((id) {
|
||||||
|
_myPeerId = id as String;
|
||||||
|
appLogger.d('WatchTogether: Guest peer opened with ID: $_myPeerId');
|
||||||
|
|
||||||
|
// Connect to the host
|
||||||
|
final hostPeerId = 'wt-$_sessionId';
|
||||||
|
appLogger.d('WatchTogether: Connecting to host: $hostPeerId');
|
||||||
|
|
||||||
|
final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true));
|
||||||
|
_handleNewConnection(conn, isOutgoing: true, completer: completer);
|
||||||
|
});
|
||||||
|
|
||||||
|
_peer!.on('error').listen((error) {
|
||||||
|
appLogger.e('WatchTogether: Peer error', error: error);
|
||||||
|
_errorController.add(
|
||||||
|
PeerError(
|
||||||
|
type: PeerErrorType.connectionFailed,
|
||||||
|
message: 'Failed to connect to session: $error',
|
||||||
|
originalError: error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_peer!.on('disconnected').listen((_) {
|
||||||
|
appLogger.w('WatchTogether: Peer disconnected from server');
|
||||||
|
_handleDisconnectedFromServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
_peer!.on('close').listen((_) {
|
||||||
|
appLogger.d('WatchTogether: Peer closed');
|
||||||
|
_connectionStateController.add(false);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('WatchTogether: Failed to create peer for joining', error: e);
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout after 15 seconds
|
||||||
|
return completer.future.timeout(
|
||||||
|
const Duration(seconds: 15),
|
||||||
|
onTimeout: () {
|
||||||
|
throw PeerError(type: PeerErrorType.timeout, message: 'Timed out joining session');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a new data connection (incoming or outgoing)
|
||||||
|
void _handleNewConnection(DataConnection conn, {bool isOutgoing = false, Completer<void>? completer}) {
|
||||||
|
final peerId = conn.peer;
|
||||||
|
appLogger.d('WatchTogether: New connection ${isOutgoing ? "to" : "from"}: $peerId');
|
||||||
|
|
||||||
|
conn.on('open').listen((_) {
|
||||||
|
appLogger.d('WatchTogether: Data channel opened with: $peerId');
|
||||||
|
_connections[peerId] = conn;
|
||||||
|
_peerConnectedController.add(peerId);
|
||||||
|
_connectionStateController.add(true);
|
||||||
|
|
||||||
|
if (completer != null && !completer.isCompleted) {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('data').listen((data) {
|
||||||
|
try {
|
||||||
|
final message = SyncMessage.fromJson(data as String);
|
||||||
|
appLogger.d('WatchTogether: Received message: ${message.type} from $peerId');
|
||||||
|
_messageReceivedController.add(message);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('WatchTogether: Failed to parse message', error: e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('close').listen((_) {
|
||||||
|
appLogger.d('WatchTogether: Connection closed with: $peerId');
|
||||||
|
_connections.remove(peerId);
|
||||||
|
_peerDisconnectedController.add(peerId);
|
||||||
|
|
||||||
|
if (_connections.isEmpty) {
|
||||||
|
_connectionStateController.add(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error').listen((error) {
|
||||||
|
appLogger.e('WatchTogether: Connection error with $peerId', error: error);
|
||||||
|
_errorController.add(
|
||||||
|
PeerError(
|
||||||
|
type: PeerErrorType.dataChannelError,
|
||||||
|
message: 'Connection error with peer: $error',
|
||||||
|
originalError: error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle disconnection from PeerJS server
|
||||||
|
void _handleDisconnectedFromServer() {
|
||||||
|
if (_reconnectAttempts < _maxReconnectAttempts) {
|
||||||
|
_reconnectAttempts++;
|
||||||
|
final delay = Duration(seconds: _reconnectAttempts * 2); // Exponential backoff
|
||||||
|
|
||||||
|
appLogger.d(
|
||||||
|
'WatchTogether: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s',
|
||||||
|
);
|
||||||
|
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = Timer(delay, () {
|
||||||
|
_peer?.reconnect();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
appLogger.e('WatchTogether: Max reconnect attempts reached');
|
||||||
|
_errorController.add(
|
||||||
|
const PeerError(
|
||||||
|
type: PeerErrorType.connectionFailed,
|
||||||
|
message: 'Lost connection to server after multiple reconnect attempts',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a message to all connected peers
|
||||||
|
void broadcast(SyncMessage message) {
|
||||||
|
final json = message.toJson();
|
||||||
|
appLogger.d('WatchTogether: Broadcasting ${message.type} to ${_connections.length} peers');
|
||||||
|
|
||||||
|
for (final conn in _connections.values) {
|
||||||
|
try {
|
||||||
|
conn.send(json);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('WatchTogether: Failed to send to ${conn.peer}', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to a specific peer
|
||||||
|
void sendTo(String peerId, SyncMessage message) {
|
||||||
|
final conn = _connections[peerId];
|
||||||
|
if (conn != null) {
|
||||||
|
try {
|
||||||
|
conn.send(message.toJson());
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.e('WatchTogether: Failed to send to $peerId', error: e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appLogger.w('WatchTogether: No connection to peer: $peerId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnect from all peers and close the session
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
appLogger.d('WatchTogether: Disconnecting...');
|
||||||
|
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
|
||||||
|
// Close all data connections
|
||||||
|
for (final conn in _connections.values) {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
_connections.clear();
|
||||||
|
|
||||||
|
// Destroy the peer
|
||||||
|
_peer?.dispose();
|
||||||
|
_peer = null;
|
||||||
|
|
||||||
|
_sessionId = null;
|
||||||
|
_myPeerId = null;
|
||||||
|
_isHost = false;
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
|
_connectionStateController.add(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose all resources
|
||||||
|
void dispose() {
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
_peerConnectedController.close();
|
||||||
|
_peerDisconnectedController.close();
|
||||||
|
_messageReceivedController.close();
|
||||||
|
_errorController.close();
|
||||||
|
_connectionStateController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../../mpv/mpv.dart';
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../models/sync_message.dart';
|
||||||
|
import '../models/watch_session.dart';
|
||||||
|
import 'watch_together_peer_service.dart';
|
||||||
|
|
||||||
|
/// Callback type for when session configuration is received
|
||||||
|
typedef SessionConfigCallback = void Function(ControlMode controlMode);
|
||||||
|
|
||||||
|
/// Callback type for when sync state changes
|
||||||
|
typedef SyncStateCallback = void Function(bool isSyncing);
|
||||||
|
|
||||||
|
/// Manages playback synchronization between peers
|
||||||
|
///
|
||||||
|
/// This class:
|
||||||
|
/// - Subscribes to player stream events
|
||||||
|
/// - Broadcasts local playback actions to peers
|
||||||
|
/// - Applies remote playback actions to the local player
|
||||||
|
/// - Handles drift correction
|
||||||
|
class WatchTogetherSyncManager {
|
||||||
|
final WatchTogetherPeerService _peerService;
|
||||||
|
final String displayName;
|
||||||
|
WatchSession _session;
|
||||||
|
|
||||||
|
Player? _player;
|
||||||
|
bool _isRemoteAction = false; // Flag to prevent echo
|
||||||
|
bool _isSyncing = false; // Flag for UI indicator during sync
|
||||||
|
|
||||||
|
// Stream subscriptions
|
||||||
|
StreamSubscription<bool>? _playingSubscription;
|
||||||
|
StreamSubscription<Duration>? _positionSubscription;
|
||||||
|
StreamSubscription<bool>? _bufferingSubscription;
|
||||||
|
StreamSubscription<double>? _rateSubscription;
|
||||||
|
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||||
|
|
||||||
|
// Position sync timer (host broadcasts position periodically)
|
||||||
|
Timer? _positionSyncTimer;
|
||||||
|
|
||||||
|
// Drift correction constants
|
||||||
|
static const Duration maxAllowedDrift = Duration(seconds: 2);
|
||||||
|
static const Duration positionSyncInterval = Duration(seconds: 5);
|
||||||
|
static const Duration excessiveDrift = Duration(seconds: 10);
|
||||||
|
|
||||||
|
// Track last known state to avoid duplicate broadcasts
|
||||||
|
bool _lastKnownPlaying = false;
|
||||||
|
double _lastKnownRate = 1.0;
|
||||||
|
|
||||||
|
// Track if we were playing before a peer started buffering (for auto-resume)
|
||||||
|
bool _wasPlayingBeforeBuffering = false;
|
||||||
|
|
||||||
|
// Position to seek to when auto-resuming deferred playback
|
||||||
|
Duration? _pendingPlayPosition;
|
||||||
|
|
||||||
|
// Whether we've announced our player as ready (first buffering: false)
|
||||||
|
bool _hasAnnouncedReady = false;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
SessionConfigCallback? onSessionConfigReceived;
|
||||||
|
SyncStateCallback? onSyncStateChanged;
|
||||||
|
|
||||||
|
/// Participants' buffering states (peer ID -> isBuffering)
|
||||||
|
final Map<String, bool> _participantBuffering = {};
|
||||||
|
|
||||||
|
/// Participants' ready states (peer ID -> hasPlayerReady)
|
||||||
|
final Map<String, bool> _participantReady = {};
|
||||||
|
|
||||||
|
WatchTogetherSyncManager({
|
||||||
|
required WatchTogetherPeerService peerService,
|
||||||
|
required WatchSession session,
|
||||||
|
required this.displayName,
|
||||||
|
}) : _peerService = peerService,
|
||||||
|
_session = session;
|
||||||
|
|
||||||
|
/// Update the session (e.g., when control mode changes)
|
||||||
|
void updateSession(WatchSession session) {
|
||||||
|
_session = session;
|
||||||
|
appLogger.d('WatchTogether: Sync manager session updated, controlMode: ${session.controlMode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this manager has a player attached
|
||||||
|
bool get hasPlayer => _player != null;
|
||||||
|
|
||||||
|
/// Whether any participant (including local player) is currently buffering
|
||||||
|
bool get isAnyBuffering => _participantBuffering.values.any((b) => b) || (_player?.state.buffering ?? false);
|
||||||
|
|
||||||
|
/// Whether all participants have their player attached and ready
|
||||||
|
/// Returns true if:
|
||||||
|
/// - We're alone (no other peers tracked)
|
||||||
|
/// - All tracked participants have sent playerReady(true)
|
||||||
|
bool get isAllReady {
|
||||||
|
// If no other peers are tracked, we're ready (solo viewing)
|
||||||
|
if (_participantBuffering.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// All peers in _participantBuffering must also be in _participantReady with value true
|
||||||
|
for (final peerId in _participantBuffering.keys) {
|
||||||
|
final ready = _participantReady[peerId];
|
||||||
|
if (ready != true) {
|
||||||
|
return false; // Peer hasn't sent ready yet or sent ready(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether sync is in progress (for UI indicator)
|
||||||
|
bool get isSyncing => _isSyncing;
|
||||||
|
|
||||||
|
/// Attach a player to sync
|
||||||
|
void attachPlayer(Player player) {
|
||||||
|
if (_player != null) {
|
||||||
|
detachPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
_player = player;
|
||||||
|
_lastKnownPlaying = player.state.playing;
|
||||||
|
_lastKnownRate = player.state.rate;
|
||||||
|
|
||||||
|
_setupPlayerSubscriptions();
|
||||||
|
_setupMessageSubscription();
|
||||||
|
|
||||||
|
// If host, start broadcasting position periodically
|
||||||
|
if (_session.isHost) {
|
||||||
|
_startPositionSync();
|
||||||
|
// Note: sessionConfig is sent after video loads (with correct position) in buffering handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: playerReady will be announced when video loads (first buffering: false)
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Player attached, isHost: ${_session.isHost}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize participant tracking from existing session participants
|
||||||
|
/// Call this before attachPlayer() to ensure we know about participants who joined before
|
||||||
|
void initializeParticipants(List<String> peerIds) {
|
||||||
|
for (final peerId in peerIds) {
|
||||||
|
if (peerId != _peerService.myPeerId) {
|
||||||
|
// Assume they're buffering until they tell us otherwise
|
||||||
|
_participantBuffering[peerId] = true;
|
||||||
|
// They're not ready until they send playerReady
|
||||||
|
_participantReady[peerId] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final otherCount = peerIds.where((id) => id != _peerService.myPeerId).length;
|
||||||
|
appLogger.d('WatchTogether: Initialized $otherCount existing participants');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach the player and stop sync
|
||||||
|
void detachPlayer() {
|
||||||
|
// Announce that our player is no longer ready
|
||||||
|
if (_peerService.myPeerId != null) {
|
||||||
|
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: false));
|
||||||
|
_participantReady[_peerService.myPeerId!] = false;
|
||||||
|
}
|
||||||
|
_hasAnnouncedReady = false;
|
||||||
|
|
||||||
|
_playingSubscription?.cancel();
|
||||||
|
_positionSubscription?.cancel();
|
||||||
|
_bufferingSubscription?.cancel();
|
||||||
|
_rateSubscription?.cancel();
|
||||||
|
_messageSubscription?.cancel();
|
||||||
|
_positionSyncTimer?.cancel();
|
||||||
|
|
||||||
|
_playingSubscription = null;
|
||||||
|
_positionSubscription = null;
|
||||||
|
_bufferingSubscription = null;
|
||||||
|
_rateSubscription = null;
|
||||||
|
_messageSubscription = null;
|
||||||
|
_positionSyncTimer = null;
|
||||||
|
|
||||||
|
_player = null;
|
||||||
|
appLogger.d('WatchTogether: Player detached');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up subscriptions to player streams
|
||||||
|
void _setupPlayerSubscriptions() {
|
||||||
|
// Listen to playing state changes
|
||||||
|
_playingSubscription = _player!.streams.playing.listen((isPlaying) async {
|
||||||
|
if (_isRemoteAction) return; // Skip if this change was caused by a remote action
|
||||||
|
|
||||||
|
if (isPlaying != _lastKnownPlaying) {
|
||||||
|
_lastKnownPlaying = isPlaying;
|
||||||
|
|
||||||
|
// If trying to play, check if all peers are ready first
|
||||||
|
if (isPlaying && (!isAllReady || isAnyBuffering)) {
|
||||||
|
appLogger.d('WatchTogether: Deferring local play - waiting for all peers to be ready');
|
||||||
|
_wasPlayingBeforeBuffering = true;
|
||||||
|
_pendingPlayPosition = _player?.state.position;
|
||||||
|
_isRemoteAction = true;
|
||||||
|
try {
|
||||||
|
await _player!.pause();
|
||||||
|
_lastKnownPlaying = false;
|
||||||
|
} finally {
|
||||||
|
_isRemoteAction = false;
|
||||||
|
}
|
||||||
|
// Still broadcast so peers know we want to play
|
||||||
|
_broadcastPlayPause(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_broadcastPlayPause(isPlaying);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to buffering state changes
|
||||||
|
_bufferingSubscription = _player!.streams.buffering.listen((isBuffering) {
|
||||||
|
if (_isRemoteAction) return;
|
||||||
|
|
||||||
|
// Announce ready when we stop buffering for the first time (video loaded)
|
||||||
|
if (!isBuffering && !_hasAnnouncedReady) {
|
||||||
|
_hasAnnouncedReady = true;
|
||||||
|
_participantReady[_peerService.myPeerId!] = true;
|
||||||
|
_peerService.broadcast(SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true));
|
||||||
|
appLogger.d('WatchTogether: Video loaded, announcing player ready');
|
||||||
|
|
||||||
|
// If host, send session config now that video is loaded with correct position
|
||||||
|
if (_session.isHost) {
|
||||||
|
_sendSessionConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_peerService.broadcast(SyncMessage.buffering(isBuffering, peerId: _peerService.myPeerId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to rate changes
|
||||||
|
_rateSubscription = _player!.streams.rate.listen((rate) {
|
||||||
|
if (_isRemoteAction) return;
|
||||||
|
|
||||||
|
if (rate != _lastKnownRate) {
|
||||||
|
_lastKnownRate = rate;
|
||||||
|
if (_canControl()) {
|
||||||
|
_peerService.broadcast(SyncMessage.rate(rate, peerId: _peerService.myPeerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up subscription to incoming sync messages
|
||||||
|
void _setupMessageSubscription() {
|
||||||
|
_messageSubscription = _peerService.onMessageReceived.listen(_handleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start periodic position sync (host only)
|
||||||
|
void _startPositionSync() {
|
||||||
|
_positionSyncTimer?.cancel();
|
||||||
|
_positionSyncTimer = Timer.periodic(positionSyncInterval, (_) {
|
||||||
|
if (_player != null && _session.isHost) {
|
||||||
|
_peerService.broadcast(SyncMessage.positionSync(_player!.state.position, peerId: _peerService.myPeerId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this peer can control playback
|
||||||
|
bool _canControl() {
|
||||||
|
if (_session.controlMode == ControlMode.anyone) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _session.isHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast play/pause state
|
||||||
|
void _broadcastPlayPause(bool isPlaying) {
|
||||||
|
if (!_canControl()) {
|
||||||
|
appLogger.d('WatchTogether: Cannot control playback in hostOnly mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
final position = _player?.state.position ?? Duration.zero;
|
||||||
|
_peerService.broadcast(SyncMessage.play(peerId: _peerService.myPeerId, position: position));
|
||||||
|
} else {
|
||||||
|
_peerService.broadcast(SyncMessage.pause(peerId: _peerService.myPeerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when user seeks locally
|
||||||
|
void onLocalSeek(Duration position) {
|
||||||
|
if (!_canControl()) {
|
||||||
|
appLogger.d('WatchTogether: Cannot control playback in hostOnly mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_peerService.broadcast(SyncMessage.seek(position, peerId: _peerService.myPeerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle incoming sync messages
|
||||||
|
void _handleMessage(SyncMessage message) async {
|
||||||
|
// Ignore our own messages
|
||||||
|
if (message.peerId == _peerService.myPeerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HOST RELAY: In "anyone" mode, host rebroadcasts control commands from guests
|
||||||
|
// This is needed because guests only connect to host (star topology), not to each other
|
||||||
|
if (_session.isHost && _session.controlMode == ControlMode.anyone) {
|
||||||
|
final isControlMessage =
|
||||||
|
message.type == SyncMessageType.play ||
|
||||||
|
message.type == SyncMessageType.pause ||
|
||||||
|
message.type == SyncMessageType.seek ||
|
||||||
|
message.type == SyncMessageType.rate;
|
||||||
|
|
||||||
|
if (isControlMessage) {
|
||||||
|
appLogger.d('WatchTogether: Host relaying ${message.type} from ${message.peerId}');
|
||||||
|
_peerService.broadcast(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In hostOnly mode, only process messages from host (unless it's join/leave/sessionConfig)
|
||||||
|
if (_session.controlMode == ControlMode.hostOnly && !_session.isHost) {
|
||||||
|
final isHostMessage = message.peerId == _session.hostPeerId;
|
||||||
|
final isMetaMessage =
|
||||||
|
message.type == SyncMessageType.join ||
|
||||||
|
message.type == SyncMessageType.leave ||
|
||||||
|
message.type == SyncMessageType.sessionConfig ||
|
||||||
|
message.type == SyncMessageType.buffering ||
|
||||||
|
message.type == SyncMessageType.ping ||
|
||||||
|
message.type == SyncMessageType.pong ||
|
||||||
|
message.type == SyncMessageType.mediaSwitch;
|
||||||
|
|
||||||
|
if (!isHostMessage && !isMetaMessage) {
|
||||||
|
appLogger.d('WatchTogether: Ignoring non-host message in hostOnly mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case SyncMessageType.play:
|
||||||
|
await _applyRemotePlay(position: message.position);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.pause:
|
||||||
|
_wasPlayingBeforeBuffering = false; // User intentionally paused, don't auto-resume
|
||||||
|
await _applyRemotePause();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.seek:
|
||||||
|
if (message.position != null) {
|
||||||
|
await _applyRemoteSeek(message.position!);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.buffering:
|
||||||
|
if (message.peerId != null && message.bufferingState != null) {
|
||||||
|
_participantBuffering[message.peerId!] = message.bufferingState!;
|
||||||
|
|
||||||
|
// Auto-pause when any peer starts buffering
|
||||||
|
if (isAnyBuffering && _player!.state.playing) {
|
||||||
|
_wasPlayingBeforeBuffering = true;
|
||||||
|
appLogger.d('WatchTogether: Peer buffering, pausing playback');
|
||||||
|
await _applyRemotePause();
|
||||||
|
}
|
||||||
|
// Auto-resume when all peers stop buffering AND all ready (if we were playing before)
|
||||||
|
else if (isAllReady && !isAnyBuffering && !_player!.state.playing && _wasPlayingBeforeBuffering) {
|
||||||
|
_wasPlayingBeforeBuffering = false;
|
||||||
|
appLogger.d('WatchTogether: All peers done buffering, resuming playback');
|
||||||
|
await _applyRemotePlay(position: _pendingPlayPosition);
|
||||||
|
_pendingPlayPosition = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.positionSync:
|
||||||
|
if (message.position != null) {
|
||||||
|
_checkAndCorrectDrift(message.position!, message.timestamp);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.rate:
|
||||||
|
if (message.rate != null) {
|
||||||
|
await _applyRemoteRate(message.rate!);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.join:
|
||||||
|
_handlePeerJoin(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.leave:
|
||||||
|
if (message.peerId != null) {
|
||||||
|
_participantBuffering.remove(message.peerId);
|
||||||
|
_participantReady.remove(message.peerId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.sessionConfig:
|
||||||
|
await _handleSessionConfig(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.ping:
|
||||||
|
if (message.pingId != null) {
|
||||||
|
_peerService.broadcast(SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.pong:
|
||||||
|
// Could be used for latency measurement
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.mediaSwitch:
|
||||||
|
// Handled at the provider level, not in sync manager
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.hostExitedPlayer:
|
||||||
|
// Handled at the provider level, not in sync manager
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncMessageType.playerReady:
|
||||||
|
if (message.peerId != null) {
|
||||||
|
_participantReady[message.peerId!] = message.bufferingState ?? false;
|
||||||
|
appLogger.d('WatchTogether: Peer ${message.peerId} player ready: ${message.bufferingState}');
|
||||||
|
|
||||||
|
// If we were waiting to play and all are now ready, start playback
|
||||||
|
if (isAllReady && !isAnyBuffering && _wasPlayingBeforeBuffering) {
|
||||||
|
_wasPlayingBeforeBuffering = false;
|
||||||
|
appLogger.d('WatchTogether: All players ready, starting playback');
|
||||||
|
await _applyRemotePlay(position: _pendingPlayPosition);
|
||||||
|
_pendingPlayPosition = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply remote play command
|
||||||
|
Future<void> _applyRemotePlay({Duration? position}) async {
|
||||||
|
if (_player == null) return;
|
||||||
|
|
||||||
|
// If not all participants have their player ready, defer play
|
||||||
|
if (!isAllReady) {
|
||||||
|
appLogger.d('WatchTogether: Deferring play - waiting for all players to be ready');
|
||||||
|
_wasPlayingBeforeBuffering = true;
|
||||||
|
if (position != null) _pendingPlayPosition = position;
|
||||||
|
return; // Will trigger when all players send playerReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// If anyone is buffering, defer play until all ready
|
||||||
|
if (isAnyBuffering) {
|
||||||
|
appLogger.d('WatchTogether: Deferring play - waiting for all peers to stop buffering');
|
||||||
|
_wasPlayingBeforeBuffering = true;
|
||||||
|
if (position != null) _pendingPlayPosition = position;
|
||||||
|
return; // Auto-resume will trigger when buffering clears
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Applying remote PLAY${position != null ? ' at ${position.inSeconds}s' : ''}');
|
||||||
|
_isRemoteAction = true;
|
||||||
|
try {
|
||||||
|
// Seek to position first if provided
|
||||||
|
if (position != null) {
|
||||||
|
await _player!.seek(position);
|
||||||
|
}
|
||||||
|
await _player!.play();
|
||||||
|
_lastKnownPlaying = true;
|
||||||
|
} on StateError catch (e) {
|
||||||
|
appLogger.w('WatchTogether: Player disposed during remote PLAY', error: e);
|
||||||
|
detachPlayer();
|
||||||
|
} finally {
|
||||||
|
_isRemoteAction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply remote pause command
|
||||||
|
Future<void> _applyRemotePause() async {
|
||||||
|
if (_player == null) return;
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Applying remote PAUSE');
|
||||||
|
_isRemoteAction = true;
|
||||||
|
try {
|
||||||
|
await _player!.pause();
|
||||||
|
_lastKnownPlaying = false;
|
||||||
|
} on StateError catch (e) {
|
||||||
|
appLogger.w('WatchTogether: Player disposed during remote PAUSE', error: e);
|
||||||
|
detachPlayer();
|
||||||
|
} finally {
|
||||||
|
_isRemoteAction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply remote seek command
|
||||||
|
Future<void> _applyRemoteSeek(Duration position) async {
|
||||||
|
if (_player == null) return;
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Applying remote SEEK to ${position.inSeconds}s');
|
||||||
|
_isRemoteAction = true;
|
||||||
|
try {
|
||||||
|
await _player!.seek(position);
|
||||||
|
} on StateError catch (e) {
|
||||||
|
appLogger.w('WatchTogether: Player disposed during remote SEEK', error: e);
|
||||||
|
detachPlayer();
|
||||||
|
} finally {
|
||||||
|
_isRemoteAction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply remote rate change
|
||||||
|
Future<void> _applyRemoteRate(double rate) async {
|
||||||
|
if (_player == null) return;
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Applying remote RATE: $rate');
|
||||||
|
_isRemoteAction = true;
|
||||||
|
try {
|
||||||
|
await _player!.setRate(rate);
|
||||||
|
_lastKnownRate = rate;
|
||||||
|
} on StateError catch (e) {
|
||||||
|
appLogger.w('WatchTogether: Player disposed during remote RATE', error: e);
|
||||||
|
detachPlayer();
|
||||||
|
} finally {
|
||||||
|
_isRemoteAction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check and correct position drift
|
||||||
|
void _checkAndCorrectDrift(Duration remotePosition, int remoteTimestamp) {
|
||||||
|
if (_player == null || _session.isHost) return;
|
||||||
|
|
||||||
|
final localPosition = _player!.state.position;
|
||||||
|
final networkDelay = DateTime.now().millisecondsSinceEpoch - remoteTimestamp;
|
||||||
|
|
||||||
|
// Estimate where remote should be now, accounting for playback time elapsed
|
||||||
|
Duration estimatedRemoteNow = remotePosition;
|
||||||
|
if (_player!.state.playing && networkDelay > 0) {
|
||||||
|
// If playing, account for time elapsed during network transit
|
||||||
|
// Multiply by rate in case playback speed is different
|
||||||
|
estimatedRemoteNow = remotePosition + Duration(milliseconds: (networkDelay * _player!.state.rate).round());
|
||||||
|
}
|
||||||
|
|
||||||
|
final drift = (localPosition - estimatedRemoteNow).abs();
|
||||||
|
|
||||||
|
if (drift > excessiveDrift) {
|
||||||
|
// Excessive drift - force sync with indicator
|
||||||
|
appLogger.w('WatchTogether: Excessive drift (${drift.inSeconds}s), force syncing');
|
||||||
|
_setSyncing(true);
|
||||||
|
_applyRemoteSeek(estimatedRemoteNow);
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () => _setSyncing(false));
|
||||||
|
} else if (drift > maxAllowedDrift) {
|
||||||
|
// Normal drift correction
|
||||||
|
appLogger.d('WatchTogether: Drift correction (${drift.inMilliseconds}ms)');
|
||||||
|
_setSyncing(true);
|
||||||
|
_applyRemoteSeek(estimatedRemoteNow);
|
||||||
|
Future.delayed(const Duration(milliseconds: 300), () => _setSyncing(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle peer join message
|
||||||
|
void _handlePeerJoin(SyncMessage message) {
|
||||||
|
appLogger.d('WatchTogether: Peer joined: ${message.displayName}');
|
||||||
|
|
||||||
|
// Assume new peer is buffering and not ready until they explicitly signal
|
||||||
|
if (message.peerId != null) {
|
||||||
|
_participantBuffering[message.peerId!] = true;
|
||||||
|
_participantReady[message.peerId!] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're the host, send session config AND our own join info to the new peer
|
||||||
|
if (_session.isHost && message.peerId != null) {
|
||||||
|
// Only send config if our video is loaded (we know the correct position)
|
||||||
|
if (_hasAnnouncedReady) {
|
||||||
|
_sendSessionConfig(toPeerId: message.peerId);
|
||||||
|
}
|
||||||
|
// Send host's join info so guest adds host to their participants list
|
||||||
|
_peerService.sendTo(
|
||||||
|
message.peerId!,
|
||||||
|
SyncMessage.join(peerId: _peerService.myPeerId!, displayName: displayName, isHost: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle session config from host
|
||||||
|
Future<void> _handleSessionConfig(SyncMessage message) async {
|
||||||
|
if (_session.isHost) return; // Host doesn't need to process config
|
||||||
|
|
||||||
|
appLogger.d('WatchTogether: Received session config');
|
||||||
|
|
||||||
|
// Update control mode
|
||||||
|
if (message.controlMode != null) {
|
||||||
|
onSessionConfigReceived?.call(message.controlMode!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync to host's current state
|
||||||
|
if (_player == null) return;
|
||||||
|
|
||||||
|
_isRemoteAction = true;
|
||||||
|
try {
|
||||||
|
// Always seek to host's position first
|
||||||
|
if (message.position != null) {
|
||||||
|
await _player!.seek(message.position!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match playback rate
|
||||||
|
if (message.rate != null) {
|
||||||
|
await _player!.setRate(message.rate!);
|
||||||
|
_lastKnownRate = message.rate!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match play/pause state (bufferingState is reused: false = playing)
|
||||||
|
if (message.bufferingState == false) {
|
||||||
|
// Host was playing - defer play until all ready
|
||||||
|
_wasPlayingBeforeBuffering = true;
|
||||||
|
_pendingPlayPosition = message.position;
|
||||||
|
// Check if we can play now
|
||||||
|
if (isAllReady && !isAnyBuffering) {
|
||||||
|
await _applyRemotePlay(position: message.position);
|
||||||
|
} else {
|
||||||
|
appLogger.d('WatchTogether: Host was playing but deferring until all ready');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await _player!.pause();
|
||||||
|
_lastKnownPlaying = false;
|
||||||
|
}
|
||||||
|
} on StateError catch (e) {
|
||||||
|
appLogger.w('WatchTogether: Player disposed during session config apply', error: e);
|
||||||
|
detachPlayer();
|
||||||
|
} finally {
|
||||||
|
_isRemoteAction = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set syncing state and notify listeners
|
||||||
|
void _setSyncing(bool isSyncing) {
|
||||||
|
if (_isSyncing != isSyncing) {
|
||||||
|
_isSyncing = isSyncing;
|
||||||
|
onSyncStateChanged?.call(isSyncing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send join announcement to all peers
|
||||||
|
void announceJoin(String displayName) {
|
||||||
|
_peerService.broadcast(
|
||||||
|
SyncMessage.join(peerId: _peerService.myPeerId!, displayName: displayName, isHost: _session.isHost),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send leave announcement to all peers
|
||||||
|
void announceLeave() {
|
||||||
|
if (_peerService.myPeerId != null) {
|
||||||
|
_peerService.broadcast(SyncMessage.leave(peerId: _peerService.myPeerId!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send current session configuration to peers
|
||||||
|
void _sendSessionConfig({String? toPeerId}) {
|
||||||
|
if (!_session.isHost || _peerService.myPeerId == null) return;
|
||||||
|
|
||||||
|
final position = _player?.state.position ?? Duration.zero;
|
||||||
|
final isPlaying = _player?.state.playing ?? false;
|
||||||
|
final rate = _player?.state.rate ?? 1.0;
|
||||||
|
|
||||||
|
final configMessage = SyncMessage.sessionConfig(
|
||||||
|
controlMode: _session.controlMode,
|
||||||
|
currentPosition: position,
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
playbackRate: rate,
|
||||||
|
peerId: _peerService.myPeerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toPeerId != null) {
|
||||||
|
_peerService.sendTo(toPeerId, configMessage);
|
||||||
|
} else {
|
||||||
|
_peerService.broadcast(configMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose resources
|
||||||
|
void dispose() {
|
||||||
|
detachPlayer();
|
||||||
|
_participantBuffering.clear();
|
||||||
|
_participantReady.clear();
|
||||||
|
_hasAnnouncedReady = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Models
|
||||||
|
export 'models/watch_session.dart';
|
||||||
|
export 'models/sync_message.dart';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export 'services/watch_together_peer_service.dart';
|
||||||
|
export 'services/watch_together_sync_manager.dart';
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
export 'providers/watch_together_provider.dart';
|
||||||
|
|
||||||
|
// Screens
|
||||||
|
export 'screens/watch_together_screen.dart';
|
||||||
|
|
||||||
|
// Widgets
|
||||||
|
export 'widgets/session_invite_dialog.dart';
|
||||||
|
export 'widgets/join_session_dialog.dart';
|
||||||
|
export 'widgets/watch_together_overlay.dart';
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
/// Dialog for joining a watch together session
|
||||||
|
class JoinSessionDialog extends StatefulWidget {
|
||||||
|
const JoinSessionDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<JoinSessionDialog> createState() => _JoinSessionDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _JoinSessionDialogState extends State<JoinSessionDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _sessionIdController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sessionIdController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.group_add, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text('Join Watch Session', style: theme.textTheme.titleLarge)),
|
||||||
|
IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Session ID input
|
||||||
|
TextFormField(
|
||||||
|
controller: _sessionIdController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Session Code',
|
||||||
|
hintText: 'Enter 8-character code',
|
||||||
|
prefixIcon: const Icon(Symbols.tag),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: _pasteFromClipboard,
|
||||||
|
icon: const Icon(Symbols.content_paste),
|
||||||
|
tooltip: 'Paste from clipboard',
|
||||||
|
),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
maxLength: 8,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
|
||||||
|
UpperCaseTextFormatter(),
|
||||||
|
],
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter a session code';
|
||||||
|
}
|
||||||
|
if (value.length != 8) {
|
||||||
|
return 'Session code must be 8 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (_) => _join(),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
Text(
|
||||||
|
'Enter the session code shared by the host to join their watch session.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Join button
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _join,
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Symbols.group_add),
|
||||||
|
label: Text(_isLoading ? 'Joining...' : 'Join Session'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pasteFromClipboard() async {
|
||||||
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
if (data?.text != null) {
|
||||||
|
// Clean the pasted text - extract alphanumeric characters and take first 8
|
||||||
|
final cleaned = data!.text!.replaceAll(RegExp(r'[^A-Za-z0-9]'), '').toUpperCase();
|
||||||
|
if (cleaned.isNotEmpty) {
|
||||||
|
_sessionIdController.text = cleaned.substring(0, cleaned.length.clamp(0, 8));
|
||||||
|
_sessionIdController.selection = TextSelection.collapsed(offset: _sessionIdController.text.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _join() {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
final sessionId = _sessionIdController.text.toUpperCase();
|
||||||
|
Navigator.of(context).pop(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text formatter to convert input to uppercase
|
||||||
|
class UpperCaseTextFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||||
|
return newValue.copyWith(text: newValue.text.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the join session dialog
|
||||||
|
///
|
||||||
|
/// Returns the session ID if user confirms, null if cancelled
|
||||||
|
Future<String?> showJoinSessionDialog(BuildContext context) {
|
||||||
|
return showDialog<String>(context: context, builder: (context) => const JoinSessionDialog());
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
/// Dialog for sharing a watch together session with others
|
||||||
|
class SessionInviteDialog extends StatelessWidget {
|
||||||
|
final String sessionId;
|
||||||
|
final int participantCount;
|
||||||
|
|
||||||
|
const SessionInviteDialog({super.key, required this.sessionId, required this.participantCount});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.group, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Watch Together', style: theme.textTheme.titleLarge),
|
||||||
|
Text(
|
||||||
|
'$participantCount ${participantCount == 1 ? 'participant' : 'participants'}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// QR Code
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: QrImageView(
|
||||||
|
data: sessionId,
|
||||||
|
version: QrVersions.auto,
|
||||||
|
size: 180,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black),
|
||||||
|
dataModuleStyle: const QrDataModuleStyle(
|
||||||
|
dataModuleShape: QrDataModuleShape.square,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Session ID with copy button
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Session Code',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
sessionId,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _copyToClipboard(context),
|
||||||
|
icon: const Icon(Symbols.content_copy),
|
||||||
|
tooltip: 'Copy code',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
Text(
|
||||||
|
'Share this code with others to let them join your watch session.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Share button
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _share(context),
|
||||||
|
icon: const Icon(Symbols.share),
|
||||||
|
label: const Text('Share'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyToClipboard(BuildContext context) {
|
||||||
|
Clipboard.setData(ClipboardData(text: sessionId));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Session code copied to clipboard')));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _share(BuildContext context) {
|
||||||
|
final text = 'Join my Watch Together session!\n\nSession Code: $sessionId';
|
||||||
|
Share.share(text, subject: 'Watch Together Invite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the session invite dialog
|
||||||
|
Future<void> showSessionInviteDialog(BuildContext context, {required String sessionId, required int participantCount}) {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SessionInviteDialog(sessionId: sessionId, participantCount: participantCount),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/watch_session.dart';
|
||||||
|
import '../providers/watch_together_provider.dart';
|
||||||
|
import 'session_invite_dialog.dart';
|
||||||
|
|
||||||
|
/// Overlay shown on the video player when in a watch together session
|
||||||
|
class WatchTogetherOverlay extends StatelessWidget {
|
||||||
|
/// Callback when the user wants to leave the session
|
||||||
|
final VoidCallback? onLeaveSession;
|
||||||
|
|
||||||
|
const WatchTogetherOverlay({super.key, this.onLeaveSession});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<WatchTogetherProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
if (!provider.isInSession) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
child: _SessionIndicator(
|
||||||
|
participantCount: provider.participantCount,
|
||||||
|
isHost: provider.isHost,
|
||||||
|
isSyncing: provider.isSyncing,
|
||||||
|
controlMode: provider.controlMode,
|
||||||
|
sessionId: provider.sessionId,
|
||||||
|
onTap: () => _showSessionMenu(context, provider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small indicator showing session status
|
||||||
|
class _SessionIndicator extends StatelessWidget {
|
||||||
|
final int participantCount;
|
||||||
|
final bool isHost;
|
||||||
|
final bool isSyncing;
|
||||||
|
final ControlMode controlMode;
|
||||||
|
final String? sessionId;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _SessionIndicator({
|
||||||
|
required this.participantCount,
|
||||||
|
required this.isHost,
|
||||||
|
required this.isSyncing,
|
||||||
|
required this.controlMode,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Sync indicator or group icon
|
||||||
|
if (isSyncing)
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(Symbols.group, size: 18, color: isHost ? theme.colorScheme.primary : Colors.white),
|
||||||
|
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
|
||||||
|
// Participant count
|
||||||
|
Text(
|
||||||
|
'$participantCount',
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Host badge
|
||||||
|
if (isHost) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(4)),
|
||||||
|
child: const Text(
|
||||||
|
'HOST',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom sheet showing session details and actions
|
||||||
|
class _SessionMenuSheet extends StatelessWidget {
|
||||||
|
final WatchTogetherProvider provider;
|
||||||
|
final VoidCallback? onLeaveSession;
|
||||||
|
|
||||||
|
const _SessionMenuSheet({required this.provider, this.onLeaveSession});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.group, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Watch Together', style: theme.textTheme.titleMedium),
|
||||||
|
Text(
|
||||||
|
provider.isHost ? 'You are the host' : 'Watching with others',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Control mode badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
provider.controlMode == ControlMode.hostOnly ? 'Host controls' : 'Anyone controls',
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Participants list
|
||||||
|
Text('Participants', style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...provider.participants.map(
|
||||||
|
(p) => ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: p.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
p.isHost ? Symbols.star : Symbols.person,
|
||||||
|
color: p.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(p.displayName),
|
||||||
|
subtitle: p.isHost ? const Text('Host') : null,
|
||||||
|
trailing: p.isBuffering
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: null,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
if (provider.isHost && provider.sessionId != null)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Symbols.share),
|
||||||
|
title: const Text('Invite others'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
showSessionInviteDialog(
|
||||||
|
context,
|
||||||
|
sessionId: provider.sessionId!,
|
||||||
|
participantCount: provider.participantCount,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Symbols.logout, color: theme.colorScheme.error),
|
||||||
|
title: Text(
|
||||||
|
provider.isHost ? 'End session' : 'Leave session',
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_confirmLeave(context);
|
||||||
|
},
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmLeave(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(provider.isHost ? 'End Session?' : 'Leave Session?'),
|
||||||
|
content: Text(
|
||||||
|
provider.isHost
|
||||||
|
? 'This will end the watch session for all participants.'
|
||||||
|
: 'You will be disconnected from the watch session.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
provider.leaveSession();
|
||||||
|
onLeaveSession?.call();
|
||||||
|
},
|
||||||
|
child: Text(provider.isHost ? 'End Session' : 'Leave'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact sync indicator for showing during drift correction
|
||||||
|
class SyncingIndicator extends StatelessWidget {
|
||||||
|
const SyncingIndicator({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<WatchTogetherProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
if (!provider.isSyncing) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
bottom: 80,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Syncing...', style: TextStyle(color: Colors.white, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,7 @@ class CustomAppBar extends StatelessWidget {
|
|||||||
final double? expandedHeight;
|
final double? expandedHeight;
|
||||||
final Widget? flexibleSpace;
|
final Widget? flexibleSpace;
|
||||||
final PreferredSizeWidget? bottom;
|
final PreferredSizeWidget? bottom;
|
||||||
|
final bool automaticallyImplyLeading;
|
||||||
|
|
||||||
const CustomAppBar({
|
const CustomAppBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -242,6 +243,7 @@ class CustomAppBar extends StatelessWidget {
|
|||||||
this.expandedHeight,
|
this.expandedHeight,
|
||||||
this.flexibleSpace,
|
this.flexibleSpace,
|
||||||
this.bottom,
|
this.bottom,
|
||||||
|
this.automaticallyImplyLeading = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -262,7 +264,7 @@ class CustomAppBar extends StatelessWidget {
|
|||||||
bottom: bottom,
|
bottom: bottom,
|
||||||
),
|
),
|
||||||
onBackPressed: onBackPressed,
|
onBackPressed: onBackPressed,
|
||||||
automaticallyImplyLeading: true,
|
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,17 @@ class FocusedScrollScaffold extends StatelessWidget {
|
|||||||
/// Defaults to true.
|
/// Defaults to true.
|
||||||
final bool pinned;
|
final bool pinned;
|
||||||
|
|
||||||
|
/// Whether to automatically add a back button.
|
||||||
|
/// Defaults to true.
|
||||||
|
final bool automaticallyImplyLeading;
|
||||||
|
|
||||||
const FocusedScrollScaffold({
|
const FocusedScrollScaffold({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.slivers,
|
required this.slivers,
|
||||||
this.actions,
|
this.actions,
|
||||||
this.pinned = true,
|
this.pinned = true,
|
||||||
|
this.automaticallyImplyLeading = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,7 +45,12 @@ class FocusedScrollScaffold extends StatelessWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
CustomAppBar(title: title, pinned: pinned, actions: actions),
|
CustomAppBar(
|
||||||
|
title: title,
|
||||||
|
pinned: pinned,
|
||||||
|
actions: actions,
|
||||||
|
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||||
|
),
|
||||||
...slivers,
|
...slivers,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ class DesktopVideoControls extends StatefulWidget {
|
|||||||
final VoidCallback? onCancelAutoHide;
|
final VoidCallback? onCancelAutoHide;
|
||||||
final VoidCallback? onStartAutoHide;
|
final VoidCallback? onStartAutoHide;
|
||||||
final String serverId;
|
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({
|
const DesktopVideoControls({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -94,6 +98,8 @@ class DesktopVideoControls extends StatefulWidget {
|
|||||||
this.onCancelAutoHide,
|
this.onCancelAutoHide,
|
||||||
this.onStartAutoHide,
|
this.onStartAutoHide,
|
||||||
this.serverId = '',
|
this.serverId = '',
|
||||||
|
this.onBack,
|
||||||
|
this.canControl = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -252,8 +258,11 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
return KeyEventResult.handled;
|
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) {
|
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;
|
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
|
||||||
|
|
||||||
// Base step: 0.5% of duration, minimum 500ms
|
// Base step: 0.5% of duration, minimum 500ms
|
||||||
@@ -314,6 +323,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
child: VideoControlsHeader(
|
child: VideoControlsHeader(
|
||||||
metadata: widget.metadata,
|
metadata: widget.metadata,
|
||||||
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
||||||
|
onBack: widget.onBack,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -336,74 +346,92 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
focusNode: _timelineFocusNode,
|
focusNode: _timelineFocusNode,
|
||||||
onKeyEvent: _handleTimelineKeyEvent,
|
onKeyEvent: _handleTimelineKeyEvent,
|
||||||
onFocusChange: _onFocusChange,
|
onFocusChange: _onFocusChange,
|
||||||
|
enabled: widget.canControl,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Row 2: Playback controls and options
|
// Row 2: Playback controls and options
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Previous item
|
// Previous item
|
||||||
_buildFocusableButton(
|
Opacity(
|
||||||
focusNode: _prevItemFocusNode,
|
opacity: widget.canControl ? 1.0 : 0.5,
|
||||||
index: 0,
|
child: _buildFocusableButton(
|
||||||
icon: Symbols.skip_previous_rounded,
|
focusNode: _prevItemFocusNode,
|
||||||
color: widget.onPrevious != null ? Colors.white : Colors.white54,
|
index: 0,
|
||||||
onPressed: widget.onPrevious,
|
icon: Symbols.skip_previous_rounded,
|
||||||
semanticLabel: t.videoControls.previousButton,
|
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)
|
// Previous chapter (or skip backward if no chapters)
|
||||||
_buildFocusableButton(
|
Opacity(
|
||||||
focusNode: _prevChapterFocusNode,
|
opacity: widget.canControl ? 1.0 : 0.5,
|
||||||
index: 1,
|
child: _buildFocusableButton(
|
||||||
icon: widget.chapters.isEmpty
|
focusNode: _prevChapterFocusNode,
|
||||||
? widget.getReplayIcon(widget.seekTimeSmall)
|
index: 1,
|
||||||
: Symbols.fast_rewind_rounded,
|
icon: widget.chapters.isEmpty
|
||||||
onPressed: widget.onSeekToPreviousChapter,
|
? widget.getReplayIcon(widget.seekTimeSmall)
|
||||||
semanticLabel: widget.chapters.isEmpty
|
: Symbols.fast_rewind_rounded,
|
||||||
? t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall)
|
onPressed: widget.canControl ? widget.onSeekToPreviousChapter : null,
|
||||||
: t.videoControls.previousChapterButton,
|
semanticLabel: widget.chapters.isEmpty
|
||||||
|
? t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall)
|
||||||
|
: t.videoControls.previousChapterButton,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Play/Pause
|
// Play/Pause
|
||||||
StreamBuilder<bool>(
|
Opacity(
|
||||||
stream: widget.player.streams.playing,
|
opacity: widget.canControl ? 1.0 : 0.5,
|
||||||
initialData: widget.player.state.playing,
|
child: StreamBuilder<bool>(
|
||||||
builder: (context, snapshot) {
|
stream: widget.player.streams.playing,
|
||||||
final isPlaying = snapshot.data ?? false;
|
initialData: widget.player.state.playing,
|
||||||
return _buildFocusableButton(
|
builder: (context, snapshot) {
|
||||||
focusNode: _playPauseFocusNode,
|
final isPlaying = snapshot.data ?? false;
|
||||||
index: 2,
|
return _buildFocusableButton(
|
||||||
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
focusNode: _playPauseFocusNode,
|
||||||
iconSize: 32,
|
index: 2,
|
||||||
onPressed: () {
|
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||||
if (isPlaying) {
|
iconSize: 32,
|
||||||
widget.player.pause();
|
onPressed: widget.canControl
|
||||||
} else {
|
? () {
|
||||||
widget.player.play();
|
if (isPlaying) {
|
||||||
}
|
widget.player.pause();
|
||||||
},
|
} else {
|
||||||
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
|
widget.player.play();
|
||||||
);
|
}
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
|
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Next chapter (or skip forward if no chapters)
|
// Next chapter (or skip forward if no chapters)
|
||||||
_buildFocusableButton(
|
Opacity(
|
||||||
focusNode: _nextChapterFocusNode,
|
opacity: widget.canControl ? 1.0 : 0.5,
|
||||||
index: 3,
|
child: _buildFocusableButton(
|
||||||
icon: widget.chapters.isEmpty
|
focusNode: _nextChapterFocusNode,
|
||||||
? widget.getForwardIcon(widget.seekTimeSmall)
|
index: 3,
|
||||||
: Symbols.fast_forward_rounded,
|
icon: widget.chapters.isEmpty
|
||||||
onPressed: widget.onSeekToNextChapter,
|
? widget.getForwardIcon(widget.seekTimeSmall)
|
||||||
semanticLabel: widget.chapters.isEmpty
|
: Symbols.fast_forward_rounded,
|
||||||
? t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall)
|
onPressed: widget.canControl ? widget.onSeekToNextChapter : null,
|
||||||
: t.videoControls.nextChapterButton,
|
semanticLabel: widget.chapters.isEmpty
|
||||||
|
? t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall)
|
||||||
|
: t.videoControls.nextChapterButton,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Next item
|
// Next item
|
||||||
_buildFocusableButton(
|
Opacity(
|
||||||
focusNode: _nextItemFocusNode,
|
opacity: widget.canControl ? 1.0 : 0.5,
|
||||||
index: 4,
|
child: _buildFocusableButton(
|
||||||
icon: Symbols.skip_next_rounded,
|
focusNode: _nextItemFocusNode,
|
||||||
color: widget.onNext != null ? Colors.white : Colors.white54,
|
index: 4,
|
||||||
onPressed: widget.onNext,
|
icon: Symbols.skip_next_rounded,
|
||||||
semanticLabel: t.videoControls.nextButton,
|
color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54,
|
||||||
|
onPressed: widget.canControl ? widget.onNext : null,
|
||||||
|
semanticLabel: t.videoControls.nextButton,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
// Volume control
|
// Volume control
|
||||||
@@ -439,6 +467,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
focusNodes: _trackControlFocusNodes,
|
focusNodes: _trackControlFocusNodes,
|
||||||
onFocusChange: _onFocusChange,
|
onFocusChange: _onFocusChange,
|
||||||
onNavigateLeft: navigateFromTrackToVolume,
|
onNavigateLeft: navigateFromTrackToVolume,
|
||||||
|
canControl: widget.canControl,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,9 +27,14 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
final Widget trackChapterControls;
|
final Widget trackChapterControls;
|
||||||
final Function(Duration) onSeek;
|
final Function(Duration) onSeek;
|
||||||
final Function(Duration) onSeekEnd;
|
final Function(Duration) onSeekEnd;
|
||||||
|
final Function(Duration)? onSeekCompleted;
|
||||||
final VoidCallback onPlayPause;
|
final VoidCallback onPlayPause;
|
||||||
final VoidCallback? onCancelAutoHide;
|
final VoidCallback? onCancelAutoHide;
|
||||||
final VoidCallback? onStartAutoHide;
|
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({
|
const MobileVideoControls({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -42,8 +47,11 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
required this.onSeek,
|
required this.onSeek,
|
||||||
required this.onSeekEnd,
|
required this.onSeekEnd,
|
||||||
required this.onPlayPause,
|
required this.onPlayPause,
|
||||||
|
this.onSeekCompleted,
|
||||||
this.onCancelAutoHide,
|
this.onCancelAutoHide,
|
||||||
this.onStartAutoHide,
|
this.onStartAutoHide,
|
||||||
|
this.onBack,
|
||||||
|
this.canControl = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -72,6 +80,7 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
style: VideoHeaderStyle.multiLine,
|
style: VideoHeaderStyle.multiLine,
|
||||||
trailing: trackChapterControls,
|
trailing: trackChapterControls,
|
||||||
|
onBack: onBack,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -80,6 +89,11 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaybackControls(BuildContext context) {
|
Widget _buildPlaybackControls(BuildContext context) {
|
||||||
|
// Hide all playback controls in host-only mode for non-host
|
||||||
|
if (!canControl) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
return StreamBuilder<bool>(
|
return StreamBuilder<bool>(
|
||||||
stream: player.streams.playing,
|
stream: player.streams.playing,
|
||||||
initialData: player.state.playing,
|
initialData: player.state.playing,
|
||||||
@@ -93,7 +107,8 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
icon: getReplayIcon(seekTimeSmall),
|
icon: getReplayIcon(seekTimeSmall),
|
||||||
iconSize: 48,
|
iconSize: 48,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
seekWithClamping(player, Duration(seconds: -seekTimeSmall));
|
final newPosition = seekWithClamping(player, Duration(seconds: -seekTimeSmall));
|
||||||
|
onSeekCompleted?.call(newPosition);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 48),
|
const SizedBox(width: 48),
|
||||||
@@ -117,7 +132,8 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
icon: getForwardIcon(seekTimeSmall),
|
icon: getForwardIcon(seekTimeSmall),
|
||||||
iconSize: 48,
|
iconSize: 48,
|
||||||
onPressed: () {
|
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,
|
onSeek: onSeek,
|
||||||
onSeekEnd: onSeekEnd,
|
onSeekEnd: onSeekEnd,
|
||||||
horizontalLayout: false,
|
horizontalLayout: false,
|
||||||
|
enabled: canControl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,11 +69,15 @@ class VideoSettingsSheet extends StatefulWidget {
|
|||||||
final int audioSyncOffset;
|
final int audioSyncOffset;
|
||||||
final int subtitleSyncOffset;
|
final int subtitleSyncOffset;
|
||||||
|
|
||||||
|
/// Whether the user can control playback (false hides speed option in host-only mode).
|
||||||
|
final bool canControl;
|
||||||
|
|
||||||
const VideoSettingsSheet({
|
const VideoSettingsSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
required this.audioSyncOffset,
|
required this.audioSyncOffset,
|
||||||
required this.subtitleSyncOffset,
|
required this.subtitleSyncOffset,
|
||||||
|
this.canControl = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<void> show(
|
static Future<void> show(
|
||||||
@@ -83,13 +87,18 @@ class VideoSettingsSheet extends StatefulWidget {
|
|||||||
int subtitleSyncOffset, {
|
int subtitleSyncOffset, {
|
||||||
VoidCallback? onOpen,
|
VoidCallback? onOpen,
|
||||||
VoidCallback? onClose,
|
VoidCallback? onClose,
|
||||||
|
bool canControl = true,
|
||||||
}) {
|
}) {
|
||||||
return BaseVideoControlSheet.showSheet(
|
return BaseVideoControlSheet.showSheet(
|
||||||
context: context,
|
context: context,
|
||||||
onOpen: onOpen,
|
onOpen: onOpen,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
builder: (context) =>
|
builder: (context) => VideoSettingsSheet(
|
||||||
VideoSettingsSheet(player: player, audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset),
|
player: player,
|
||||||
|
audioSyncOffset: audioSyncOffset,
|
||||||
|
subtitleSyncOffset: subtitleSyncOffset,
|
||||||
|
canControl: canControl,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,21 +210,22 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
// Playback Speed
|
// Playback Speed - only show if user can control playback
|
||||||
StreamBuilder<double>(
|
if (widget.canControl)
|
||||||
stream: widget.player.streams.rate,
|
StreamBuilder<double>(
|
||||||
initialData: widget.player.state.rate,
|
stream: widget.player.streams.rate,
|
||||||
builder: (context, snapshot) {
|
initialData: widget.player.state.rate,
|
||||||
final currentRate = snapshot.data ?? 1.0;
|
builder: (context, snapshot) {
|
||||||
return _SettingsMenuItem(
|
final currentRate = snapshot.data ?? 1.0;
|
||||||
focusNode: _initialFocusNode,
|
return _SettingsMenuItem(
|
||||||
icon: Symbols.speed_rounded,
|
focusNode: _initialFocusNode,
|
||||||
title: 'Playback Speed',
|
icon: Symbols.speed_rounded,
|
||||||
valueText: _formatSpeed(currentRate),
|
title: 'Playback Speed',
|
||||||
onTap: () => _navigateTo(_SettingsView.speed),
|
valueText: _formatSpeed(currentRate),
|
||||||
);
|
onTap: () => _navigateTo(_SettingsView.speed),
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Sleep Timer
|
// Sleep Timer
|
||||||
ListenableBuilder(
|
ListenableBuilder(
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import 'dart:io' show Platform;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:plezy/widgets/app_icon.dart';
|
import 'package:plezy/widgets/app_icon.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:rate_limiter/rate_limiter.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:macos_window_utils/macos_window_utils.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
@@ -30,6 +39,7 @@ import 'icons.dart';
|
|||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../focus/input_mode_tracker.dart';
|
import '../../focus/input_mode_tracker.dart';
|
||||||
|
import '../../watch_together/watch_together.dart';
|
||||||
import 'widgets/track_chapter_controls.dart';
|
import 'widgets/track_chapter_controls.dart';
|
||||||
import 'mobile_video_controls.dart';
|
import 'mobile_video_controls.dart';
|
||||||
import 'desktop_video_controls.dart';
|
import 'desktop_video_controls.dart';
|
||||||
@@ -46,6 +56,9 @@ Widget plexVideoControlsBuilder(
|
|||||||
VoidCallback? onCycleBoxFitMode,
|
VoidCallback? onCycleBoxFitMode,
|
||||||
Function(AudioTrack)? onAudioTrackChanged,
|
Function(AudioTrack)? onAudioTrackChanged,
|
||||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
||||||
|
Function(Duration position)? onSeekCompleted,
|
||||||
|
VoidCallback? onBack,
|
||||||
|
bool canControl = true,
|
||||||
}) {
|
}) {
|
||||||
return PlexVideoControls(
|
return PlexVideoControls(
|
||||||
player: player,
|
player: player,
|
||||||
@@ -58,6 +71,9 @@ Widget plexVideoControlsBuilder(
|
|||||||
onCycleBoxFitMode: onCycleBoxFitMode,
|
onCycleBoxFitMode: onCycleBoxFitMode,
|
||||||
onAudioTrackChanged: onAudioTrackChanged,
|
onAudioTrackChanged: onAudioTrackChanged,
|
||||||
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
||||||
|
onSeekCompleted: onSeekCompleted,
|
||||||
|
onBack: onBack,
|
||||||
|
canControl: canControl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +89,15 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
final Function(AudioTrack)? onAudioTrackChanged;
|
final Function(AudioTrack)? onAudioTrackChanged;
|
||||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
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({
|
const PlexVideoControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
@@ -85,6 +110,9 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
this.onCycleBoxFitMode,
|
this.onCycleBoxFitMode,
|
||||||
this.onAudioTrackChanged,
|
this.onAudioTrackChanged,
|
||||||
this.onSubtitleTrackChanged,
|
this.onSubtitleTrackChanged,
|
||||||
|
this.onSeekCompleted,
|
||||||
|
this.onBack,
|
||||||
|
this.canControl = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -167,6 +195,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_focusPlayPauseIfKeyboardMode();
|
_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)
|
/// 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() {
|
void _skipMarker() {
|
||||||
if (_currentMarker != null) {
|
if (_currentMarker != null) {
|
||||||
widget.player.seek(_currentMarker!.endTime);
|
final endTime = _currentMarker!.endTime;
|
||||||
|
widget.player.seek(endTime);
|
||||||
|
widget.onSeekCompleted?.call(endTime);
|
||||||
}
|
}
|
||||||
_cancelAutoSkipTimer();
|
_cancelAutoSkipTimer();
|
||||||
}
|
}
|
||||||
@@ -365,6 +397,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
|
||||||
_hideTimer?.cancel();
|
_hideTimer?.cancel();
|
||||||
_feedbackTimer?.cancel();
|
_feedbackTimer?.cancel();
|
||||||
_resizeDebounceTimer?.cancel();
|
_resizeDebounceTimer?.cancel();
|
||||||
@@ -676,49 +709,55 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
onCancelAutoHide: () => _hideTimer?.cancel(),
|
onCancelAutoHide: () => _hideTimer?.cancel(),
|
||||||
onStartAutoHide: _startHideTimer,
|
onStartAutoHide: _startHideTimer,
|
||||||
serverId: widget.metadata.serverId ?? '',
|
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) {
|
if (_chapters.isEmpty) {
|
||||||
// No chapters - seek backward by configured amount
|
// No chapters - seek by configured amount
|
||||||
seekWithClamping(widget.player, Duration(seconds: -_seekTimeSmall));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentPosition = widget.player.state.position.inMilliseconds;
|
final currentPositionMs = widget.player.state.position.inMilliseconds;
|
||||||
|
|
||||||
// Find current chapter
|
if (forward) {
|
||||||
for (int i = _chapters.length - 1; i >= 0; i--) {
|
// Find next chapter
|
||||||
final chapterStart = _chapters[i].startTimeOffset ?? 0;
|
for (final chapter in _chapters) {
|
||||||
if (currentPosition > chapterStart + 3000) {
|
final chapterStart = chapter.startTimeOffset ?? 0;
|
||||||
// If more than 3 seconds into chapter, go to start of current chapter
|
if (chapterStart > currentPositionMs) {
|
||||||
widget.player.seek(Duration(milliseconds: chapterStart));
|
_seekToPosition(Duration(milliseconds: chapterStart));
|
||||||
return;
|
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() {
|
void _seekToPosition(Duration position) {
|
||||||
if (_chapters.isEmpty) {
|
widget.player.seek(position);
|
||||||
// No chapters - seek forward by configured amount
|
widget.onSeekCompleted?.call(position);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms
|
/// 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) {
|
void _finalizeSeek(Duration position) {
|
||||||
_seekThrottle.cancel();
|
_seekThrottle.cancel();
|
||||||
widget.player.seek(position);
|
widget.player.seek(position);
|
||||||
|
widget.onSeekCompleted?.call(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle double-tap skip forward or backward
|
/// Handle double-tap skip forward or backward
|
||||||
void _handleDoubleTapSkip({required bool isForward}) {
|
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
|
// 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
|
// Show visual feedback
|
||||||
_showSkipFeedback(isForward: isForward);
|
_showSkipFeedback(isForward: isForward);
|
||||||
@@ -838,8 +891,48 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
key == LogicalKeyboardKey.gameButtonA;
|
key == LogicalKeyboardKey.gameButtonA;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show controls and focus play/pause on keyboard input (desktop only)
|
/// Determine if the key event should toggle play/pause based on configured hotkeys.
|
||||||
void _showControlsWithFocus() {
|
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) {
|
if (!_showControls) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showControls = true;
|
_showControls = true;
|
||||||
@@ -853,9 +946,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
_startHideTimer();
|
_startHideTimer();
|
||||||
|
|
||||||
// Request focus on play/pause button after controls are shown
|
// Request focus on play/pause button after controls are shown
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
if (requestFocus) {
|
||||||
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
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)
|
/// 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 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
|
// Handle Back/Escape: show controls if hidden, navigate back if visible
|
||||||
if (_isBackKey(key)) {
|
if (_isBackKey(key)) {
|
||||||
@@ -929,7 +1047,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
// Pass other events to the keyboard shortcuts service
|
// Pass other events to the keyboard shortcuts service
|
||||||
if (_keyboardService == null) return KeyEventResult.ignored;
|
if (_keyboardService == null) return KeyEventResult.ignored;
|
||||||
|
|
||||||
return _keyboardService!.handleVideoPlayerKeyEvent(
|
final result = _keyboardService!.handleVideoPlayerKeyEvent(
|
||||||
event,
|
event,
|
||||||
widget.player,
|
widget.player,
|
||||||
_toggleFullscreen,
|
_toggleFullscreen,
|
||||||
@@ -938,8 +1056,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
_nextSubtitleTrack,
|
_nextSubtitleTrack,
|
||||||
_nextChapter,
|
_nextChapter,
|
||||||
_previousChapter,
|
_previousChapter,
|
||||||
onBack: () => Navigator.of(context).pop(true),
|
onBack: widget.onBack ?? () => Navigator.of(context).pop(true),
|
||||||
);
|
);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
child: Listener(
|
child: Listener(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
@@ -1000,9 +1119,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
trackChapterControls: _buildTrackChapterControlsWidget(),
|
trackChapterControls: _buildTrackChapterControlsWidget(),
|
||||||
onSeek: _throttledSeek,
|
onSeek: _throttledSeek,
|
||||||
onSeekEnd: _finalizeSeek,
|
onSeekEnd: _finalizeSeek,
|
||||||
|
onSeekCompleted: widget.onSeekCompleted,
|
||||||
onPlayPause: () {}, // Not used, handled internally
|
onPlayPause: () {}, // Not used, handled internally
|
||||||
onCancelAutoHide: () => _hideTimer?.cancel(),
|
onCancelAutoHide: () => _hideTimer?.cancel(),
|
||||||
onStartAutoHide: _startHideTimer,
|
onStartAutoHide: _startHideTimer,
|
||||||
|
onBack: widget.onBack,
|
||||||
|
canControl: widget.canControl,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Listener(
|
: Listener(
|
||||||
@@ -1045,6 +1167,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
onCancelAutoHide: () => _hideTimer?.cancel(),
|
onCancelAutoHide: () => _hideTimer?.cancel(),
|
||||||
onStartAutoHide: _startHideTimer,
|
onStartAutoHide: _startHideTimer,
|
||||||
serverId: widget.metadata.serverId ?? '',
|
serverId: widget.metadata.serverId ?? '',
|
||||||
|
onBack: widget.onBack,
|
||||||
|
canControl: widget.canControl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class TimelineSlider extends StatelessWidget {
|
|||||||
/// Called when focus changes.
|
/// Called when focus changes.
|
||||||
final ValueChanged<bool>? onFocusChange;
|
final ValueChanged<bool>? onFocusChange;
|
||||||
|
|
||||||
|
/// Whether the slider is enabled for interaction.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
const TimelineSlider({
|
const TimelineSlider({
|
||||||
super.key,
|
super.key,
|
||||||
required this.position,
|
required this.position,
|
||||||
@@ -36,6 +39,7 @@ class TimelineSlider extends StatelessWidget {
|
|||||||
this.focusNode,
|
this.focusNode,
|
||||||
this.onKeyEvent,
|
this.onKeyEvent,
|
||||||
this.onFocusChange,
|
this.onFocusChange,
|
||||||
|
this.enabled = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -68,22 +72,25 @@ class TimelineSlider extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Slider
|
// Slider - use IgnorePointer to block interaction while preserving visual style
|
||||||
Semantics(
|
IgnorePointer(
|
||||||
label: t.videoControls.timelineSlider,
|
ignoring: !enabled,
|
||||||
slider: true,
|
child: Semantics(
|
||||||
child: Slider(
|
label: t.videoControls.timelineSlider,
|
||||||
value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0,
|
slider: true,
|
||||||
min: 0.0,
|
child: Slider(
|
||||||
max: duration.inMilliseconds.toDouble(),
|
value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0,
|
||||||
onChanged: (value) {
|
min: 0.0,
|
||||||
onSeek(Duration(milliseconds: value.toInt()));
|
max: duration.inMilliseconds.toDouble(),
|
||||||
},
|
onChanged: (value) {
|
||||||
onChangeEnd: (value) {
|
onSeek(Duration(milliseconds: value.toInt()));
|
||||||
onSeekEnd(Duration(milliseconds: value.toInt()));
|
},
|
||||||
},
|
onChangeEnd: (value) {
|
||||||
activeColor: Colors.white,
|
onSeekEnd(Duration(milliseconds: value.toInt()));
|
||||||
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
},
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Chapter marker indicators
|
// Chapter marker indicators
|
||||||
@@ -105,7 +112,7 @@ class TimelineSlider extends StatelessWidget {
|
|||||||
if (focusNode != null) {
|
if (focusNode != null) {
|
||||||
slider = FocusableWrapper(
|
slider = FocusableWrapper(
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
onKeyEvent: onKeyEvent,
|
onKeyEvent: enabled ? onKeyEvent : null,
|
||||||
onFocusChange: onFocusChange,
|
onFocusChange: onFocusChange,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
autoScroll: false,
|
autoScroll: false,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class TrackChapterControls extends StatelessWidget {
|
|||||||
/// Called to navigate left from the first button
|
/// Called to navigate left from the first button
|
||||||
final VoidCallback? onNavigateLeft;
|
final VoidCallback? onNavigateLeft;
|
||||||
|
|
||||||
|
/// Whether the user can control playback (false in host-only mode for non-host).
|
||||||
|
final bool canControl;
|
||||||
|
|
||||||
const TrackChapterControls({
|
const TrackChapterControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
@@ -76,6 +79,7 @@ class TrackChapterControls extends StatelessWidget {
|
|||||||
this.focusNodes,
|
this.focusNodes,
|
||||||
this.onFocusChange,
|
this.onFocusChange,
|
||||||
this.onNavigateLeft,
|
this.onNavigateLeft,
|
||||||
|
this.canControl = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Handle key event for button navigation
|
/// Handle key event for button navigation
|
||||||
@@ -175,6 +179,7 @@ class TrackChapterControls extends StatelessWidget {
|
|||||||
subtitleSyncOffset,
|
subtitleSyncOffset,
|
||||||
onOpen: onCancelAutoHide,
|
onOpen: onCancelAutoHide,
|
||||||
onClose: onStartAutoHide,
|
onClose: onStartAutoHide,
|
||||||
|
canControl: canControl,
|
||||||
);
|
);
|
||||||
onLoadSeekTimes?.call();
|
onLoadSeekTimes?.call();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ class VideoControlsHeader extends StatelessWidget {
|
|||||||
/// Optional trailing widget (e.g., track/chapter controls)
|
/// Optional trailing widget (e.g., track/chapter controls)
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
|
|
||||||
|
/// Optional callback for back button. If null, defaults to Navigator.pop(true).
|
||||||
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
const VideoControlsHeader({
|
const VideoControlsHeader({
|
||||||
super.key,
|
super.key,
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
this.style = VideoHeaderStyle.multiLine,
|
this.style = VideoHeaderStyle.multiLine,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
|
this.onBack,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,7 +42,7 @@ class VideoControlsHeader extends StatelessWidget {
|
|||||||
AppBarBackButton(
|
AppBarBackButton(
|
||||||
style: BackButtonStyle.video,
|
style: BackButtonStyle.video,
|
||||||
semanticLabel: t.videoControls.backButton,
|
semanticLabel: t.videoControls.backButton,
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: onBack ?? () => Navigator.of(context).pop(true),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()),
|
Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()),
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class VideoTimelineBar extends StatelessWidget {
|
|||||||
/// Called when focus changes.
|
/// Called when focus changes.
|
||||||
final ValueChanged<bool>? onFocusChange;
|
final ValueChanged<bool>? onFocusChange;
|
||||||
|
|
||||||
|
/// Whether the timeline is enabled for interaction.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
const VideoTimelineBar({
|
const VideoTimelineBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
@@ -41,6 +44,7 @@ class VideoTimelineBar extends StatelessWidget {
|
|||||||
this.focusNode,
|
this.focusNode,
|
||||||
this.onKeyEvent,
|
this.onKeyEvent,
|
||||||
this.onFocusChange,
|
this.onFocusChange,
|
||||||
|
this.enabled = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -109,6 +113,7 @@ class VideoTimelineBar extends StatelessWidget {
|
|||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
onKeyEvent: onKeyEvent,
|
onKeyEvent: onKeyEvent,
|
||||||
onFocusChange: onFocusChange,
|
onFocusChange: onFocusChange,
|
||||||
|
enabled: enabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||||
#include <gamepads_linux/gamepads_linux_plugin.h>
|
#include <gamepads_linux/gamepads_linux_plugin.h>
|
||||||
#include <hotkey_manager_linux/hotkey_manager_linux_plugin.h>
|
#include <hotkey_manager_linux/hotkey_manager_linux_plugin.h>
|
||||||
#include <os_media_controls/os_media_controls_plugin.h>
|
#include <os_media_controls/os_media_controls_plugin.h>
|
||||||
@@ -15,6 +16,9 @@
|
|||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
|
||||||
|
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) gamepads_linux_registrar =
|
g_autoptr(FlPluginRegistrar) gamepads_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin");
|
||||||
gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar);
|
gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_webrtc
|
||||||
gamepads_linux
|
gamepads_linux
|
||||||
hotkey_manager_linux
|
hotkey_manager_linux
|
||||||
os_media_controls
|
os_media_controls
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Foundation
|
|||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import file_picker
|
import file_picker
|
||||||
|
import flutter_webrtc
|
||||||
import gamepads_darwin
|
import gamepads_darwin
|
||||||
import hotkey_manager_macos
|
import hotkey_manager_macos
|
||||||
import macos_window_utils
|
import macos_window_utils
|
||||||
@@ -15,6 +16,7 @@ import os_media_controls
|
|||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import screen_retriever_macos
|
import screen_retriever_macos
|
||||||
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import sqlite3_flutter_libs
|
import sqlite3_flutter_libs
|
||||||
@@ -26,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
|
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||||
GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin"))
|
GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin"))
|
||||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
||||||
@@ -33,6 +36,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- flutter_webrtc (1.2.0):
|
||||||
|
- FlutterMacOS
|
||||||
|
- WebRTC-SDK (= 137.7151.04)
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
- gamepads_darwin (0.1.1):
|
- gamepads_darwin (0.1.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -23,6 +26,8 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- screen_retriever_macos (0.0.1):
|
- screen_retriever_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -58,6 +63,7 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- wakelock_plus (0.0.1):
|
- wakelock_plus (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- WebRTC-SDK (137.7151.04)
|
||||||
- window_manager (0.2.0):
|
- window_manager (0.2.0):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
@@ -65,6 +71,7 @@ DEPENDENCIES:
|
|||||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||||
|
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`)
|
- gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`)
|
||||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||||
@@ -73,6 +80,7 @@ DEPENDENCIES:
|
|||||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
|
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
|
||||||
|
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
||||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
- sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||||
@@ -84,6 +92,7 @@ SPEC REPOS:
|
|||||||
trunk:
|
trunk:
|
||||||
- HotKey
|
- HotKey
|
||||||
- sqlite3
|
- sqlite3
|
||||||
|
- WebRTC-SDK
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
@@ -92,6 +101,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||||
|
flutter_webrtc:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
gamepads_darwin:
|
gamepads_darwin:
|
||||||
@@ -108,6 +119,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||||
screen_retriever_macos:
|
screen_retriever_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
|
||||||
|
share_plus:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
@@ -125,6 +138,7 @@ SPEC CHECKSUMS:
|
|||||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||||
|
flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
gamepads_darwin: 643b6a69e20ca678fae83781b7f7fc8f15f5d710
|
gamepads_darwin: 643b6a69e20ca678fae83781b7f7fc8f15f5d710
|
||||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||||
@@ -134,12 +148,14 @@ SPEC CHECKSUMS:
|
|||||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||||
|
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
|
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
|
||||||
sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41
|
sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41
|
||||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
||||||
|
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
||||||
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
|
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
|
||||||
|
|
||||||
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
|
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
|
||||||
|
|||||||
+58
-2
@@ -289,6 +289,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
|
dart_webrtc:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_webrtc
|
||||||
|
sha256: "51bcda4ba5d7dd9e65a309244ce3ac0b58025e6e1f6d7442cee4cd02134ef65f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -353,6 +361,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.3"
|
version: "4.0.3"
|
||||||
|
events_emitter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: events_emitter
|
||||||
|
sha256: a075477bdf9c8c0c31bb7c7b7bdd357b4486c34f30163119f96de4e7f54abeff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -456,6 +472,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_webrtc:
|
||||||
|
dependency: "direct overridden"
|
||||||
|
description:
|
||||||
|
name: flutter_webrtc
|
||||||
|
sha256: "71a38363a5b50603e405c275f30de2eb90f980b0cc94b0e1e9d8b9d6a6b03bf0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
frontend_server_client:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -881,6 +905,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
peerdart:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: peerdart
|
||||||
|
sha256: "1d0db041d42194f42e57d8889d029fb8d107610bf1062b39f43f4651de547e3c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.6"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -997,10 +1029,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: rxdart
|
name: rxdart
|
||||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
version: "0.27.7"
|
||||||
saf_stream:
|
saf_stream:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1057,6 +1089,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.0"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.1.4"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.2"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1462,6 +1510,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
webrtc_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webrtc_interface
|
||||||
|
sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ dependencies:
|
|||||||
saf_util: ^0.11.0
|
saf_util: ^0.11.0
|
||||||
saf_stream: ^0.12.2
|
saf_stream: ^0.12.2
|
||||||
material_symbols_icons: ^4.2892.0
|
material_symbols_icons: ^4.2892.0
|
||||||
|
peerdart: ^0.5.6
|
||||||
|
share_plus: ^10.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -57,6 +59,9 @@ dev_dependencies:
|
|||||||
dart_code_linter: ^3.1.1
|
dart_code_linter: ^3.1.1
|
||||||
drift_dev: ^2.14.0
|
drift_dev: ^2.14.0
|
||||||
|
|
||||||
|
dependency_overrides:
|
||||||
|
flutter_webrtc: ^1.2.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
|
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||||
#include <gamepads_windows/gamepads_windows_plugin_c_api.h>
|
#include <gamepads_windows/gamepads_windows_plugin_c_api.h>
|
||||||
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
|
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
|
||||||
#include <os_media_controls/os_media_controls_plugin_c_api.h>
|
#include <os_media_controls/os_media_controls_plugin_c_api.h>
|
||||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||||
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
@@ -18,6 +20,8 @@
|
|||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
|
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
|
||||||
GamepadsWindowsPluginCApiRegisterWithRegistrar(
|
GamepadsWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("GamepadsWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("GamepadsWindowsPluginCApi"));
|
||||||
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
|
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
|
||||||
@@ -26,6 +30,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
|
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
|
||||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||||
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
connectivity_plus
|
connectivity_plus
|
||||||
|
flutter_webrtc
|
||||||
gamepads_windows
|
gamepads_windows
|
||||||
hotkey_manager_windows
|
hotkey_manager_windows
|
||||||
os_media_controls
|
os_media_controls
|
||||||
screen_retriever_windows
|
screen_retriever_windows
|
||||||
|
share_plus
|
||||||
sqlite3_flutter_libs
|
sqlite3_flutter_libs
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
window_manager
|
window_manager
|
||||||
|
|||||||
Reference in New Issue
Block a user