Files
plezy/lib/screens/search_screen.dart
T
2025-12-07 15:00:28 +01:00

282 lines
8.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:rate_limiter/rate_limiter.dart';
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 '../utils/app_logger.dart';
import '../utils/sliver_adaptive_media_builder.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_card.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> with Refreshable {
final _searchController = TextEditingController();
final _searchFocusNode = FocusNode(debugLabel: 'SearchInput');
List<PlexMetadata> _searchResults = [];
bool _isSearching = false;
bool _hasSearched = false;
late final Debounce _searchDebounce;
String _lastSearchedQuery = '';
@override
void initState() {
super.initState();
_searchDebounce = debounce(
_performSearch,
const Duration(milliseconds: 500),
);
_searchController.addListener(_onSearchChanged);
// Focus the search input when the screen is shown
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
@override
void dispose() {
_searchDebounce.cancel();
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _onSearchChanged() {
final query = _searchController.text;
if (query.trim().isEmpty) {
_searchDebounce.cancel();
setState(() {
_searchResults = [];
_hasSearched = false;
_isSearching = false;
_lastSearchedQuery = '';
});
return;
}
// Only search if the query has actually changed
if (query.trim() == _lastSearchedQuery.trim()) {
return;
}
_searchDebounce([query]);
}
Future<void> _performSearch(String query) async {
if (query.trim().isEmpty) {
setState(() {
_searchResults = [];
_hasSearched = false;
});
return;
}
setState(() {
_isSearching = true;
_hasSearched = true;
});
try {
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
}
// Search across all connected servers
final results = await multiServerProvider.aggregationService
.searchAcrossServers(query);
if (mounted) {
setState(() {
_searchResults = results;
_isSearching = false;
_lastSearchedQuery = query.trim();
});
}
} catch (e) {
if (mounted) {
setState(() {
_isSearching = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.errors.searchFailed(error: e))),
);
}
}
}
@override
void refresh() {
// Re-run the current search if there is one
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
/// Focus the search input field
void focusSearchInput() {
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
// Public method to fully reload all content (for profile switches)
void fullRefresh() {
appLogger.d(
'SearchScreen.fullRefresh() called - clearing search and reloading',
);
// Clear search results and search text for new profile
_searchController.clear();
setState(() {
_searchResults.clear();
_isSearching = false;
_hasSearched = false;
_lastSearchedQuery = '';
});
}
void updateItem(String ratingKey) {
// Trigger a refresh of the search to get updated metadata
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: CustomScrollView(
slivers: [
DesktopSliverAppBar(title: Text(t.screens.search), floating: true),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: t.search.hint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
// State update handled by listener
},
)
: null,
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
),
if (_isSearching)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (!_hasSearched)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
t.search.searchYourMedia,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
t.search.enterTitleActorOrKeyword,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
)
else if (_searchResults.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
t.messages.noResultsFound,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
t.search.tryDifferentTerm,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
)
else
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return buildAdaptiveMediaSliverBuilder<PlexMetadata>(
context: context,
items: _searchResults,
itemBuilder: (context, item, index) {
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
viewMode: settingsProvider.viewMode,
density: settingsProvider.libraryDensity,
padding: const EdgeInsets.all(16),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
);
},
),
],
),
),
);
}
}