Files
plezy/lib/screens/search_screen.dart
T

326 lines
10 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../services/settings_service.dart';
import '../providers/settings_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => context.clientSafe;
final _searchController = TextEditingController();
List<PlexMetadata> _searchResults = [];
bool _isSearching = false;
bool _hasSearched = false;
Timer? _debounceTimer;
String _lastSearchedQuery = '';
@override
void initState() {
super.initState();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
// Cancel previous timer
_debounceTimer?.cancel();
final query = _searchController.text;
if (query.trim().isEmpty) {
setState(() {
_searchResults = [];
_hasSearched = false;
_isSearching = false;
_lastSearchedQuery = '';
});
return;
}
// Only search if the query has actually changed
if (query.trim() == _lastSearchedQuery.trim()) {
return;
}
// Start new timer
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
Future<void> _performSearch(String query) async {
if (query.trim().isEmpty) {
setState(() {
_searchResults = [];
_hasSearched = false;
});
return;
}
setState(() {
_isSearching = true;
_hasSearched = true;
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
final results = await client.search(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('Search failed: $e')));
}
}
}
@override
void refresh() {
// Re-run the current search if there is one
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
// 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 = '';
});
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _searchResults.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (index != -1) {
_searchResults[index] = updatedMetadata;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: CustomScrollView(
slivers: [
DesktopSliverAppBar(title: const Text('Search'), floating: true),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
controller: _searchController,
hintText: 'Search movies, shows, music...',
leading: const Icon(Icons.search),
trailing: [
if (_searchController.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
// State update handled by listener
},
),
],
autoFocus: false,
),
),
),
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(
'Search your media',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Enter a title, actor, or keyword',
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(
'No results found',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Try a different search term',
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
)
else
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
childCount: _searchResults.length,
),
),
);
} else {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
);
}
},
),
],
),
),
);
}
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
final screenWidth = MediaQuery.of(context).size.width;
final padding = 32.0; // 16px left + 16px right from SliverPadding
final availableWidth = screenWidth - padding;
if (screenWidth >= 900) {
// Wide screens (desktop/large tablet landscape): Responsive division
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 6.5;
maxItemWidth = 280;
break;
case LibraryDensity.normal:
divisor = 8.0;
maxItemWidth = 200;
break;
case LibraryDensity.compact:
divisor = 10.0;
maxItemWidth = 160;
break;
}
return (availableWidth / divisor).clamp(0, maxItemWidth);
} else if (screenWidth >= 600) {
// Medium screens (tablets): Fixed 4-5-6 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 4,
LibraryDensity.normal => 5,
LibraryDensity.compact => 6,
};
return availableWidth / targetItemCount;
} else {
// Small screens (phones): Fixed 2-3-4 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 2,
LibraryDensity.normal => 3,
LibraryDensity.compact => 4,
};
return availableWidth / targetItemCount;
}
}
}