diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 3c0a0555..606d4067 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,6 +1,7 @@ import 'dart:io' show Platform, exit; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show SystemNavigator; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; @@ -419,14 +420,13 @@ class _MainScreenState extends State with RouteAware, WindowListener } KeyEventResult _handleBackKey(KeyEvent event) { - // Toggle focus between sidebar and content on BACK key - return handleBackKeyAction(event, () { - if (_isSidebarFocused) { - _focusContent(); - } else { - _focusSidebar(); - } - }); + if (!_isSidebarFocused) { + // Content focused → move to sidebar + return handleBackKeyAction(event, _focusSidebar); + } + + // Sidebar focused → exit app + return handleBackKeyAction(event, () => SystemNavigator.pop()); } @override diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index 04681777..fb8a3ed3 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -91,7 +91,7 @@ class _PinEntryDialogState extends State with SingleTickerProvid controller: _pinController, focusNode: _focusNode, obscureText: _obscureText, - keyboardType: TextInputType.number, + keyboardType: TextInputType.phone, inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10)], decoration: InputDecoration( hintText: t.pinEntry.enterPin, diff --git a/lib/screens/profile/profile_list_tile.dart b/lib/screens/profile/profile_list_tile.dart index 84a6c622..fd138dfb 100644 --- a/lib/screens/profile/profile_list_tile.dart +++ b/lib/screens/profile/profile_list_tile.dart @@ -13,6 +13,7 @@ class ProfileListTile extends StatelessWidget { final VoidCallback onTap; final bool isCurrentUser; final bool showTrailingIcon; + final bool autofocus; const ProfileListTile({ super.key, @@ -20,6 +21,7 @@ class ProfileListTile extends StatelessWidget { required this.onTap, this.isCurrentUser = false, this.showTrailingIcon = true, + this.autofocus = false, }); @override @@ -27,6 +29,7 @@ class ProfileListTile extends StatelessWidget { final theme = Theme.of(context); return ListTile( + autofocus: autofocus, leading: UserAvatarWidget(user: user, size: 40, showIndicators: false), title: Text(user.displayName), subtitle: _hasUserAttributes() ? Row(children: _buildUserAttributes(theme)) : null, @@ -49,7 +52,6 @@ class ProfileListTile extends StatelessWidget { ) : (showTrailingIcon ? const AppIcon(Symbols.chevron_right_rounded, fill: 1) : null), onTap: isCurrentUser ? null : onTap, - enabled: !isCurrentUser, ); } diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 81c126e3..755fbb2b 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -6,7 +6,7 @@ import '../../providers/user_profile_provider.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import 'profile_list_tile.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; import '../libraries/state_messages.dart'; import '../../i18n/strings.g.dart'; @@ -17,83 +17,88 @@ class ProfileSwitchScreen extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.screens.switchProfile)), - SliverFillRemaining( - child: Consumer( - builder: (context, userProvider, child) { - final users = userProvider.home?.users ?? []; + return Consumer( + builder: (context, userProvider, child) { + final users = userProvider.home?.users ?? []; - if (userProvider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (userProvider.error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - userProvider.error!, - style: TextStyle(color: theme.colorScheme.error), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - userProvider.refreshCurrentUser(); - }, - child: Text(t.common.retry), - ), - ], - ), - ); - } - - if (users.isEmpty) { - return const EmptyStateWidget( - message: 'No profiles available', - subtitle: 'Contact your Plex administrator to add profiles', - icon: Symbols.person_off_rounded, - ); - } - - return ListView.builder( - itemCount: users.length, - padding: const EdgeInsets.all(16), - itemBuilder: (context, index) { + return FocusedScrollScaffold( + title: Text(t.screens.switchProfile), + slivers: [ + if (userProvider.isLoading) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (userProvider.error != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + userProvider.error!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + userProvider.refreshCurrentUser(); + }, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (users.isEmpty) + const SliverFillRemaining( + child: EmptyStateWidget( + message: 'No profiles available', + subtitle: 'Contact your Plex administrator to add profiles', + icon: Symbols.person_off_rounded, + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { final user = users[index]; final isCurrentUser = user.uuid == userProvider.currentUser?.uuid; + final isFirstSelectable = !isCurrentUser && + !users.take(index).any((u) => u.uuid != userProvider.currentUser?.uuid); return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: EdgeInsets.only( + left: 16, + right: 16, + top: index == 0 ? 16 : 0, + bottom: 8, + ), child: Card( child: ProfileListTile( user: user, isCurrentUser: isCurrentUser, + autofocus: isFirstSelectable, onTap: () => _switchToUser(context, user), ), ), ); }, - ); - }, - ), - ), - ], - ), + childCount: users.length, + ), + ), + ], + ); + }, ); } void _switchToUser(BuildContext context, PlexHomeUser user) async { final userProvider = context.userProfile; + final navigator = Navigator.of(context); final success = await userProvider.switchToUser(user, context); - if (success && context.mounted) { - Navigator.of(context).pop(); - } else if (!success && context.mounted) { + if (success) { + navigator.pop(); + } else if (context.mounted) { showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: user.displayName)); } } diff --git a/lib/services/watch_next_service.dart b/lib/services/watch_next_service.dart new file mode 100644 index 00000000..eb6cdea3 --- /dev/null +++ b/lib/services/watch_next_service.dart @@ -0,0 +1,202 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../models/plex_metadata.dart'; +import '../utils/app_logger.dart'; +import '../utils/plex_url_helper.dart'; +import 'plex_client.dart'; + +/// Service for syncing Plex "On Deck" content to Android TV's Watch Next row. +/// This allows users to resume content directly from the Android TV launcher. +class WatchNextService { + static const MethodChannel _channel = MethodChannel('app.plezy/watch_next'); + + // Singleton instance + static final WatchNextService _instance = WatchNextService._internal(); + factory WatchNextService() => _instance; + + WatchNextService._internal() { + // Listen for callbacks from native Android (deep link taps) + _channel.setMethodCallHandler(_handleMethodCall); + } + + /// Callback for when a Watch Next item is tapped. + /// The contentId format is: plezy_{serverId}_{ratingKey} + ValueChanged? onWatchNextTap; + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'onWatchNextTap': + final contentId = call.arguments['contentId'] as String?; + if (contentId != null) { + appLogger.d('Watch Next tap received: $contentId'); + onWatchNextTap?.call(contentId); + } + break; + } + } + + /// Check if Watch Next is supported (Android TV only). + Future isSupported() async { + if (!Platform.isAndroid) return false; + + try { + return await _channel.invokeMethod('isSupported') ?? false; + } catch (e) { + appLogger.w('Failed to check Watch Next support', error: e); + return false; + } + } + + /// Sync On Deck items to Watch Next row. + /// Call this after fetching On Deck data on Android TV. + /// + /// [onDeckItems] - List of PlexMetadata items from On Deck + /// [getClientForServerId] - Function to get PlexClient for a given server ID + Future syncFromOnDeck( + List onDeckItems, + PlexClient Function(String serverId) getClientForServerId, + ) async { + if (!Platform.isAndroid) return false; + + try { + // Check if supported first + final supported = await isSupported(); + if (!supported) { + appLogger.d('Watch Next not supported on this device'); + return false; + } + + // Convert PlexMetadata items to Watch Next format + final items = onDeckItems.map((item) { + return _convertToWatchNextItem(item, getClientForServerId); + }).toList(); + + appLogger.d('Syncing ${items.length} items to Watch Next'); + + final success = await _channel.invokeMethod('sync', {'items': items}) ?? false; + + if (success) { + appLogger.d('Watch Next sync completed successfully'); + } else { + appLogger.w('Watch Next sync returned false'); + } + + return success; + } catch (e) { + appLogger.e('Failed to sync Watch Next', error: e); + return false; + } + } + + /// Clear all Watch Next entries. + Future clear() async { + if (!Platform.isAndroid) return false; + + try { + return await _channel.invokeMethod('clear') ?? false; + } catch (e) { + appLogger.e('Failed to clear Watch Next', error: e); + return false; + } + } + + /// Remove a single item from Watch Next. + Future removeItem(String serverId, String ratingKey) async { + if (!Platform.isAndroid) return false; + + try { + final contentId = _buildContentId(serverId, ratingKey); + return await _channel.invokeMethod('remove', {'contentId': contentId}) ?? false; + } catch (e) { + appLogger.e('Failed to remove Watch Next item', error: e); + return false; + } + } + + /// Build a content ID for Watch Next. + /// Format: plezy_{serverId}_{ratingKey} + static String _buildContentId(String? serverId, String ratingKey) { + final safeServerId = serverId ?? 'unknown'; + return 'plezy_${safeServerId}_$ratingKey'; + } + + /// Parse a content ID back to server ID and rating key. + /// Returns (serverId, ratingKey) or null if invalid. + static (String serverId, String ratingKey)? parseContentId(String contentId) { + if (!contentId.startsWith('plezy_')) return null; + + final parts = contentId.substring(6).split('_'); + if (parts.length < 2) return null; + + // The rating key might contain underscores, so rejoin everything after server ID + final serverId = parts[0]; + final ratingKey = parts.sublist(1).join('_'); + + return (serverId, ratingKey); + } + + /// Convert PlexMetadata to Watch Next item format. + Map _convertToWatchNextItem( + PlexMetadata item, + PlexClient Function(String serverId) getClientForServerId, + ) { + final contentId = _buildContentId(item.serverId, item.ratingKey); + + // Get poster URL with auth token + String? posterUri; + try { + if (item.serverId != null) { + final client = getClientForServerId(item.serverId!); + // Use grandparent thumb for episodes (show poster), or thumb for movies + final thumbPath = item.grandparentThumb ?? item.thumb; + if (thumbPath != null) { + posterUri = client.getThumbnailUrl(thumbPath); + } + } + } catch (e) { + appLogger.w('Failed to get poster URL for Watch Next: ${item.title}', error: e); + } + + // For episodes, create a display title that includes the show name + String title; + if (item.mediaType == PlexMediaType.episode && item.grandparentTitle != null) { + if (item.parentIndex != null && item.index != null) { + title = '${item.grandparentTitle} - S${item.parentIndex}:E${item.index}'; + } else { + title = '${item.grandparentTitle} - ${item.title}'; + } + } else { + title = item.title; + } + + // Calculate last engagement time (when the item was last watched) + // Use lastViewedAt if available, otherwise use current time + final lastEngagementTime = item.lastViewedAt != null + ? item.lastViewedAt! * + 1000 // Convert seconds to milliseconds + : DateTime.now().millisecondsSinceEpoch; + + return { + 'contentId': contentId, + 'title': title, + 'description': item.summary, + 'posterUri': posterUri, + 'type': item.type.toLowerCase(), + 'duration': item.duration ?? 0, + 'lastPlaybackPosition': item.viewOffset ?? 0, + 'lastEngagementTime': lastEngagementTime, + 'seriesTitle': item.grandparentTitle, + 'seasonNumber': item.parentIndex, + 'episodeNumber': item.index, + }; + } +} + +/// Extension on PlexMetadata for Watch Next convenience methods. +extension WatchNextMetadataExtension on PlexMetadata { + /// Get the Watch Next content ID for this item. + String get watchNextContentId => WatchNextService._buildContentId(serverId, ratingKey); +} diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 4e2dc65d..b5371772 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -96,6 +96,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { SizedBox( width: double.infinity, child: FilledButton.icon( + autofocus: true, onPressed: _isCreating || _isJoining ? null : _createSession, icon: _isCreating ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) @@ -320,6 +321,7 @@ class _ActiveSessionContent extends StatelessWidget { SizedBox( width: double.infinity, child: OutlinedButton.icon( + autofocus: true, onPressed: () => _leaveSession(context), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index 6f4a2fc1..1625460e 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -41,6 +41,7 @@ class FocusedScrollScaffold extends StatelessWidget { @override Widget build(BuildContext context) { return Focus( + canRequestFocus: false, onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), child: Scaffold( body: CustomScrollView(