Plex API returns Directory entries without type field, causing TypeError crash in fromJson. Made fields nullable and migrated callers to use mediaType enum and displayTitle getter instead of raw string access.
1606 lines
63 KiB
Dart
1606 lines
63 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../focus/focusable_action_bar.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../focus/key_event_utils.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import '../../services/plex_client.dart';
|
|
import '../utils/plex_image_helper.dart';
|
|
import '../widgets/plex_optimized_image.dart' show blurArtwork;
|
|
import '../models/plex_metadata.dart';
|
|
import '../utils/content_utils.dart';
|
|
import '../models/plex_hub.dart';
|
|
import '../providers/multi_server_provider.dart';
|
|
import '../providers/hidden_libraries_provider.dart';
|
|
import '../providers/playback_state_provider.dart';
|
|
import 'profile/user_avatar_widget.dart';
|
|
import '../widgets/hub_section.dart';
|
|
import 'profile/profile_switch_screen.dart';
|
|
import '../providers/user_profile_provider.dart';
|
|
import '../providers/settings_provider.dart';
|
|
import '../mixins/refreshable.dart';
|
|
import '../mixins/tab_visibility_aware.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../mixins/item_updatable.dart';
|
|
import '../mixins/watch_state_aware.dart';
|
|
import '../utils/watch_state_notifier.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/dialogs.dart';
|
|
import '../utils/provider_extensions.dart';
|
|
import '../utils/video_player_navigation.dart';
|
|
import '../utils/layout_constants.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../theme/mono_tokens.dart';
|
|
import '../services/watch_next_service.dart';
|
|
import 'auth_screen.dart';
|
|
import 'libraries/state_messages.dart';
|
|
import 'main_screen.dart';
|
|
import '../watch_together/watch_together.dart';
|
|
import '../providers/companion_remote_provider.dart';
|
|
import '../widgets/companion_remote/remote_session_dialog.dart';
|
|
import 'companion_remote/mobile_remote_screen.dart';
|
|
|
|
class DiscoverScreen extends StatefulWidget {
|
|
final VoidCallback? onBecameVisible;
|
|
|
|
const DiscoverScreen({super.key, this.onBecameVisible});
|
|
|
|
@override
|
|
State<DiscoverScreen> createState() => _DiscoverScreenState();
|
|
}
|
|
|
|
class _DiscoverScreenState extends State<DiscoverScreen>
|
|
with Refreshable, FullRefreshable, ItemUpdatable, WatchStateAware, TabVisibilityAware, WidgetsBindingObserver {
|
|
static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
|
|
static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200);
|
|
|
|
@override
|
|
PlexClient get client {
|
|
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
final serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
|
if (serverId == null) {
|
|
throw Exception('No servers available');
|
|
}
|
|
return context.getClientForServer(serverId);
|
|
}
|
|
|
|
List<PlexMetadata> _onDeck = [];
|
|
List<PlexHub> _hubs = [];
|
|
bool _isLoading = true;
|
|
bool _areHubsLoading = true;
|
|
String? _errorMessage;
|
|
final PageController _heroController = PageController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
int _currentHeroIndex = 0;
|
|
Timer? _autoScrollTimer;
|
|
Timer? _indicatorTimer;
|
|
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
|
bool _isAutoScrollPaused = false;
|
|
HiddenLibrariesProvider? _hiddenLibrariesProvider;
|
|
|
|
// WatchStateAware: watch on-deck items and their parent shows/seasons
|
|
@override
|
|
Set<String>? get watchedRatingKeys {
|
|
final keys = <String>{};
|
|
for (final item in _onDeck) {
|
|
keys.add(item.ratingKey);
|
|
if (item.parentRatingKey != null) {
|
|
keys.add(item.parentRatingKey!);
|
|
}
|
|
if (item.grandparentRatingKey != null) {
|
|
keys.add(item.grandparentRatingKey!);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
@override
|
|
Set<String>? get watchedGlobalKeys {
|
|
final keys = <String>{};
|
|
for (final item in _onDeck) {
|
|
final serverId = item.serverId;
|
|
if (serverId == null) return null;
|
|
|
|
keys.add(buildGlobalKey(serverId, item.ratingKey));
|
|
if (item.parentRatingKey != null) {
|
|
keys.add(buildGlobalKey(serverId, item.parentRatingKey!));
|
|
}
|
|
if (item.grandparentRatingKey != null) {
|
|
keys.add(buildGlobalKey(serverId, item.grandparentRatingKey!));
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
@override
|
|
void onWatchStateChanged(WatchStateEvent event) {
|
|
// Refresh continue watching when any relevant item changes
|
|
_refreshContinueWatching();
|
|
}
|
|
|
|
// Track initial load so we can focus hero when content first appears
|
|
bool _initialLoadComplete = false;
|
|
|
|
// Hub navigation keys
|
|
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
|
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
|
|
|
// Hero and app bar focus
|
|
late FocusNode _heroFocusNode;
|
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
|
|
|
/// Get the correct PlexClient for an item's server
|
|
PlexClient _getClientForItem(PlexMetadata? item) {
|
|
// Items should always have a serverId, but if not, fall back to first available server
|
|
final serverId = item?.serverId;
|
|
if (serverId == null) {
|
|
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
final fallbackId = multiServerProvider.onlineServerIds.firstOrNull;
|
|
if (fallbackId == null) {
|
|
throw Exception('No servers available');
|
|
}
|
|
return context.getClientForServer(fallbackId);
|
|
}
|
|
return context.getClientForServer(serverId);
|
|
}
|
|
|
|
/// Update hub keys when hubs list changes — reuse existing keys to avoid
|
|
/// mass deep unmounts (ARM32 stack overflow during finalizeTree).
|
|
void _updateHubKeys() {
|
|
while (_hubKeys.length < _hubs.length) {
|
|
_hubKeys.add(GlobalKey<HubSectionState>());
|
|
}
|
|
if (_hubKeys.length > _hubs.length) {
|
|
_hubKeys.removeRange(_hubs.length, _hubKeys.length);
|
|
}
|
|
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
|
}
|
|
|
|
/// Get all hub states (continue watching + other hubs)
|
|
List<GlobalKey<HubSectionState>> get _allHubKeys {
|
|
final keys = <GlobalKey<HubSectionState>>[];
|
|
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
|
|
keys.add(_continueWatchingHubKey!);
|
|
}
|
|
keys.addAll(_hubKeys);
|
|
return keys;
|
|
}
|
|
|
|
bool get _isHeroSectionVisible => _onDeck.isNotEmpty && context.read<SettingsProvider>().showHeroSection;
|
|
|
|
void _scrollToTop() {
|
|
if (!_scrollController.hasClients) return;
|
|
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
|
}
|
|
|
|
void _focusTopBoundary() {
|
|
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
|
if (_isHeroSectionVisible) {
|
|
_heroFocusNode.requestFocus();
|
|
} else {
|
|
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
|
|
}
|
|
_scrollToTop();
|
|
}
|
|
|
|
void _focusContentFromAppBar() {
|
|
if (_isHeroSectionVisible) {
|
|
_heroFocusNode.requestFocus();
|
|
return;
|
|
}
|
|
|
|
final keys = _allHubKeys;
|
|
if (keys.isNotEmpty) {
|
|
keys.first.currentState?.requestFocusFromMemory();
|
|
}
|
|
}
|
|
|
|
/// Handle vertical navigation between hubs
|
|
/// Returns true if the navigation was handled
|
|
bool _handleVerticalNavigation(int hubIndex, bool isUp) {
|
|
final keys = _allHubKeys;
|
|
if (keys.isEmpty) return false;
|
|
|
|
// UP from first hub: navigate to hero when visible, otherwise app bar
|
|
if (isUp && hubIndex == 0) {
|
|
_focusTopBoundary();
|
|
return true;
|
|
}
|
|
|
|
final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1;
|
|
|
|
// Check if target is valid
|
|
if (targetIndex < 0 || targetIndex >= keys.length) {
|
|
// At boundary, block navigation (return true to consume the event)
|
|
return true;
|
|
}
|
|
|
|
// Navigate to target hub, clamping to available items
|
|
final targetState = keys[targetIndex].currentState;
|
|
if (targetState != null) {
|
|
targetState.requestFocusFromMemory();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// Navigate focus to the sidebar
|
|
void _navigateToSidebar() {
|
|
MainScreenFocusScope.of(context)?.focusSidebar();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
|
|
_loadContent();
|
|
_startAutoScroll();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final provider = context.read<HiddenLibrariesProvider>();
|
|
if (provider != _hiddenLibrariesProvider) {
|
|
_hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged);
|
|
_hiddenLibrariesProvider = provider;
|
|
_hiddenLibrariesProvider!.addListener(_onHiddenLibrariesChanged);
|
|
}
|
|
}
|
|
|
|
void _onHiddenLibrariesChanged() {
|
|
_loadContent();
|
|
}
|
|
|
|
/// Handle key events for the hero section
|
|
late final _handleHeroKeyEvent = dpadKeyHandler(
|
|
onDown: () {
|
|
final keys = _allHubKeys;
|
|
if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory();
|
|
},
|
|
onUp: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(),
|
|
onLeft: () {
|
|
if (_currentHeroIndex > 0) {
|
|
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
|
} else {
|
|
_navigateToSidebar();
|
|
}
|
|
},
|
|
onRight: () {
|
|
if (_currentHeroIndex < _onDeck.length - 1) {
|
|
_heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
|
}
|
|
},
|
|
onSelect: () {
|
|
if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) {
|
|
navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]);
|
|
}
|
|
},
|
|
);
|
|
|
|
@override
|
|
void dispose() {
|
|
_hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_autoScrollTimer?.cancel();
|
|
_indicatorTimer?.cancel();
|
|
_indicatorProgress.dispose();
|
|
_heroController.dispose();
|
|
_scrollController.dispose();
|
|
_heroFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
// Restart auto-scroll when app becomes active again
|
|
if (!_isAutoScrollPaused) _startAutoScroll();
|
|
// Refresh continue watching on mobile only
|
|
// (on desktop, "resumed" fires on every window focus gain)
|
|
if (Platform.isIOS || Platform.isAndroid) {
|
|
_refreshContinueWatching();
|
|
}
|
|
} else if (state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.hidden) {
|
|
// Stop animations to prevent scroll state corruption while backgrounded
|
|
_autoScrollTimer?.cancel();
|
|
_stopIndicatorProgress();
|
|
}
|
|
}
|
|
|
|
void _startAutoScroll() {
|
|
_autoScrollTimer?.cancel();
|
|
if (_isAutoScrollPaused) return;
|
|
|
|
_startIndicatorProgress();
|
|
_autoScrollTimer = Timer.periodic(_heroAutoScrollDuration, (timer) {
|
|
if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused) {
|
|
return;
|
|
}
|
|
|
|
// Validate current index is within bounds before calculating next page
|
|
if (_currentHeroIndex >= _onDeck.length) {
|
|
_currentHeroIndex = 0;
|
|
}
|
|
|
|
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
|
|
_heroController.animateToPage(nextPage, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut);
|
|
// Wait for page transition to complete before resetting progress
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (!_isAutoScrollPaused) {
|
|
_startIndicatorProgress();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
void _startIndicatorProgress() {
|
|
_indicatorTimer?.cancel();
|
|
_indicatorProgress.value = 0.0;
|
|
final totalSteps = _heroAutoScrollDuration.inMilliseconds ~/ _indicatorUpdateInterval.inMilliseconds;
|
|
int step = 0;
|
|
_indicatorTimer = Timer.periodic(_indicatorUpdateInterval, (timer) {
|
|
step++;
|
|
_indicatorProgress.value = (step / totalSteps).clamp(0.0, 1.0);
|
|
if (step >= totalSteps) {
|
|
timer.cancel();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _stopIndicatorProgress() {
|
|
_indicatorTimer?.cancel();
|
|
}
|
|
|
|
void _resetAutoScrollTimer() {
|
|
_autoScrollTimer?.cancel();
|
|
_startAutoScroll();
|
|
}
|
|
|
|
void _pauseAutoScroll() {
|
|
setState(() {
|
|
_isAutoScrollPaused = true;
|
|
});
|
|
_autoScrollTimer?.cancel();
|
|
_stopIndicatorProgress();
|
|
}
|
|
|
|
void _resumeAutoScroll() {
|
|
setState(() {
|
|
_isAutoScrollPaused = false;
|
|
});
|
|
_startAutoScroll();
|
|
}
|
|
|
|
@override
|
|
void onTabHidden() {
|
|
_autoScrollTimer?.cancel();
|
|
_stopIndicatorProgress();
|
|
}
|
|
|
|
@override
|
|
void onTabShown({bool scrollToTop = true}) {
|
|
if (!_isAutoScrollPaused) {
|
|
_startAutoScroll();
|
|
}
|
|
if (scrollToTop) {
|
|
_focusTopBoundary();
|
|
}
|
|
}
|
|
|
|
// Helper method to calculate visible dot range (max 5 dots)
|
|
({int start, int end}) _getVisibleDotRange() {
|
|
final totalDots = _onDeck.length;
|
|
if (totalDots <= 5) {
|
|
return (start: 0, end: totalDots - 1);
|
|
}
|
|
|
|
// Center the active dot when possible
|
|
final center = _currentHeroIndex;
|
|
int start = (center - 2).clamp(0, totalDots - 5);
|
|
int end = start + 4; // 5 dots total (0-4 inclusive)
|
|
|
|
return (start: start, end: end);
|
|
}
|
|
|
|
// Helper method to determine dot size based on position
|
|
double _getDotSize(int dotIndex, int start, int end) {
|
|
final totalDots = _onDeck.length;
|
|
|
|
// If we have 5 or fewer dots, all are full size (8px)
|
|
if (totalDots <= 5) {
|
|
return 8.0;
|
|
}
|
|
|
|
// First and last visible dots are smaller if there are more items beyond them
|
|
final isFirstVisible = dotIndex == start && start > 0;
|
|
final isLastVisible = dotIndex == end && end < totalDots - 1;
|
|
|
|
if (isFirstVisible || isLastVisible) {
|
|
return 5.0; // Smaller edge dots
|
|
}
|
|
|
|
return 8.0; // Normal size
|
|
}
|
|
|
|
Future<void> _loadContent() async {
|
|
appLogger.d('Loading discover content from all servers');
|
|
setState(() {
|
|
_isLoading = true;
|
|
_areHubsLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
appLogger.d('Fetching onDeck and global hubs from all Plex servers');
|
|
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
|
|
if (!multiServerProvider.hasConnectedServers) {
|
|
throw Exception('No servers available');
|
|
}
|
|
|
|
// Get hidden libraries for filtering
|
|
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
|
|
|
// Get settings for hub mode preference (ensure initialized before accessing)
|
|
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
|
|
await settingsProvider.ensureInitialized();
|
|
|
|
// Start OnDeck and hubs fetch in parallel
|
|
final onDeckFuture = multiServerProvider.aggregationService.getOnDeckFromAllServers(
|
|
limit: 20,
|
|
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
|
);
|
|
final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers(
|
|
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
|
useGlobalHubs: settingsProvider.useGlobalHubs,
|
|
);
|
|
|
|
// Wait for OnDeck to complete and show it immediately
|
|
final onDeck = await onDeckFuture;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_onDeck = onDeck;
|
|
_isLoading = false; // Show content, but hubs still loading
|
|
|
|
// Reset hero index to avoid sync issues
|
|
_currentHeroIndex = 0;
|
|
|
|
// Create continue watching hub key if needed
|
|
if (_onDeck.isNotEmpty) {
|
|
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
|
}
|
|
});
|
|
|
|
// Focus hero section now that it's visible, but only if no modal route is on top
|
|
if (onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
|
_heroFocusNode.requestFocus();
|
|
}
|
|
|
|
// Sync to Android TV Watch Next row
|
|
if (Platform.isAndroid) {
|
|
_syncWatchNext(onDeck);
|
|
}
|
|
|
|
// Sync PageController to first page after OnDeck loads
|
|
if (_heroController.hasClients && onDeck.isNotEmpty) {
|
|
_heroController.jumpToPage(0);
|
|
}
|
|
|
|
// On initial load, focus the hero so the user starts on content (not the toolbar)
|
|
if (!_initialLoadComplete && onDeck.isNotEmpty) {
|
|
_initialLoadComplete = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _heroFocusNode.canRequestFocus && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
|
_heroFocusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Wait for global hubs
|
|
final allHubs = await hubsFuture;
|
|
|
|
if (!mounted) return;
|
|
|
|
// Filter out Continue Watching / On Deck hubs (handled separately in hero section)
|
|
final filteredHubs = allHubs.where((hub) {
|
|
final hubId = hub.hubIdentifier?.toLowerCase() ?? '';
|
|
final title = hub.title.toLowerCase();
|
|
return !hubId.contains('ondeck') &&
|
|
!hubId.contains('continue') &&
|
|
!title.contains('continue watching') &&
|
|
!title.contains('on deck');
|
|
}).toList();
|
|
|
|
appLogger.d('Received ${onDeck.length} on deck items and ${filteredHubs.length} global hubs from all servers');
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_hubs = filteredHubs;
|
|
_areHubsLoading = false;
|
|
_updateHubKeys();
|
|
});
|
|
|
|
appLogger.d('Discover content loaded successfully');
|
|
} catch (e) {
|
|
appLogger.e('Failed to load discover content', error: e);
|
|
setState(() {
|
|
_errorMessage = 'Failed to load content: $e';
|
|
_isLoading = false;
|
|
_areHubsLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Refresh only the Continue Watching section in the background
|
|
/// This is called when returning to the home screen to avoid blocking UI
|
|
Future<void> _refreshContinueWatching() async {
|
|
appLogger.d('Refreshing Continue Watching in background from all servers');
|
|
|
|
try {
|
|
final multiServerProvider = context.read<MultiServerProvider>();
|
|
if (!multiServerProvider.hasConnectedServers) {
|
|
appLogger.w('No servers available for background refresh');
|
|
return;
|
|
}
|
|
|
|
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
|
final onDeck = await multiServerProvider.aggregationService.getOnDeckFromAllServers(
|
|
limit: 20,
|
|
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_onDeck = onDeck;
|
|
// Reset hero index if needed
|
|
if (_currentHeroIndex >= onDeck.length) {
|
|
_currentHeroIndex = 0;
|
|
if (_heroController.hasClients && onDeck.isNotEmpty) {
|
|
_heroController.jumpToPage(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sync to Android TV Watch Next row
|
|
if (Platform.isAndroid) {
|
|
_syncWatchNext(onDeck);
|
|
}
|
|
|
|
appLogger.d('Continue Watching refreshed successfully');
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to refresh Continue Watching', error: e);
|
|
// Silently fail - don't show error to user for background refresh
|
|
}
|
|
}
|
|
|
|
/// Sync On Deck items to Android TV Watch Next row
|
|
Future<void> _syncWatchNext(List<PlexMetadata> onDeck) async {
|
|
try {
|
|
await WatchNextService().syncFromOnDeck(onDeck, (serverId) => context.getClientForServer(serverId));
|
|
} catch (e) {
|
|
appLogger.w('Failed to sync Watch Next', error: e);
|
|
}
|
|
}
|
|
|
|
// Public method to refresh content (for normal navigation)
|
|
@override
|
|
void refresh() {
|
|
appLogger.d('DiscoverScreen.refresh() called');
|
|
// Only refresh Continue Watching in background, not full screen reload
|
|
_refreshContinueWatching();
|
|
}
|
|
|
|
// Public method to fully reload all content (for profile switches)
|
|
@override
|
|
void fullRefresh() {
|
|
appLogger.d('DiscoverScreen.fullRefresh() called - reloading all content');
|
|
// Reload all content including On Deck and content hubs
|
|
_loadContent();
|
|
}
|
|
|
|
/// Get icon for hub based on its title
|
|
IconData _getHubIcon(String title) {
|
|
final lowerTitle = title.toLowerCase();
|
|
|
|
// Trending/Popular content
|
|
if (lowerTitle.contains('trending')) {
|
|
return Symbols.trending_up_rounded;
|
|
}
|
|
if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) {
|
|
return Symbols.whatshot_rounded;
|
|
}
|
|
|
|
// Seasonal/Time-based
|
|
if (lowerTitle.contains('seasonal')) {
|
|
return Symbols.calendar_month_rounded;
|
|
}
|
|
if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) {
|
|
return Symbols.new_releases_rounded;
|
|
}
|
|
if (lowerTitle.contains('recently released') || lowerTitle.contains('recent')) {
|
|
return Symbols.schedule_rounded;
|
|
}
|
|
|
|
// Top/Rated content
|
|
if (lowerTitle.contains('top rated') || lowerTitle.contains('highest rated')) {
|
|
return Symbols.star_rounded;
|
|
}
|
|
if (lowerTitle.contains('top ')) {
|
|
return Symbols.military_tech_rounded;
|
|
}
|
|
|
|
// Genre-specific
|
|
if (lowerTitle.contains('thriller')) {
|
|
return Symbols.warning_amber_rounded;
|
|
}
|
|
if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) {
|
|
return Symbols.mood_rounded;
|
|
}
|
|
if (lowerTitle.contains('action')) {
|
|
return Symbols.flash_on_rounded;
|
|
}
|
|
if (lowerTitle.contains('drama')) {
|
|
return Symbols.theater_comedy_rounded;
|
|
}
|
|
if (lowerTitle.contains('fantasy')) {
|
|
return Symbols.auto_fix_high_rounded;
|
|
}
|
|
if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) {
|
|
return Symbols.rocket_launch_rounded;
|
|
}
|
|
if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) {
|
|
return Symbols.nights_stay_rounded;
|
|
}
|
|
if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) {
|
|
return Symbols.favorite_border_rounded;
|
|
}
|
|
if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) {
|
|
return Symbols.explore_rounded;
|
|
}
|
|
|
|
// Watchlist/Playlists
|
|
if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) {
|
|
return Symbols.playlist_play_rounded;
|
|
}
|
|
if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) {
|
|
return Symbols.visibility_off_rounded;
|
|
}
|
|
if (lowerTitle.contains('watched') || lowerTitle.contains('played')) {
|
|
return Symbols.visibility_rounded;
|
|
}
|
|
|
|
// Network/Studio
|
|
if (lowerTitle.contains('network') || lowerTitle.contains('more from')) {
|
|
return Symbols.tv_rounded;
|
|
}
|
|
|
|
// Actor/Director
|
|
if (lowerTitle.contains('actor') || lowerTitle.contains('director')) {
|
|
return Symbols.person_rounded;
|
|
}
|
|
|
|
// Year-based (80s, 90s, etc.)
|
|
if (lowerTitle.contains('80') || lowerTitle.contains('90') || lowerTitle.contains('00')) {
|
|
return Symbols.history_rounded;
|
|
}
|
|
|
|
// Rediscover/Start Watching
|
|
if (lowerTitle.contains('rediscover') || lowerTitle.contains('start watching')) {
|
|
return Symbols.play_arrow_rounded;
|
|
}
|
|
|
|
// Default icon for other hubs
|
|
return Symbols.auto_awesome_rounded;
|
|
}
|
|
|
|
/// Get the set of hub titles that appear more than once (duplicates)
|
|
Set<String> _getDuplicateHubTitles() {
|
|
final titleCounts = <String, int>{};
|
|
for (final hub in _hubs) {
|
|
titleCounts[hub.title] = (titleCounts[hub.title] ?? 0) + 1;
|
|
}
|
|
return titleCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
|
|
}
|
|
|
|
@override
|
|
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
|
// Check and update in _onDeck list
|
|
final onDeckIndex = _onDeck.indexWhere((item) => item.ratingKey == ratingKey);
|
|
if (onDeckIndex != -1) {
|
|
_onDeck[onDeckIndex] = updatedMetadata;
|
|
}
|
|
|
|
// Check and update in hub items
|
|
for (final hub in _hubs) {
|
|
final itemIndex = hub.items.indexWhere((item) => item.ratingKey == ratingKey);
|
|
if (itemIndex != -1) {
|
|
hub.items[itemIndex] = updatedMetadata;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _handleLogout() async {
|
|
final confirm = await showConfirmDialog(
|
|
context,
|
|
title: t.common.logout,
|
|
message: t.messages.logoutConfirm,
|
|
confirmText: t.common.logout,
|
|
isDestructive: true,
|
|
);
|
|
|
|
if (confirm && mounted) {
|
|
// Use comprehensive logout through UserProfileProvider
|
|
final userProfileProvider = Provider.of<UserProfileProvider>(context, listen: false);
|
|
final multiServerProvider = context.read<MultiServerProvider>();
|
|
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
|
final playbackStateProvider = context.read<PlaybackStateProvider>();
|
|
|
|
// Clear all user data and provider states
|
|
await userProfileProvider.logout();
|
|
multiServerProvider.clearAllConnections();
|
|
await hiddenLibrariesProvider.refresh();
|
|
playbackStateProvider.clearShuffle();
|
|
|
|
if (mounted) {
|
|
Navigator.of(
|
|
context,
|
|
).pushAndRemoveUntil(MaterialPageRoute(builder: (context) => const AuthScreen()), (route) => false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleSwitchProfile(BuildContext context) {
|
|
Navigator.push(context, MaterialPageRoute(builder: (context) => const ProfileSwitchScreen()));
|
|
}
|
|
|
|
/// Show user menu programmatically (for D-pad select)
|
|
void _showUserMenu(BuildContext context, UserProfileProvider userProvider) {
|
|
final actionBar = _actionBarKey.currentState;
|
|
if (actionBar == null) return;
|
|
final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1);
|
|
final RenderBox? button = lastNode.context?.findRenderObject() as RenderBox?;
|
|
if (button == null) return;
|
|
|
|
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
|
|
final position = RelativeRect.fromRect(
|
|
Rect.fromPoints(
|
|
button.localToGlobal(Offset.zero, ancestor: overlay),
|
|
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
|
|
),
|
|
Offset.zero & overlay.size,
|
|
);
|
|
|
|
showMenu<String>(
|
|
context: context,
|
|
position: position,
|
|
items: [
|
|
if (userProvider.hasMultipleUsers)
|
|
PopupMenuItem(
|
|
value: 'switch_profile',
|
|
child: Row(
|
|
children: [
|
|
AppIcon(Symbols.people_rounded, fill: 1),
|
|
const SizedBox(width: 8),
|
|
Text(t.discover.switchProfile),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'logout',
|
|
child: Row(
|
|
children: [AppIcon(Symbols.logout_rounded, fill: 1), const SizedBox(width: 8), Text(t.common.logout)],
|
|
),
|
|
),
|
|
],
|
|
).then((value) {
|
|
if (!context.mounted) return;
|
|
if (value == 'switch_profile') {
|
|
_handleSwitchProfile(context);
|
|
} else if (value == 'logout') {
|
|
_handleLogout();
|
|
}
|
|
});
|
|
}
|
|
|
|
Widget _buildOverlaidAppBar() {
|
|
final statusBarHeight = MediaQuery.of(context).padding.top;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withValues(alpha: 0.7),
|
|
Colors.black.withValues(alpha: 0.5),
|
|
Colors.black.withValues(alpha: 0.3),
|
|
Colors.transparent,
|
|
],
|
|
stops: const [0.0, 0.3, 0.6, 1.0],
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.only(top: statusBarHeight, left: 16, right: 16, bottom: 8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
t.discover.title,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
|
|
),
|
|
const Spacer(),
|
|
Consumer2<WatchTogetherProvider, CompanionRemoteProvider>(
|
|
builder: (context, watchTogether, companionRemote, _) {
|
|
final isDesktop = PlatformDetector.isDesktop(context);
|
|
final userProvider = context.watch<UserProfileProvider>();
|
|
|
|
return FocusableActionBar(
|
|
key: _actionBarKey,
|
|
onNavigateLeft: _navigateToSidebar,
|
|
onNavigateDown: _focusContentFromAppBar,
|
|
actions: [
|
|
FocusableAction(icon: Symbols.refresh_rounded, iconColor: Colors.white, onPressed: _loadContent),
|
|
// Watch Together
|
|
FocusableAction(
|
|
onPressed: () =>
|
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
|
|
child: Stack(
|
|
children: [
|
|
IconButton(
|
|
icon: AppIcon(
|
|
Symbols.group_rounded,
|
|
fill: watchTogether.isInSession ? 1 : 0,
|
|
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white,
|
|
),
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
|
|
),
|
|
tooltip: 'Watch Together',
|
|
),
|
|
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: const BorderRadius.all(Radius.circular(8)),
|
|
),
|
|
child: Text(
|
|
'${watchTogether.participantCount}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onPrimary,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Companion Remote
|
|
FocusableAction(
|
|
onPressed: () {
|
|
if (isDesktop) {
|
|
RemoteSessionDialog.show(context);
|
|
} else {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
|
|
);
|
|
}
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
IconButton(
|
|
icon: AppIcon(
|
|
Symbols.phone_android_rounded,
|
|
fill: companionRemote.isConnected ? 1 : 0,
|
|
color: companionRemote.isConnected
|
|
? Theme.of(context).colorScheme.primary
|
|
: Colors.white,
|
|
),
|
|
onPressed: () {
|
|
if (isDesktop) {
|
|
RemoteSessionDialog.show(context);
|
|
} else {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
|
|
);
|
|
}
|
|
},
|
|
tooltip: t.companionRemote.title,
|
|
),
|
|
if (companionRemote.isConnected)
|
|
Positioned(
|
|
top: 6,
|
|
right: 6,
|
|
child: Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.green,
|
|
shape: BoxShape.circle,
|
|
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// User menu
|
|
FocusableAction(
|
|
onPressed: () => _showUserMenu(context, userProvider),
|
|
child: PopupMenuButton<String>(
|
|
icon: userProvider.currentUser?.thumb != null
|
|
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
|
|
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
|
|
onSelected: (value) {
|
|
if (value == 'switch_profile') {
|
|
_handleSwitchProfile(context);
|
|
} else if (value == 'logout') {
|
|
_handleLogout();
|
|
}
|
|
},
|
|
itemBuilder: (context) => [
|
|
if (userProvider.hasMultipleUsers)
|
|
PopupMenuItem(
|
|
value: 'switch_profile',
|
|
child: Row(
|
|
children: [
|
|
AppIcon(Symbols.people_rounded, fill: 1),
|
|
const SizedBox(width: 8),
|
|
Text(t.discover.switchProfile),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'logout',
|
|
child: Row(
|
|
children: [
|
|
AppIcon(Symbols.logout_rounded, fill: 1),
|
|
const SizedBox(width: 8),
|
|
Text(t.common.logout),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Get settings for server name display
|
|
final showServerNameOnHubs = context.watch<SettingsProvider>().showServerNameOnHubs;
|
|
final duplicateHubTitles = _getDuplicateHubTitles();
|
|
|
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
return Material(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
child: Stack(
|
|
children: [
|
|
CustomScrollView(
|
|
controller: _scrollController,
|
|
slivers: [
|
|
// Hero Section (Continue Watching) - at top of screen
|
|
Consumer<SettingsProvider>(
|
|
builder: (context, settingsProvider, child) {
|
|
if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) {
|
|
return _buildHeroSection();
|
|
}
|
|
// Add top padding when hero is not shown
|
|
return SliverToBoxAdapter(
|
|
child: SizedBox(height: kToolbarHeight + MediaQuery.of(context).padding.top + 16),
|
|
);
|
|
},
|
|
),
|
|
if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())),
|
|
if (_errorMessage != null)
|
|
SliverFillRemaining(
|
|
child: ErrorStateWidget(
|
|
message: _errorMessage!,
|
|
icon: Symbols.error_outline_rounded,
|
|
onRetry: _loadContent,
|
|
),
|
|
),
|
|
if (!_isLoading && _errorMessage == null) ...[
|
|
// On Deck / Continue Watching
|
|
if (_onDeck.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: HubSection(
|
|
key: _continueWatchingHubKey,
|
|
hub: PlexHub(
|
|
hubKey: 'continue_watching',
|
|
title: t.discover.continueWatching,
|
|
type: 'mixed',
|
|
hubIdentifier: '_continue_watching_',
|
|
size: _onDeck.length,
|
|
more: false,
|
|
items: _onDeck,
|
|
),
|
|
icon: Symbols.play_circle_rounded,
|
|
onRefresh: updateItem,
|
|
onRemoveFromContinueWatching: _refreshContinueWatching,
|
|
isInContinueWatching: true,
|
|
onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp),
|
|
onNavigateUp: _focusTopBoundary,
|
|
onNavigateToSidebar: _navigateToSidebar,
|
|
),
|
|
),
|
|
|
|
// Recommendation Hubs (Trending, Top in Genre, etc.)
|
|
for (int i = 0; i < _hubs.length; i++)
|
|
SliverToBoxAdapter(
|
|
child: HubSection(
|
|
key: i < _hubKeys.length ? _hubKeys[i] : null,
|
|
hub: _hubs[i],
|
|
icon: _getHubIcon(_hubs[i].title),
|
|
showServerName: showServerNameOnHubs || duplicateHubTitles.contains(_hubs[i].title),
|
|
onRefresh: updateItem,
|
|
// Hub index is i + 1 if continue watching exists, otherwise i
|
|
onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp),
|
|
onNavigateUp: (i == 0 && _onDeck.isEmpty) ? _focusTopBoundary : null,
|
|
onNavigateToSidebar: _navigateToSidebar,
|
|
),
|
|
),
|
|
|
|
// Show loading skeleton for hubs while they're loading
|
|
if (_areHubsLoading && _hubs.isEmpty)
|
|
for (int i = 0; i < 3; i++)
|
|
SliverToBoxAdapter(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Hub title skeleton
|
|
Container(
|
|
width: 200,
|
|
height: 24,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Hub items skeleton
|
|
SizedBox(
|
|
height: 200,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: 5,
|
|
itemBuilder: (context, index) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(right: 12),
|
|
width: 140,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading)
|
|
SliverFillRemaining(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
|
|
const SizedBox(height: 16),
|
|
Text(t.discover.noContentAvailable),
|
|
const SizedBox(height: 8),
|
|
Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(child: SizedBox(height: 24 + bottomPadding)),
|
|
],
|
|
],
|
|
),
|
|
// Overlaid app bar — excluded from default focus traversal so that
|
|
// initial/tab-switch focus lands on content (hero/hubs), not the toolbar.
|
|
// Toolbar buttons are still reachable via explicit UP from hero section.
|
|
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeroSection() {
|
|
final statusBarHeight = MediaQuery.of(context).padding.top;
|
|
final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
|
|
final heroHeight = useSideNav ? MediaQuery.of(context).size.height * 0.75 : 500 + statusBarHeight;
|
|
return SliverToBoxAdapter(
|
|
child: Focus(
|
|
focusNode: _heroFocusNode,
|
|
onKeyEvent: _handleHeroKeyEvent,
|
|
child: SizedBox(
|
|
height: heroHeight,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
PageView.builder(
|
|
controller: _heroController,
|
|
itemCount: _onDeck.length,
|
|
onPageChanged: (index) {
|
|
// Validate index is within bounds before updating
|
|
if (index >= 0 && index < _onDeck.length) {
|
|
setState(() {
|
|
_currentHeroIndex = index;
|
|
});
|
|
_resetAutoScrollTimer();
|
|
}
|
|
},
|
|
itemBuilder: (context, index) {
|
|
return _buildHeroItem(_onDeck[index], heroHeight);
|
|
},
|
|
),
|
|
// Page indicators with animated progress and pause/play button
|
|
if (!InputModeTracker.isKeyboardMode(context))
|
|
Positioned(
|
|
bottom: 16,
|
|
left: -26,
|
|
right: 0,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// Pause/Play button
|
|
GestureDetector(
|
|
onTap: () {
|
|
if (_isAutoScrollPaused) {
|
|
_resumeAutoScroll();
|
|
} else {
|
|
_pauseAutoScroll();
|
|
}
|
|
},
|
|
child: AppIcon(
|
|
_isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded,
|
|
fill: 1,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
size: 18,
|
|
semanticLabel: '${_isAutoScrollPaused ? t.common.play : t.common.pause} auto-scroll',
|
|
),
|
|
),
|
|
// Spacer to separate indicators from button
|
|
const SizedBox(width: 8),
|
|
// Page indicators (limited to 5 dots)
|
|
...() {
|
|
final range = _getVisibleDotRange();
|
|
return List.generate(range.end - range.start + 1, (i) {
|
|
final index = range.start + i;
|
|
final isActive = _currentHeroIndex == index;
|
|
final dotSize = _getDotSize(index, range.start, range.end);
|
|
|
|
return isActive
|
|
// Progress indicator for active page (~5fps via Timer)
|
|
? ValueListenableBuilder<double>(
|
|
valueListenable: _indicatorProgress,
|
|
builder: (context, progress, child) {
|
|
final maxWidth = dotSize * 3; // 24px for normal, 15px for small
|
|
final fillWidth = dotSize + ((maxWidth - dotSize) * progress);
|
|
final onSurface = Theme.of(context).colorScheme.onSurface;
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
width: maxWidth,
|
|
height: dotSize,
|
|
decoration: BoxDecoration(
|
|
color: onSurface.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(dotSize / 2),
|
|
),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Container(
|
|
width: fillWidth,
|
|
height: dotSize,
|
|
decoration: BoxDecoration(
|
|
color: onSurface,
|
|
borderRadius: BorderRadius.circular(dotSize / 2),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
)
|
|
// Static indicator for inactive pages
|
|
: AnimatedContainer(
|
|
duration: tokens(context).slow,
|
|
curve: Curves.easeInOut,
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
width: dotSize,
|
|
height: dotSize,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(dotSize / 2),
|
|
),
|
|
);
|
|
});
|
|
}(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) {
|
|
final isEpisode = heroItem.isEpisode;
|
|
final showName = heroItem.grandparentTitle ?? heroItem.displayTitle;
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
|
|
|
|
// Determine content type label for chip
|
|
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
|
|
|
|
// Spoiler protection
|
|
final hideSpoilers = context.watch<SettingsProvider>().hideSpoilers;
|
|
final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler;
|
|
|
|
// Build semantic label for hero item
|
|
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
|
|
|
|
return Semantics(
|
|
label: heroLabel,
|
|
button: true,
|
|
hint: t.accessibility.tapToPlay,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
|
|
navigateToVideoPlayer(context, metadata: heroItem);
|
|
},
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Background Image with fade/zoom animation and parallax
|
|
if (heroItem.art != null || heroItem.backgroundSquare != null || heroItem.grandparentArt != null)
|
|
ClipRect(
|
|
child: AnimatedBuilder(
|
|
animation: _scrollController,
|
|
builder: (context, child) {
|
|
final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
|
|
return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child);
|
|
},
|
|
child: TweenAnimationBuilder<double>(
|
|
tween: Tween(begin: 0.0, end: 1.0),
|
|
duration: const Duration(milliseconds: 800),
|
|
curve: Curves.easeOut,
|
|
builder: (context, value, child) {
|
|
return Transform.scale(
|
|
scale: 1.0 + (0.1 * (1 - value)),
|
|
child: Opacity(opacity: value, child: child),
|
|
);
|
|
},
|
|
child: Builder(
|
|
builder: (context) {
|
|
final client = _getClientForItem(heroItem);
|
|
final mediaQuery = MediaQuery.of(context);
|
|
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
|
|
final containerAspect = screenWidth / heroHeight;
|
|
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
|
|
client: client,
|
|
thumbPath: heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArt,
|
|
maxWidth: mediaQuery.size.width,
|
|
maxHeight: mediaQuery.size.height * 0.7,
|
|
devicePixelRatio: dpr,
|
|
imageType: ImageType.art,
|
|
);
|
|
|
|
return blurArtwork(
|
|
CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
fit: BoxFit.cover,
|
|
placeholder: (context, url) =>
|
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
|
errorWidget: (context, url, error) =>
|
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
|
|
|
// Gradient Overlay - blends into scaffold background
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: -4, // Extend past stack bounds to ensure coverage
|
|
child: IgnorePointer(
|
|
child: Builder(
|
|
builder: (context) {
|
|
final bgColor = Theme.of(context).scaffoldBackgroundColor;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor],
|
|
stops: const [0.5, 0.85, 1.0],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
// Content with responsive alignment
|
|
Positioned(
|
|
bottom: isLargeScreen ? 80 : 50,
|
|
left: 0,
|
|
right: isLargeScreen ? 200 : 0,
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: isLargeScreen ? 40 : 24),
|
|
child: Column(
|
|
crossAxisAlignment: isLargeScreen ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Show logo or name/title
|
|
if (heroItem.clearLogo != null)
|
|
SizedBox(
|
|
height: 120,
|
|
width: 400,
|
|
child: Builder(
|
|
builder: (context) {
|
|
final client = _getClientForItem(heroItem);
|
|
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
|
|
final logoUrl = PlexImageHelper.getOptimizedImageUrl(
|
|
client: client,
|
|
thumbPath: heroItem.clearLogo,
|
|
maxWidth: 400,
|
|
maxHeight: 120,
|
|
devicePixelRatio: dpr,
|
|
imageType: ImageType.logo,
|
|
);
|
|
|
|
return blurArtwork(
|
|
CachedNetworkImage(
|
|
imageUrl: logoUrl,
|
|
filterQuality: FilterQuality.medium,
|
|
fit: BoxFit.contain,
|
|
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
|
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
|
|
placeholder: (context, url) => Align(
|
|
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
|
child: Text(
|
|
showName,
|
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
|
fontWeight: FontWeight.bold,
|
|
shadows: [
|
|
Shadow(
|
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8),
|
|
blurRadius: 8,
|
|
),
|
|
],
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
),
|
|
),
|
|
errorWidget: (context, url, error) {
|
|
// Fallback to text if logo fails to load
|
|
return Align(
|
|
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
|
child: Text(
|
|
showName,
|
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
shadows: [
|
|
Shadow(
|
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8),
|
|
blurRadius: 8,
|
|
),
|
|
],
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
sigma: 10,
|
|
clip: false,
|
|
);
|
|
},
|
|
),
|
|
)
|
|
else
|
|
Text(
|
|
showName,
|
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
shadows: [
|
|
Shadow(color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8),
|
|
],
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
),
|
|
|
|
// Metadata as dot-separated text with content type
|
|
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
[
|
|
contentTypeLabel,
|
|
if (heroItem.rating != null) '★ ${heroItem.rating!.toStringAsFixed(1)}',
|
|
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
|
|
if (heroItem.year != null) heroItem.year.toString(),
|
|
].join(' • '),
|
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
),
|
|
],
|
|
|
|
// On small screens: show button before summary
|
|
if (!isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
|
|
|
// Summary with episode info (Apple TV style)
|
|
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
|
const SizedBox(height: 12),
|
|
RichText(
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
text: TextSpan(
|
|
style: TextStyle(
|
|
color: isLargeScreen
|
|
? Colors.white.withValues(alpha: 0.7)
|
|
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
),
|
|
children: [
|
|
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
|
TextSpan(
|
|
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: isLargeScreen ? Colors.white : Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: heroItem.summary?.isNotEmpty == true
|
|
? heroItem.summary!
|
|
: 'No description available',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
] else if (shouldHideSpoiler && isEpisode && heroItem.parentIndex != null && heroItem.index != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
style: TextStyle(
|
|
color: isLargeScreen
|
|
? Colors.white.withValues(alpha: 0.7)
|
|
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
],
|
|
|
|
// On large screens: show button after summary
|
|
if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSmartPlayButton(PlexMetadata heroItem) {
|
|
final hasProgress =
|
|
heroItem.viewOffset != null && heroItem.duration != null && heroItem.viewOffset! > 0 && heroItem.duration! > 0;
|
|
|
|
final minutesLeft = hasProgress ? ((heroItem.duration! - heroItem.viewOffset!) / 60000).round() : 0;
|
|
|
|
final progress = hasProgress ? heroItem.viewOffset! / heroItem.duration! : 0.0;
|
|
|
|
return InkWell(
|
|
onTap: () {
|
|
appLogger.d('Playing: ${heroItem.title}');
|
|
navigateToVideoPlayer(context, metadata: heroItem);
|
|
},
|
|
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(24))),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 20, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
if (hasProgress) ...[
|
|
// Progress bar
|
|
Container(
|
|
width: 40,
|
|
height: 6,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.black26,
|
|
borderRadius: BorderRadius.all(Radius.circular(3)),
|
|
),
|
|
child: FractionallySizedBox(
|
|
alignment: Alignment.centerLeft,
|
|
widthFactor: progress,
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.black,
|
|
borderRadius: BorderRadius.all(Radius.circular(2)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
t.discover.minutesLeft(minutes: minutesLeft),
|
|
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
|
|
),
|
|
] else
|
|
Text(
|
|
t.common.play,
|
|
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|