Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import '../models/video_item.dart';
import '../services/app_settings_service.dart';
import '../services/preferred_language_service.dart';
import '../services/youtube_service.dart';
import '../widgets/video_result_card.dart';
import 'video_player_page.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final TextEditingController _controller = TextEditingController();
final YoutubeService _youtubeService = YoutubeService();
List<VideoItem> _results = [];
bool _loading = false;
String? _error;
String _lastQuery = '';
Future<void> _search() async {
final query = _controller.text.trim();
if (query.isEmpty || _loading) {
return;
}
FocusScope.of(context).unfocus();
setState(() {
_loading = true;
_error = null;
_lastQuery = query;
});
try {
final results = await _youtubeService.searchVideos(query);
final preferredLanguage =
AppSettingsService.instance.preferredLanguage.value;
final rankedResults = PreferredLanguageService.rankVideos(
results,
preferredLanguage,
);
if (!mounted) {
return;
}
setState(() {
_results = rankedResults;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_results = [];
_error =
'Impossible de récupérer les résultats YouTube.\n'
'Vérifie ta connexion puis réessaie.';
});
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
void _clear() {
_controller.clear();
setState(() {
_results = [];
_error = null;
_lastQuery = '';
});
}
void _openVideo(VideoItem video) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => VideoPlayerPage(video: video),
),
);
}
@override
void dispose() {
_controller.dispose();
_youtubeService.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
_SearchHeader(
controller: _controller,
loading: _loading,
onSearch: _search,
onClear: _clear,
),
Expanded(
child: _buildBody(),
),
],
),
);
}
Widget _buildBody() {
if (_loading && _results.isEmpty) {
return Center(
child: CircularProgressIndicator(),
);
}
if (_error != null) {
return _ErrorState(
message: _error!,
onRetry: _search,
);
}
if (_results.isEmpty) {
if (_lastQuery.isNotEmpty) {
return Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Aucune vidéo trouvée pour « $_lastQuery ».',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 16,
),
),
),
);
}
return _EmptySearchState();
}
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, 4, 20, 6),
child: Row(
children: [
Expanded(
child: Text(
'Résultats pour « $_lastQuery »',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
if (_loading)
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
),
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(12, 4, 12, 24),
itemCount: _results.length,
separatorBuilder: (context, index) => SizedBox(height: 4),
itemBuilder: (context, index) {
final video = _results[index];
return VideoResultCard(
video: video,
onTap: () => _openVideo(video),
);
},
),
),
],
);
}
}
class _SearchHeader extends StatelessWidget {
const _SearchHeader({
required this.controller,
required this.loading,
required this.onSearch,
required this.onClear,
});
final TextEditingController controller;
final bool loading;
final VoidCallback onSearch;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final wide = MediaQuery.sizeOf(context).width >= 700;
return Padding(
padding: EdgeInsets.fromLTRB(
wide ? 28 : 16,
18,
wide ? 28 : 16,
14,
),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 900),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
textInputAction: TextInputAction.search,
onSubmitted: (_) => onSearch(),
decoration: InputDecoration(
hintText: 'Rechercher sur YouTube...',
prefixIcon: Icon(Icons.search),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, child) {
if (value.text.isEmpty) {
return SizedBox.shrink();
}
return IconButton(
tooltip: 'Effacer',
onPressed: onClear,
icon: Icon(Icons.close),
);
},
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide(
color: Color(0xFFFF4E45),
width: 1.5,
),
),
),
),
),
SizedBox(width: 10),
IconButton.filled(
tooltip: 'Rechercher',
onPressed: loading ? null : onSearch,
icon: Icon(Icons.search),
),
],
),
),
),
);
}
}
class _EmptySearchState extends StatelessWidget {
const _EmptySearchState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.youtube_searched_for,
size: 70,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44),
),
SizedBox(height: 18),
Text(
'Recherche YouTube',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 8),
Text(
'Saisis un titre, une chaîne ou un sujet.',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 15,
),
),
],
),
),
);
}
}
class _ErrorState extends StatelessWidget {
const _ErrorState({
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.cloud_off_outlined,
size: 62,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
),
SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 15,
),
),
SizedBox(height: 18),
FilledButton.icon(
onPressed: onRetry,
icon: Icon(Icons.refresh),
label: Text('Réessayer'),
),
],
),
),
);
}
}