feat: implement core server with API (auth, playlists), static assets, Xtream proxy, media streaming, and icons

This commit is contained in:
Michael committed 2025-12-06 13:10:58 +01:00
1 parent f064feb22c
commit 85471b5312
10 files changed
+432 -119

No files matched your search

+66 -5
View File
@@ -153,21 +153,34 @@ Handler _createXtreamProxyHandler() {
final targetUrl = Uri.parse(fullUrl); final targetUrl = Uri.parse(fullUrl);
final baseUrl = '${targetUrl.scheme}://${targetUrl.host}${targetUrl.hasPort ? ':${targetUrl.port}' : ''}'; final baseUrl = '${targetUrl.scheme}://${targetUrl.host}${targetUrl.hasPort ? ':${targetUrl.port}' : ''}';
// Check if this is a video file that needs streaming
final lowerPath = targetUrl.path.toLowerCase();
final isVideoFile = lowerPath.endsWith('.mp4') ||
lowerPath.endsWith('.mkv') ||
lowerPath.endsWith('.avi') ||
lowerPath.endsWith('.ts') ||
lowerPath.endsWith('.m4v') ||
lowerPath.contains('/movie/') ||
lowerPath.contains('/series/');
print('Proxying request to: $targetUrl'); print('Proxying request to: $targetUrl (video streaming: $isVideoFile)');
// For video files, use streaming to avoid loading entire file in memory
if (isVideoFile) {
return _streamVideoFile(targetUrl);
}
// Headers to simulate a legitimate IPTV client (VLC/Kodi style) // Headers to simulate a legitimate IPTV client (VLC/Kodi style)
// Many IPTV servers check User-Agent to prevent web playback
final proxyHeaders = { final proxyHeaders = {
// Use VLC Media Player User-Agent - most IPTV servers whitelist this
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', 'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
'Accept': '*/*', 'Accept': '*/*',
'Accept-Encoding': 'identity', 'Accept-Encoding': 'identity',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
'Icy-MetaData': '1', // Common for IPTV streams 'Icy-MetaData': '1',
}; };
// Use simple http.get/post with timeout and proper headers // Use simple http.get/post for non-video content
http.Response response; http.Response response;
if (request.method == 'GET') { if (request.method == 'GET') {
response = await http.get(targetUrl, headers: proxyHeaders).timeout( response = await http.get(targetUrl, headers: proxyHeaders).timeout(
@@ -231,6 +244,43 @@ Handler _createXtreamProxyHandler() {
}; };
} }
/// Stream video file using HttpClient (avoids loading entire file in memory)
Future<Response> _streamVideoFile(Uri targetUrl) async {
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 30);
final req = await client.getUrl(targetUrl);
req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18');
req.headers.set('Accept', '*/*');
req.headers.set('Connection', 'keep-alive');
final response = await req.close();
// Get content type from response
final contentType = response.headers.contentType?.mimeType ?? 'video/mp4';
// Create a streaming response
return Response(
response.statusCode,
body: response,
headers: {
'content-type': contentType,
'access-control-allow-origin': '*',
'accept-ranges': 'bytes',
if (response.contentLength > 0)
'content-length': response.contentLength.toString(),
},
);
} catch (e) {
print('Video streaming error: $e');
return Response.internalServerError(
body: jsonEncode({'error': 'Video streaming error', 'message': e.toString()}),
headers: {'content-type': 'application/json'},
);
}
}
/// Rewrite URLs in M3U8 playlist to go through the proxy /// Rewrite URLs in M3U8 playlist to go through the proxy
/// ///
/// Handles both relative and absolute URLs in HLS playlists /// Handles both relative and absolute URLs in HLS playlists
@@ -355,14 +405,22 @@ Handler _createStreamHandler() {
// Start FFmpeg process with VLC User-Agent // Start FFmpeg process with VLC User-Agent
// Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome) // Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome)
// Using fast preset for lower CPU usage // Using fast preset for lower CPU usage
// Added resilience flags for corrupted/discontinuous streams
final process = await Process.start('ffmpeg', [ final process = await Process.start('ffmpeg', [
'-y', // Overwrite output files '-y', // Overwrite output files
'-loglevel', 'warning', // Reduce verbose output '-loglevel', 'warning', // Reduce verbose output
'-err_detect', 'ignore_err', // Ignore decoding errors
'-fflags', '+genpts+discardcorrupt+igndts', // Generate PTS, discard corrupt frames, ignore DTS
'-flags', 'low_delay', // Low delay mode
'-analyzeduration', '2000000', // Limit analysis time (2 seconds)
'-probesize', '1000000', // Limit probe size (1MB)
'-user_agent', 'VLC/3.0.18 LibVLC/3.0.18', '-user_agent', 'VLC/3.0.18 LibVLC/3.0.18',
'-headers', 'Accept: */*\r\nConnection: keep-alive\r\n', '-headers', 'Accept: */*\r\nConnection: keep-alive\r\n',
'-reconnect', '1', '-reconnect', '1',
'-reconnect_streamed', '1', '-reconnect_streamed', '1',
'-reconnect_delay_max', '5', '-reconnect_delay_max', '5',
'-reconnect_on_network_error', '1', // Reconnect on network errors
'-reconnect_on_http_error', '4xx,5xx', // Reconnect on HTTP errors
'-i', iptvUrl, '-i', iptvUrl,
// Video: transcode to H.264 for browser compatibility // Video: transcode to H.264 for browser compatibility
'-c:v', 'libx264', '-c:v', 'libx264',
@@ -373,10 +431,13 @@ Handler _createStreamHandler() {
'-b:v', '2500k', // 2.5 Mbps video bitrate '-b:v', '2500k', // 2.5 Mbps video bitrate
'-maxrate', '3000k', '-maxrate', '3000k',
'-bufsize', '6000k', '-bufsize', '6000k',
'-vsync', 'passthrough', // Pass through timestamps as-is (fixes discontinuity)
'-avoid_negative_ts', 'make_zero', // Handle negative timestamps
// Audio: transcode to AAC // Audio: transcode to AAC
'-c:a', 'aac', '-c:a', 'aac',
'-b:a', '128k', '-b:a', '128k',
'-ar', '44100', '-ar', '44100',
'-async', '1', // Audio sync (stretch/compression allowed)
// HLS output settings // HLS output settings
'-f', 'hls', '-f', 'hls',
'-hls_time', '2', // 2 second segments '-hls_time', '2', // 2 second segments
@@ -1,55 +1,167 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Settings state for IPTV preferences /// Keys for SharedPreferences storage
class _SettingsKeys {
static const String liveTvFilter = 'filter_live_tv';
static const String moviesFilter = 'filter_movies';
static const String seriesFilter = 'filter_series';
}
/// Settings state for IPTV preferences with persistence
class IptvSettings { class IptvSettings {
/// Category filter keywords (comma-separated) /// Category filter keywords for Live TV (comma-separated)
/// Example: "FR,FRANCE,HD" will show only categories containing one of these words final String liveTvCategoryFilter;
final String categoryFilter;
/// Category filter keywords for Movies (comma-separated)
final String moviesCategoryFilter;
/// Category filter keywords for Series (comma-separated)
final String seriesCategoryFilter;
const IptvSettings({ const IptvSettings({
this.categoryFilter = '', this.liveTvCategoryFilter = '',
this.moviesCategoryFilter = '',
this.seriesCategoryFilter = '',
}); });
IptvSettings copyWith({ IptvSettings copyWith({
String? categoryFilter, String? liveTvCategoryFilter,
String? moviesCategoryFilter,
String? seriesCategoryFilter,
}) { }) {
return IptvSettings( return IptvSettings(
categoryFilter: categoryFilter ?? this.categoryFilter, liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter,
moviesCategoryFilter: moviesCategoryFilter ?? this.moviesCategoryFilter,
seriesCategoryFilter: seriesCategoryFilter ?? this.seriesCategoryFilter,
); );
} }
/// Get list of filter keywords (trimmed and non-empty) /// Get list of filter keywords for a given filter string
List<String> get filterKeywords { static List<String> _parseKeywords(String filter) {
if (categoryFilter.isEmpty) return []; if (filter.isEmpty) return [];
return categoryFilter return filter
.split(',') .split(',')
.map((s) => s.trim().toUpperCase()) .map((s) => s.trim().toUpperCase())
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.toList(); .toList();
} }
/// Check if a category name matches the filter /// Get live TV filter keywords
/// Returns true if no filter is set or if the category contains any keyword List<String> get liveTvKeywords => _parseKeywords(liveTvCategoryFilter);
bool matchesFilter(String categoryName) {
final keywords = filterKeywords; /// Get movies filter keywords
List<String> get moviesKeywords => _parseKeywords(moviesCategoryFilter);
/// Get series filter keywords
List<String> get seriesKeywords => _parseKeywords(seriesCategoryFilter);
/// Check if a category name matches the Live TV filter
bool matchesLiveTvFilter(String categoryName) {
return _matchesFilter(categoryName, liveTvKeywords);
}
/// Check if a category name matches the Movies filter
bool matchesMoviesFilter(String categoryName) {
return _matchesFilter(categoryName, moviesKeywords);
}
/// Check if a category name matches the Series filter
bool matchesSeriesFilter(String categoryName) {
return _matchesFilter(categoryName, seriesKeywords);
}
/// Generic filter matching
bool _matchesFilter(String categoryName, List<String> keywords) {
if (keywords.isEmpty) return true; if (keywords.isEmpty) return true;
final upperName = categoryName.toUpperCase(); final upperName = categoryName.toUpperCase();
return keywords.any((keyword) => upperName.contains(keyword)); return keywords.any((keyword) => upperName.contains(keyword));
} }
/// Legacy method for backwards compatibility
bool matchesFilter(String categoryName) => matchesLiveTvFilter(categoryName);
} }
/// IPTV Settings notifier for managing user preferences /// IPTV Settings notifier with SharedPreferences persistence
class IptvSettingsNotifier extends StateNotifier<IptvSettings> { class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
IptvSettingsNotifier() : super(const IptvSettings()); SharedPreferences? _prefs;
bool _initialized = false;
void setCategoryFilter(String filter) { IptvSettingsNotifier() : super(const IptvSettings()) {
state = state.copyWith(categoryFilter: filter); _loadSettings();
} }
void clearCategoryFilter() { /// Load settings from SharedPreferences
state = state.copyWith(categoryFilter: ''); Future<void> _loadSettings() async {
if (_initialized) return;
try {
_prefs = await SharedPreferences.getInstance();
final liveTv = _prefs?.getString(_SettingsKeys.liveTvFilter) ?? '';
final movies = _prefs?.getString(_SettingsKeys.moviesFilter) ?? '';
final series = _prefs?.getString(_SettingsKeys.seriesFilter) ?? '';
state = IptvSettings(
liveTvCategoryFilter: liveTv,
moviesCategoryFilter: movies,
seriesCategoryFilter: series,
);
_initialized = true;
print('Settings loaded: LiveTV="$liveTv", Movies="$movies", Series="$series"');
} catch (e) {
print('Error loading settings: $e');
}
} }
/// Save a setting to SharedPreferences
Future<void> _saveString(String key, String value) async {
try {
_prefs ??= await SharedPreferences.getInstance();
await _prefs?.setString(key, value);
} catch (e) {
print('Error saving setting $key: $e');
}
}
/// Set Live TV category filter
void setLiveTvFilter(String filter) {
state = state.copyWith(liveTvCategoryFilter: filter);
_saveString(_SettingsKeys.liveTvFilter, filter);
}
/// Set Movies category filter
void setMoviesFilter(String filter) {
state = state.copyWith(moviesCategoryFilter: filter);
_saveString(_SettingsKeys.moviesFilter, filter);
}
/// Set Series category filter
void setSeriesFilter(String filter) {
state = state.copyWith(seriesCategoryFilter: filter);
_saveString(_SettingsKeys.seriesFilter, filter);
}
/// Clear Live TV filter
void clearLiveTvFilter() => setLiveTvFilter('');
/// Clear Movies filter
void clearMoviesFilter() => setMoviesFilter('');
/// Clear Series filter
void clearSeriesFilter() => setSeriesFilter('');
/// Clear all filters
void clearAllFilters() {
clearLiveTvFilter();
clearMoviesFilter();
clearSeriesFilter();
}
// Legacy methods for backwards compatibility
void setCategoryFilter(String filter) => setLiveTvFilter(filter);
void clearCategoryFilter() => clearLiveTvFilter();
} }
/// Provider for IPTV settings /// Provider for IPTV settings
+3 -3
View File
@@ -70,8 +70,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Filter categories based on keywords // Filter categories based on keywords
var categories = groupedChannels.keys.toList(); var categories = groupedChannels.keys.toList();
if (settings.filterKeywords.isNotEmpty) { if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesFilter(cat)).toList(); categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
} }
categories.sort(); categories.sort();
@@ -88,7 +88,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Filter: ${settings.categoryFilter}', 'Filter: ${settings.liveTvCategoryFilter}',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
), ),
], ],
+32 -3
View File
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart'; import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/player_screen.dart'; import '../screens/player_screen.dart';
class MoviesTab extends ConsumerStatefulWidget { class MoviesTab extends ConsumerStatefulWidget {
@@ -78,10 +79,38 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
// Filter movies by category
final filteredMovies = settings.moviesKeywords.isEmpty
? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
if (_movies.isEmpty && !_isLoading) { if (_movies.isEmpty && !_isLoading) {
return const Center(child: Text('No movies available')); return const Center(child: Text('No movies available'));
} }
if (filteredMovies.isEmpty && _movies.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.filter_list_off, size: 48, color: Colors.grey),
const SizedBox(height: 16),
Text(
'No movies match the filter',
style: GoogleFonts.roboto(color: Colors.grey),
),
const SizedBox(height: 8),
Text(
'Filter: ${settings.moviesCategoryFilter}',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
),
],
),
);
}
return GridView.builder( return GridView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -91,13 +120,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
mainAxisSpacing: 6, mainAxisSpacing: 6,
childAspectRatio: 0.65, childAspectRatio: 0.65,
), ),
itemCount: _movies.length + (_hasMore ? 1 : 0), itemCount: filteredMovies.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= _movies.length) { if (index >= filteredMovies.length) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final movie = _movies[index]; final movie = filteredMovies[index];
return _MovieCard( return _MovieCard(
movie: movie, movie: movie,
playlist: widget.playlist, playlist: widget.playlist,
+32 -3
View File
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart'; import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/series_detail_screen.dart'; import '../screens/series_detail_screen.dart';
class SeriesTab extends ConsumerStatefulWidget { class SeriesTab extends ConsumerStatefulWidget {
@@ -78,10 +79,38 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
// Filter series by category
final filteredSeries = settings.seriesKeywords.isEmpty
? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
if (_series.isEmpty && !_isLoading) { if (_series.isEmpty && !_isLoading) {
return const Center(child: Text('No series available')); return const Center(child: Text('No series available'));
} }
if (filteredSeries.isEmpty && _series.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.filter_list_off, size: 48, color: Colors.grey),
const SizedBox(height: 16),
Text(
'No series match the filter',
style: GoogleFonts.roboto(color: Colors.grey),
),
const SizedBox(height: 8),
Text(
'Filter: ${settings.seriesCategoryFilter}',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
),
],
),
);
}
return GridView.builder( return GridView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -91,13 +120,13 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
mainAxisSpacing: 6, mainAxisSpacing: 6,
childAspectRatio: 0.65, childAspectRatio: 0.65,
), ),
itemCount: _series.length + (_hasMore ? 1 : 0), itemCount: filteredSeries.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= _series.length) { if (index >= filteredSeries.length) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final serie = _series[index]; final serie = filteredSeries[index];
return _SeriesCard(series: serie, playlist: widget.playlist); return _SeriesCard(series: serie, playlist: widget.playlist);
}, },
); );
+164 -83
View File
@@ -13,29 +13,49 @@ class SettingsTab extends ConsumerStatefulWidget {
} }
class _SettingsTabState extends ConsumerState<SettingsTab> { class _SettingsTabState extends ConsumerState<SettingsTab> {
late TextEditingController _filterController; late TextEditingController _liveTvController;
late TextEditingController _moviesController;
late TextEditingController _seriesController;
bool _initialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_filterController = TextEditingController(); _liveTvController = TextEditingController();
_moviesController = TextEditingController();
_seriesController = TextEditingController();
} }
@override @override
void dispose() { void dispose() {
_filterController.dispose(); _liveTvController.dispose();
_moviesController.dispose();
_seriesController.dispose();
super.dispose(); super.dispose();
} }
void _syncControllers(IptvSettings settings) {
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) {
_liveTvController.text = settings.liveTvCategoryFilter;
}
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) {
_moviesController.text = settings.moviesCategoryFilter;
}
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter;
}
_initialized = true;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser; final currentUser = ref.watch(authProvider).currentUser;
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
// Sync controller with state // Sync controllers with persisted state (only on first load)
if (_filterController.text != settings.categoryFilter) { WidgetsBinding.instance.addPostFrameCallback((_) {
_filterController.text = settings.categoryFilter; if (!_initialized) _syncControllers(settings);
} });
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -62,84 +82,70 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Category filter section // Category filters section
Card( Text(
child: Padding( 'Category Filters',
padding: const EdgeInsets.all(16), style: GoogleFonts.roboto(
child: Column( fontWeight: FontWeight.bold,
crossAxisAlignment: CrossAxisAlignment.start, fontSize: 16,
children: [
Row(
children: [
const Icon(Icons.filter_list, size: 20),
const SizedBox(width: 8),
Text(
'Live TV Category Filter',
style: GoogleFonts.roboto(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
Text(
'Enter keywords separated by commas. Only categories containing one of these words will be shown.',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 12),
TextField(
controller: _filterController,
decoration: InputDecoration(
hintText: 'Ex: FR,FRANCE,HD,SPORT',
hintStyle: GoogleFonts.roboto(fontSize: 13),
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
suffixIcon: _filterController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_filterController.clear();
ref.read(iptvSettingsProvider.notifier)
.clearCategoryFilter();
},
)
: null,
),
style: GoogleFonts.roboto(fontSize: 13),
onChanged: (value) {
ref.read(iptvSettingsProvider.notifier)
.setCategoryFilter(value);
},
),
if (settings.filterKeywords.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: settings.filterKeywords.map((keyword) {
return Chip(
label: Text(
keyword,
style: GoogleFonts.roboto(fontSize: 11),
),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}).toList(),
),
],
],
),
), ),
), ),
const SizedBox(height: 4),
Text(
'Only categories containing one of these keywords will be shown. Separate with commas.',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 12),
// Live TV Filter
_buildFilterCard(
title: 'Live TV Filter',
icon: Icons.live_tv,
controller: _liveTvController,
keywords: settings.liveTvKeywords,
onChanged: (value) {
ref.read(iptvSettingsProvider.notifier).setLiveTvFilter(value);
},
onClear: () {
_liveTvController.clear();
ref.read(iptvSettingsProvider.notifier).clearLiveTvFilter();
},
),
const SizedBox(height: 8),
// Movies Filter
_buildFilterCard(
title: 'Movies Filter',
icon: Icons.movie,
controller: _moviesController,
keywords: settings.moviesKeywords,
onChanged: (value) {
ref.read(iptvSettingsProvider.notifier).setMoviesFilter(value);
},
onClear: () {
_moviesController.clear();
ref.read(iptvSettingsProvider.notifier).clearMoviesFilter();
},
),
const SizedBox(height: 8),
// Series Filter
_buildFilterCard(
title: 'Series Filter',
icon: Icons.tv,
controller: _seriesController,
keywords: settings.seriesKeywords,
onChanged: (value) {
ref.read(iptvSettingsProvider.notifier).setSeriesFilter(value);
},
onClear: () {
_seriesController.clear();
ref.read(iptvSettingsProvider.notifier).clearSeriesFilter();
},
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Admin panel // Admin panel
@@ -195,4 +201,79 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
], ],
); );
} }
Widget _buildFilterCard({
required String title,
required IconData icon,
required TextEditingController controller,
required List<String> keywords,
required ValueChanged<String> onChanged,
required VoidCallback onClear,
}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: Colors.blue.shade700),
const SizedBox(width: 8),
Text(
title,
style: GoogleFonts.roboto(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
const SizedBox(height: 8),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Ex: FR,FRANCE,HD,SPORT',
hintStyle: GoogleFonts.roboto(fontSize: 12),
border: const OutlineInputBorder(),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 16),
onPressed: onClear,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
)
: null,
),
style: GoogleFonts.roboto(fontSize: 12),
onChanged: onChanged,
),
if (keywords.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 4,
children: keywords.map((keyword) {
return Chip(
label: Text(
keyword,
style: GoogleFonts.roboto(fontSize: 10),
),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}).toList(),
),
],
],
),
),
);
}
} }
+1
View File
@@ -46,6 +46,7 @@ dependencies:
# Utilities # Utilities
intl: ^0.19.0 intl: ^0.19.0
equatable: ^2.0.5 equatable: ^2.0.5
shared_preferences: ^2.2.2
# Server dependencies (for backend proxy) # Server dependencies (for backend proxy)
shelf: ^1.4.1 shelf: ^1.4.1
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB