diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index aba5376e..db35c443 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -9,11 +9,7 @@ import '../i18n/strings.g.dart'; import '../mixins/refreshable.dart'; import '../models/plex_metadata.dart'; import '../providers/multi_server_provider.dart'; -import '../providers/settings_provider.dart'; -import '../services/settings_service.dart' show ViewMode; import '../utils/app_logger.dart'; -import '../utils/grid_size_calculator.dart'; -import '../utils/sliver_adaptive_media_builder.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/focusable_media_card.dart'; @@ -130,7 +126,7 @@ class _SearchScreenState extends State with Refreshable, FullRefre /// Focus the search input field @override void focusSearchInput() { - FocusUtils.requestFocusAfterBuild(this, _searchFocusNode); + _searchFocusNode.requestFocus(); } /// Set the search query externally (e.g. from companion remote) @@ -196,130 +192,101 @@ class _SearchScreenState extends State with Refreshable, FullRefre return KeyEventResult.ignored; } - /// Calculate column count based on available width. - int _calculateColumnCount(double availableWidth, double maxCrossAxisExtent, double crossAxisSpacing) { - return ((availableWidth + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); + Widget _buildResultsList(BuildContext context) { + final multiServer = context.watch(); + final showServerName = multiServer.totalServerCount > 1; + return SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = _searchResults[index]; + return FocusableMediaCard( + key: Key(item.globalKey), + item: item, + forceListMode: true, + focusNode: index == 0 ? _firstResultFocusNode : null, + onListRefresh: () => updateItem(item.ratingKey), + onNavigateLeft: _navigateToSidebar, + onNavigateUp: index == 0 ? focusSearchInput : null, + showServerName: showServerName, + ); + }, childCount: _searchResults.length), + ), + ); } @override Widget build(BuildContext context) { - // Use LayoutBuilder to get the actual available width (accounting for sidebar) - return LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth; - - return Scaffold( - body: SafeArea( - child: CustomScrollView( - slivers: [ - DesktopSliverAppBar(title: Text(t.common.search), floating: true), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - child: Focus( - onKeyEvent: _handleSearchInputKeyEvent, - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - decoration: InputDecoration( - hintText: t.search.hint, - prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const AppIcon(Symbols.clear_rounded, fill: 1), - onPressed: () { - _searchController.clear(); - // State update handled by listener - }, - ) - : null, - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(100)), - borderSide: BorderSide.none, - ), - enabledBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(100)), - borderSide: BorderSide.none, - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(100)), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + DesktopSliverAppBar(title: Text(t.common.search), floating: true), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), + child: Focus( + onKeyEvent: _handleSearchInputKeyEvent, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: t.search.hint, + prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const AppIcon(Symbols.clear_rounded, fill: 1), + onPressed: () { + _searchController.clear(); + // State update handled by listener + }, + ) + : null, + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(100)), + borderSide: BorderSide.none, ), + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(100)), + borderSide: BorderSide.none, + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(100)), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ), ), - if (_isSearching) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (!_hasSearched) - SliverFillRemaining( - child: StateMessageWidget( - message: t.search.searchYourMedia, - subtitle: t.search.enterTitleActorOrKeyword, - icon: Symbols.search_rounded, - iconSize: 80, - ), - ) - else if (_searchResults.isEmpty) - SliverFillRemaining( - child: StateMessageWidget( - message: t.messages.noResultsFound, - subtitle: t.search.tryDifferentTerm, - icon: Symbols.search_off_rounded, - iconSize: 80, - ), - ) - else - Consumer( - builder: (context, settingsProvider, child) { - final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ); - const gridPadding = EdgeInsets.all(16); - const crossAxisSpacing = 8.0; - final gridAvailableWidth = availableWidth - gridPadding.left - gridPadding.right; - final columnCount = _calculateColumnCount( - gridAvailableWidth, - maxCrossAxisExtent, - crossAxisSpacing, - ); - final isList = settingsProvider.viewMode == ViewMode.list; - - return buildAdaptiveMediaSliverBuilder( - context: context, - items: _searchResults, - itemBuilder: (context, item, index) { - // In list view, all items are in the first column - final isFirstColumn = isList || GridSizeCalculator.isFirstColumn(index, columnCount); - final isFirstRow = isList ? index == 0 : GridSizeCalculator.isFirstRow(index, columnCount); - return FocusableMediaCard( - key: Key(item.ratingKey), - item: item, - focusNode: index == 0 ? _firstResultFocusNode : null, - onListRefresh: () => updateItem(item.ratingKey), - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, - onNavigateUp: isFirstRow ? focusSearchInput : null, - ); - }, - viewMode: settingsProvider.viewMode, - density: settingsProvider.libraryDensity, - padding: const EdgeInsets.all(16), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ); - }, - ), - ], + ), ), - ), - ); - }, + if (_isSearching) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (!_hasSearched) + SliverFillRemaining( + child: StateMessageWidget( + message: t.search.searchYourMedia, + subtitle: t.search.enterTitleActorOrKeyword, + icon: Symbols.search_rounded, + iconSize: 80, + ), + ) + else if (_searchResults.isEmpty) + SliverFillRemaining( + child: StateMessageWidget( + message: t.messages.noResultsFound, + subtitle: t.search.tryDifferentTerm, + icon: Symbols.search_off_rounded, + iconSize: 80, + ), + ) + else + _buildResultsList(context), + ], + ), + ), ); } } diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index dc213bfe..89efaa02 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -18,6 +18,7 @@ class FocusableMediaCard extends StatefulWidget { final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; final bool forceGridMode; + final bool forceListMode; final bool isInContinueWatching; final String? collectionId; @@ -27,6 +28,9 @@ class FocusableMediaCard extends StatefulWidget { /// True when in a hub with mixed content (movies + episodes) final bool mixedHubContext; + /// Show server name in list view (multi-server) + final bool showServerName; + /// Optional external focus node for programmatic focus control. /// If not provided, an internal focus node is created. final FocusNode? focusNode; @@ -60,10 +64,12 @@ class FocusableMediaCard extends StatefulWidget { this.onRemoveFromContinueWatching, this.onListRefresh, this.forceGridMode = false, + this.forceListMode = false, this.isInContinueWatching = false, this.collectionId, this.isOffline = false, this.mixedHubContext = false, + this.showServerName = false, this.focusNode, this.onNavigateUp, this.onNavigateLeft, @@ -103,10 +109,12 @@ class _FocusableMediaCardState extends State { onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, onListRefresh: widget.onListRefresh, forceGridMode: widget.forceGridMode, + forceListMode: widget.forceListMode, isInContinueWatching: widget.isInContinueWatching, collectionId: widget.collectionId, isOffline: widget.isOffline, mixedHubContext: widget.mixedHubContext, + showServerName: widget.showServerName, ), ); } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 6526d777..fc12da91 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -29,10 +29,12 @@ class MediaCard extends StatefulWidget { final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; // Callback to refresh the entire parent list final bool forceGridMode; + final bool forceListMode; final bool isInContinueWatching; final String? collectionId; // The collection ID if displaying within a collection final bool isOffline; // True for downloaded content without server access final bool mixedHubContext; // True when in a hub with mixed content (movies + episodes) + final bool showServerName; // Show server name in list view (multi-server) const MediaCard({ super.key, @@ -43,10 +45,12 @@ class MediaCard extends StatefulWidget { this.onRemoveFromContinueWatching, this.onListRefresh, this.forceGridMode = false, + this.forceListMode = false, this.isInContinueWatching = false, this.collectionId, this.isOffline = false, this.mixedHubContext = false, + this.showServerName = false, }); @override @@ -166,7 +170,11 @@ class MediaCardState extends State { @override Widget build(BuildContext context) { final settingsProvider = context.watch(); - final viewMode = widget.forceGridMode ? ViewMode.grid : settingsProvider.viewMode; + final viewMode = widget.forceListMode + ? ViewMode.list + : widget.forceGridMode + ? ViewMode.grid + : settingsProvider.viewMode; final semanticLabel = _buildSemanticLabel(); final localPosterPath = _getLocalPosterPath(context); @@ -184,6 +192,7 @@ class MediaCardState extends State { density: settingsProvider.libraryDensity, isOffline: widget.isOffline, localPosterPath: localPosterPath, + showServerName: widget.showServerName, ); // MediaContextMenu as a non-widget helper — only wrap with its key for @@ -299,6 +308,7 @@ class _MediaCardList extends StatelessWidget { final LibraryDensity density; final bool isOffline; final String? localPosterPath; + final bool showServerName; const _MediaCardList({ required this.item, @@ -311,6 +321,7 @@ class _MediaCardList extends StatelessWidget { required this.density, this.isOffline = false, this.localPosterPath, + this.showServerName = false, }); double _basePosterWidth() { @@ -560,7 +571,10 @@ class _MediaCardList extends StatelessWidget { const SizedBox(height: 4), ], // Summary (hidden when spoiler protection is active) - if (!(item is PlexMetadata && context.watch().hideSpoilers && (item as PlexMetadata).shouldHideSpoiler) && item.summary != null) ...[ + if (!(item is PlexMetadata && + context.watch().hideSpoilers && + (item as PlexMetadata).shouldHideSpoiler) && + item.summary != null) ...[ Text( item.summary!, maxLines: _summaryMaxLines, @@ -572,6 +586,32 @@ class _MediaCardList extends StatelessWidget { ), ), ], + // Server name (multi-server mode) + if (showServerName && item is PlexMetadata && (item as PlexMetadata).serverName != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + AppIcon( + Symbols.dns_rounded, + fill: 1, + size: _metadataFontSize + 2, + color: tokens(context).textMuted.withValues(alpha: 0.6), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + (item as PlexMetadata).serverName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted.withValues(alpha: 0.6), + fontSize: _metadataFontSize, + ), + ), + ), + ], + ), + ], ], ), ), @@ -609,7 +649,10 @@ Widget _buildPosterImage( } else if (item is PlexMetadata) { final settingsProvider = context.watch(); final episodePosterMode = settingsProvider.episodePosterMode; - final shouldBlur = settingsProvider.hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; + final shouldBlur = + settingsProvider.hideSpoilers && + item.shouldHideSpoiler && + episodePosterMode == EpisodePosterMode.episodeThumbnail; posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext); Widget image; @@ -637,10 +680,7 @@ Widget _buildPosterImage( if (shouldBlur) { return ClipRect( - child: ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: image, - ), + child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: image), ); } return image;