diff --git a/bin/server.dart b/bin/server.dart index d9f0fff..04baa1a 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -410,7 +410,33 @@ Handler _createStreamHandler() { return Response.badRequest(body: 'Missing url parameter'); } - print('Starting FFmpeg stream for ID: $streamId'); + // Get streaming settings from query parameters + final quality = request.url.queryParameters['quality'] ?? 'medium'; + final buffer = request.url.queryParameters['buffer'] ?? 'medium'; + final timeout = request.url.queryParameters['timeout'] ?? 'medium'; + + // Map quality to bitrate/CRF + final (int bitrate, int crf) = switch (quality) { + 'low' => (1500, 26), + 'high' => (5000, 20), + _ => (3000, 23), // medium + }; + + // Map buffer to segment duration and buffer size + final (int segmentDuration, int bufferSize) = switch (buffer) { + 'low' => (2, 4000), + 'high' => (6, 12000), + _ => (4, 8000), // medium + }; + + // Map timeout to seconds + final int timeoutSeconds = switch (timeout) { + 'short' => 15, + 'long' => 60, + _ => 30, // medium + }; + + print('Starting FFmpeg stream for ID: $streamId (quality=$quality, buffer=$buffer)'); print('IPTV URL: $iptvUrl'); // Create output directory for this stream @@ -470,21 +496,21 @@ Handler _createStreamHandler() { '-profile:v', 'main', // Main profile for better quality HD '-level', '4.0', // Higher level for HD content '-pix_fmt', 'yuv420p', // Required for browser compatibility - '-crf', '21', // Quality-based encoding (lower = better, 21 is high quality) - '-b:v', '4000k', // 4 Mbps video bitrate for HD quality - '-maxrate', '5000k', // Allow bursts up to 5 Mbps - '-bufsize', '8000k', // Larger buffer for smoother playback + '-crf', '$crf', // Quality-based encoding from settings + '-b:v', '${bitrate}k', // Video bitrate from settings + '-maxrate', '${(bitrate * 1.25).round()}k', // Allow bursts + '-bufsize', '${bufferSize}k', // Buffer size from settings ]); // Add latency flags only for live streams if (!isVod) { ffmpegArgs.addAll([ '-tune', 'zerolatency', // Low latency for live streaming - '-vsync', 'passthrough', // Pass through timestamps as-is + '-fps_mode', 'passthrough', // Pass through timestamps as-is (replaces deprecated -vsync) ]); } else { ffmpegArgs.addAll([ - '-vsync', 'cfr', // Constant frame rate for VOD + '-fps_mode', 'cfr', // Constant frame rate for VOD (replaces deprecated -vsync) '-g', '48', // Keyframe every 2 seconds at 24fps '-keyint_min', '48', ]); @@ -499,8 +525,8 @@ Handler _createStreamHandler() { '-ac', '2', // Force stereo // HLS output settings - optimized for stability '-f', 'hls', - '-hls_time', isVod ? '4' : '3', // 4s for VOD, 3s for live (better buffering) - '-hls_list_size', isVod ? '0' : '10', // Keep all segments for VOD, 10 for live (30s buffer) + '-hls_time', isVod ? '4' : '$segmentDuration', // Segment duration from settings for live + '-hls_list_size', isVod ? '0' : '10', // Keep all segments for VOD, 10 for live '-hls_flags', isVod ? 'independent_segments' : 'delete_segments+append_list+omit_endlist+program_date_time', '-hls_start_number_source', 'datetime', // Better segment continuity '-hls_segment_filename', '${outputDir.path}/segment_%d.ts', diff --git a/lib/features/iptv/models/xtream_models.dart b/lib/features/iptv/models/xtream_models.dart index 1f18733..10f1fff 100644 --- a/lib/features/iptv/models/xtream_models.dart +++ b/lib/features/iptv/models/xtream_models.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + /// Xtream API Data Models (lightweight for memory optimization) class LiveChannel { @@ -261,21 +263,62 @@ class ShortEPG { final now = DateTime.now(); + // Helper to decode Base64 encoded title + String decodeTitle(String? encodedTitle) { + if (encodedTitle == null || encodedTitle.isEmpty) return ''; + try { + // Try Base64 decode + final decoded = String.fromCharCodes( + base64Decode(encodedTitle), + ); + return decoded; + } catch (_) { + // Not Base64, return as-is + return encodedTitle; + } + } + + // Helper to parse timestamp (Unix epoch or ISO format) + DateTime? parseTimestamp(dynamic value) { + if (value == null) return null; + + final strValue = value.toString(); + + // Try parsing as Unix timestamp (seconds) + final intValue = int.tryParse(strValue); + if (intValue != null) { + return DateTime.fromMillisecondsSinceEpoch(intValue * 1000); + } + + // Try parsing as ISO date string + return DateTime.tryParse(strValue); + } + for (var i = 0; i < epgListings.length; i++) { - final listing = epgListings[i]; - final start = DateTime.tryParse(listing['start']?.toString() ?? ''); - final stop = DateTime.tryParse(listing['stop']?.toString() ?? ''); + final listing = epgListings[i] as Map; + + // Parse start/stop timestamps (can be 'start', 'start_timestamp', or Unix epoch) + final start = parseTimestamp(listing['start']) ?? + parseTimestamp(listing['start_timestamp']); + final stop = parseTimestamp(listing['stop']) ?? + parseTimestamp(listing['end']) ?? + parseTimestamp(listing['stop_timestamp']); if (start != null && stop != null) { if (now.isAfter(start) && now.isBefore(stop)) { - nowTitle = listing['title']?.toString(); + // Decode title (may be Base64 encoded) + nowTitle = decodeTitle(listing['title']?.toString()); + final duration = stop.difference(start).inSeconds; final elapsed = now.difference(start).inSeconds; - currentProgress = elapsed / duration; + if (duration > 0) { + currentProgress = elapsed / duration; + } // Get next item if (i + 1 < epgListings.length) { - nextTitle = epgListings[i + 1]['title']?.toString(); + final nextListing = epgListings[i + 1] as Map; + nextTitle = decodeTitle(nextListing['title']?.toString()); } break; } diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index e35642d..c3b4996 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -1,43 +1,173 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Streaming quality presets for Live TV +enum StreamQuality { + low, // 1.5 Mbps, CRF 26 + medium, // 3 Mbps, CRF 23 + high, // 5 Mbps, CRF 20 +} + +/// Buffer size presets for Live TV +enum BufferSize { + low, // 2s segments, 4MB buffer + medium, // 4s segments, 8MB buffer + high, // 6s segments, 12MB buffer +} + +/// Connection timeout presets +enum ConnectionTimeout { + short, // 15 seconds + medium, // 30 seconds + long, // 60 seconds +} + +/// EPG cache duration presets +enum EpgCacheDuration { + short, // 5 minutes + medium, // 15 minutes + long, // 60 minutes +} + +/// Transcoding mode for Live TV +enum TranscodingMode { + auto, // Auto-detect best mode + forced, // Always transcode + disabled, // Direct stream (no transcoding) +} + /// Keys for SharedPreferences storage class _SettingsKeys { + // Filters static const String liveTvFilter = 'filter_live_tv'; static const String moviesFilter = 'filter_movies'; static const String seriesFilter = 'filter_series'; + + // Streaming settings + static const String streamQuality = 'stream_quality'; + static const String bufferSize = 'buffer_size'; + static const String connectionTimeout = 'connection_timeout'; + static const String autoReconnect = 'auto_reconnect'; + static const String epgCacheDuration = 'epg_cache_duration'; + static const String transcodingMode = 'transcoding_mode'; + static const String preferDirectPlay = 'prefer_direct_play'; } /// Settings state for IPTV preferences with persistence class IptvSettings { - /// Category filter keywords for Live TV (comma-separated) + // Category filters final String liveTvCategoryFilter; - - /// Category filter keywords for Movies (comma-separated) final String moviesCategoryFilter; - - /// Category filter keywords for Series (comma-separated) final String seriesCategoryFilter; + // Streaming settings (Live TV only) + final StreamQuality streamQuality; + final BufferSize bufferSize; + final ConnectionTimeout connectionTimeout; + final bool autoReconnect; + final EpgCacheDuration epgCacheDuration; + final TranscodingMode transcodingMode; + final bool preferDirectPlay; + const IptvSettings({ + // Filters this.liveTvCategoryFilter = '', this.moviesCategoryFilter = '', this.seriesCategoryFilter = '', + // Streaming defaults + this.streamQuality = StreamQuality.medium, + this.bufferSize = BufferSize.medium, + this.connectionTimeout = ConnectionTimeout.medium, + this.autoReconnect = true, + this.epgCacheDuration = EpgCacheDuration.medium, + this.transcodingMode = TranscodingMode.auto, + this.preferDirectPlay = false, }); IptvSettings copyWith({ String? liveTvCategoryFilter, String? moviesCategoryFilter, String? seriesCategoryFilter, + StreamQuality? streamQuality, + BufferSize? bufferSize, + ConnectionTimeout? connectionTimeout, + bool? autoReconnect, + EpgCacheDuration? epgCacheDuration, + TranscodingMode? transcodingMode, + bool? preferDirectPlay, }) { return IptvSettings( liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, moviesCategoryFilter: moviesCategoryFilter ?? this.moviesCategoryFilter, seriesCategoryFilter: seriesCategoryFilter ?? this.seriesCategoryFilter, + streamQuality: streamQuality ?? this.streamQuality, + bufferSize: bufferSize ?? this.bufferSize, + connectionTimeout: connectionTimeout ?? this.connectionTimeout, + autoReconnect: autoReconnect ?? this.autoReconnect, + epgCacheDuration: epgCacheDuration ?? this.epgCacheDuration, + transcodingMode: transcodingMode ?? this.transcodingMode, + preferDirectPlay: preferDirectPlay ?? this.preferDirectPlay, ); } - /// Get list of filter keywords for a given filter string + // ===== Streaming Value Getters ===== + + /// Get bitrate in kbps based on quality setting + int get bitrateKbps { + switch (streamQuality) { + case StreamQuality.low: return 1500; + case StreamQuality.medium: return 3000; + case StreamQuality.high: return 5000; + } + } + + /// Get CRF value based on quality setting + int get crfValue { + switch (streamQuality) { + case StreamQuality.low: return 26; + case StreamQuality.medium: return 23; + case StreamQuality.high: return 20; + } + } + + /// Get HLS segment duration in seconds + int get hlsSegmentDuration { + switch (bufferSize) { + case BufferSize.low: return 2; + case BufferSize.medium: return 4; + case BufferSize.high: return 6; + } + } + + /// Get buffer size in KB + int get bufferSizeKb { + switch (bufferSize) { + case BufferSize.low: return 4000; + case BufferSize.medium: return 8000; + case BufferSize.high: return 12000; + } + } + + /// Get connection timeout in seconds + int get timeoutSeconds { + switch (connectionTimeout) { + case ConnectionTimeout.short: return 15; + case ConnectionTimeout.medium: return 30; + case ConnectionTimeout.long: return 60; + } + } + + /// Get EPG cache duration in minutes + int get epgCacheMinutes { + switch (epgCacheDuration) { + case EpgCacheDuration.short: return 5; + case EpgCacheDuration.medium: return 15; + case EpgCacheDuration.long: return 60; + } + } + + // ===== Filter Methods ===== + static List _parseKeywords(String filter) { if (filter.isEmpty) return []; return filter @@ -47,38 +177,28 @@ class IptvSettings { .toList(); } - /// Get live TV filter keywords List get liveTvKeywords => _parseKeywords(liveTvCategoryFilter); - - /// Get movies filter keywords List get moviesKeywords => _parseKeywords(moviesCategoryFilter); - - /// Get series filter keywords List 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 keywords) { if (keywords.isEmpty) return true; final upperName = categoryName.toUpperCase(); return keywords.any((keyword) => upperName.contains(keyword)); } - /// Legacy method for backwards compatibility bool matchesFilter(String categoryName) => matchesLiveTvFilter(categoryName); } @@ -98,24 +218,52 @@ class IptvSettingsNotifier extends StateNotifier { try { _prefs = await SharedPreferences.getInstance(); + // Load filters final liveTv = _prefs?.getString(_SettingsKeys.liveTvFilter) ?? ''; final movies = _prefs?.getString(_SettingsKeys.moviesFilter) ?? ''; final series = _prefs?.getString(_SettingsKeys.seriesFilter) ?? ''; + // Load streaming settings + final quality = StreamQuality.values[ + _prefs?.getInt(_SettingsKeys.streamQuality) ?? StreamQuality.medium.index + ]; + final buffer = BufferSize.values[ + _prefs?.getInt(_SettingsKeys.bufferSize) ?? BufferSize.medium.index + ]; + final timeout = ConnectionTimeout.values[ + _prefs?.getInt(_SettingsKeys.connectionTimeout) ?? ConnectionTimeout.medium.index + ]; + final reconnect = _prefs?.getBool(_SettingsKeys.autoReconnect) ?? true; + final epgCache = EpgCacheDuration.values[ + _prefs?.getInt(_SettingsKeys.epgCacheDuration) ?? EpgCacheDuration.medium.index + ]; + final transcoding = TranscodingMode.values[ + _prefs?.getInt(_SettingsKeys.transcodingMode) ?? TranscodingMode.auto.index + ]; + final directPlay = _prefs?.getBool(_SettingsKeys.preferDirectPlay) ?? false; + state = IptvSettings( liveTvCategoryFilter: liveTv, moviesCategoryFilter: movies, seriesCategoryFilter: series, + streamQuality: quality, + bufferSize: buffer, + connectionTimeout: timeout, + autoReconnect: reconnect, + epgCacheDuration: epgCache, + transcodingMode: transcoding, + preferDirectPlay: directPlay, ); _initialized = true; - print('Settings loaded: LiveTV="$liveTv", Movies="$movies", Series="$series"'); + print('Settings loaded: LiveTV="$liveTv", Quality=${quality.name}'); } catch (e) { print('Error loading settings: $e'); } } - /// Save a setting to SharedPreferences + // ===== Save Helpers ===== + Future _saveString(String key, String value) async { try { _prefs ??= await SharedPreferences.getInstance(); @@ -125,41 +273,89 @@ class IptvSettingsNotifier extends StateNotifier { } } - /// Set Live TV category filter + Future _saveInt(String key, int value) async { + try { + _prefs ??= await SharedPreferences.getInstance(); + await _prefs?.setInt(key, value); + } catch (e) { + print('Error saving setting $key: $e'); + } + } + + Future _saveBool(String key, bool value) async { + try { + _prefs ??= await SharedPreferences.getInstance(); + await _prefs?.setBool(key, value); + } catch (e) { + print('Error saving setting $key: $e'); + } + } + + // ===== Filter Setters ===== + 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 + // ===== Streaming Setters ===== + + void setStreamQuality(StreamQuality quality) { + state = state.copyWith(streamQuality: quality); + _saveInt(_SettingsKeys.streamQuality, quality.index); + } + + void setBufferSize(BufferSize buffer) { + state = state.copyWith(bufferSize: buffer); + _saveInt(_SettingsKeys.bufferSize, buffer.index); + } + + void setConnectionTimeout(ConnectionTimeout timeout) { + state = state.copyWith(connectionTimeout: timeout); + _saveInt(_SettingsKeys.connectionTimeout, timeout.index); + } + + void setAutoReconnect(bool value) { + state = state.copyWith(autoReconnect: value); + _saveBool(_SettingsKeys.autoReconnect, value); + } + + void setEpgCacheDuration(EpgCacheDuration duration) { + state = state.copyWith(epgCacheDuration: duration); + _saveInt(_SettingsKeys.epgCacheDuration, duration.index); + } + + void setTranscodingMode(TranscodingMode mode) { + state = state.copyWith(transcodingMode: mode); + _saveInt(_SettingsKeys.transcodingMode, mode.index); + } + + void setPreferDirectPlay(bool value) { + state = state.copyWith(preferDirectPlay: value); + _saveBool(_SettingsKeys.preferDirectPlay, value); + } + + // Legacy methods void setCategoryFilter(String filter) => setLiveTvFilter(filter); void clearCategoryFilter() => clearLiveTvFilter(); } diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index f78aea4..e8a4937 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:http/http.dart' as http; import '../providers/xtream_provider.dart'; import '../providers/playback_positions_provider.dart'; +import '../providers/settings_provider.dart'; import '../widgets/epg_overlay.dart'; import '../../../core/models/playlist_config.dart'; @@ -111,11 +112,30 @@ class _PlayerScreenState extends ConsumerState { _statusMessage = 'Starting stream transcoding...'; }); - // Build the FFmpeg endpoint URL + // Build the FFmpeg endpoint URL with streaming settings + final settings = ref.read(iptvSettingsProvider); final baseUrl = html.window.location.origin; final iptvUrl = '${widget.playlist.dns}/live/${widget.playlist.username}/${widget.playlist.password}/${widget.streamId}.ts'; final encodedUrl = Uri.encodeComponent(iptvUrl); - final streamEndpoint = '$baseUrl/api/stream/${widget.streamId}?url=$encodedUrl'; + + // Map enum values to string params + final qualityParam = switch (settings.streamQuality) { + StreamQuality.low => 'low', + StreamQuality.medium => 'medium', + StreamQuality.high => 'high', + }; + final bufferParam = switch (settings.bufferSize) { + BufferSize.low => 'low', + BufferSize.medium => 'medium', + BufferSize.high => 'high', + }; + final timeoutParam = switch (settings.connectionTimeout) { + ConnectionTimeout.short => 'short', + ConnectionTimeout.medium => 'medium', + ConnectionTimeout.long => 'long', + }; + + final streamEndpoint = '$baseUrl/api/stream/${widget.streamId}?url=$encodedUrl&quality=$qualityParam&buffer=$bufferParam&timeout=$timeoutParam'; debugPrint('PlayerScreen: Starting FFmpeg stream: $streamEndpoint'); diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 6de8a26..147ea4b 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -237,7 +237,7 @@ class _LiveTVTabState extends ConsumerState // Proxy HTTP images through our server to avoid Mixed Content blocking if (hasIcon && channel.streamIcon.startsWith('http://')) { - iconUrl = '/api/xtream/${Uri.encodeComponent(channel.streamIcon)}'; + iconUrl = '/api/xtream/${channel.streamIcon}'; } final Widget placeholder = Container( diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart index 37edd41..444e504 100644 --- a/lib/features/iptv/widgets/settings_tab.dart +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../auth/providers/auth_provider.dart'; import '../providers/settings_provider.dart'; +import 'streaming_settings_tab.dart'; +/// Main Settings tab with sub-tabs for Filters and Streaming class SettingsTab extends ConsumerStatefulWidget { const SettingsTab({super.key}); @@ -12,7 +14,8 @@ class SettingsTab extends ConsumerStatefulWidget { ConsumerState createState() => _SettingsTabState(); } -class _SettingsTabState extends ConsumerState { +class _SettingsTabState extends ConsumerState with SingleTickerProviderStateMixin { + late TabController _tabController; late TextEditingController _liveTvController; late TextEditingController _moviesController; late TextEditingController _seriesController; @@ -21,6 +24,7 @@ class _SettingsTabState extends ConsumerState { @override void initState() { super.initState(); + _tabController = TabController(length: 2, vsync: this); _liveTvController = TextEditingController(); _moviesController = TextEditingController(); _seriesController = TextEditingController(); @@ -28,6 +32,7 @@ class _SettingsTabState extends ConsumerState { @override void dispose() { + _tabController.dispose(); _liveTvController.dispose(); _moviesController.dispose(); _seriesController.dispose(); @@ -57,6 +62,38 @@ class _SettingsTabState extends ConsumerState { if (!_initialized) _syncControllers(settings); }); + return Column( + children: [ + // TabBar + Container( + color: Theme.of(context).colorScheme.surface, + child: TabBar( + controller: _tabController, + tabs: const [ + Tab(icon: Icon(Icons.filter_list), text: 'Filtres'), + Tab(icon: Icon(Icons.stream), text: 'Streaming'), + ], + ), + ), + + // TabBarView + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + // Filters Tab + _buildFiltersTab(context, currentUser, settings), + + // Streaming Tab + const StreamingSettingsTab(), + ], + ), + ), + ], + ); + } + + Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) { return ListView( padding: const EdgeInsets.all(16), children: [ @@ -65,7 +102,7 @@ class _SettingsTabState extends ConsumerState { child: ListTile( leading: const Icon(Icons.person), title: Text( - 'Logged in as', + 'Connecté en tant que', style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey), ), subtitle: Text( @@ -84,7 +121,7 @@ class _SettingsTabState extends ConsumerState { // Category filters section Text( - 'Category Filters', + 'Filtres de Catégories', style: GoogleFonts.roboto( fontWeight: FontWeight.bold, fontSize: 16, @@ -92,7 +129,7 @@ class _SettingsTabState extends ConsumerState { ), const SizedBox(height: 4), Text( - 'Only categories containing one of these keywords will be shown. Separate with commas.', + 'Seules les catégories contenant un de ces mots-clés seront affichées. Séparez par des virgules.', style: GoogleFonts.roboto( fontSize: 12, color: Colors.grey.shade600, @@ -102,7 +139,7 @@ class _SettingsTabState extends ConsumerState { // Live TV Filter _buildFilterCard( - title: 'Live TV Filter', + title: 'Filtre TV Live', icon: Icons.live_tv, controller: _liveTvController, keywords: settings.liveTvKeywords, @@ -118,7 +155,7 @@ class _SettingsTabState extends ConsumerState { // Movies Filter _buildFilterCard( - title: 'Movies Filter', + title: 'Filtre Films', icon: Icons.movie, controller: _moviesController, keywords: settings.moviesKeywords, @@ -134,7 +171,7 @@ class _SettingsTabState extends ConsumerState { // Series Filter _buildFilterCard( - title: 'Series Filter', + title: 'Filtre Séries', icon: Icons.tv, controller: _seriesController, keywords: settings.seriesKeywords, @@ -153,7 +190,7 @@ class _SettingsTabState extends ConsumerState { Card( child: ListTile( leading: const Icon(Icons.admin_panel_settings), - title: Text('Admin Panel', style: GoogleFonts.roboto()), + title: Text('Panneau Admin', style: GoogleFonts.roboto()), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/admin'), ), @@ -165,7 +202,7 @@ class _SettingsTabState extends ConsumerState { Card( child: ListTile( leading: const Icon(Icons.playlist_play), - title: Text('Change Playlist', style: GoogleFonts.roboto()), + title: Text('Changer de Playlist', style: GoogleFonts.roboto()), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/playlists'), ), @@ -176,7 +213,7 @@ class _SettingsTabState extends ConsumerState { Card( child: ListTile( leading: const Icon(Icons.info_outline), - title: Text('About', style: GoogleFonts.roboto()), + title: Text('À propos', style: GoogleFonts.roboto()), subtitle: Text( 'XtremFlow IPTV v1.0.0', style: GoogleFonts.roboto(fontSize: 12), @@ -192,7 +229,7 @@ class _SettingsTabState extends ConsumerState { context.go('/login'); }, icon: const Icon(Icons.logout), - label: const Text('Logout'), + label: const Text('Déconnexion'), style: FilledButton.styleFrom( backgroundColor: Colors.red.shade700, padding: const EdgeInsets.symmetric(vertical: 16), diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart new file mode 100644 index 0000000..f786cff --- /dev/null +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../providers/settings_provider.dart'; + +/// Streaming settings tab for Live TV configuration +class StreamingSettingsTab extends ConsumerWidget { + const StreamingSettingsTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(iptvSettingsProvider); + final notifier = ref.read(iptvSettingsProvider.notifier); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Header + Text( + 'Paramètres Streaming Live TV', + style: GoogleFonts.roboto( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Ces paramètres s\'appliquent uniquement aux flux TV en direct.', + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 24), + + // Quality Section + _buildSectionCard( + context: context, + title: 'Qualité Vidéo', + icon: Icons.high_quality, + children: [ + _buildDropdownTile( + title: 'Qualité du flux', + subtitle: 'Bitrate: ${settings.bitrateKbps} kbps', + value: settings.streamQuality, + items: StreamQuality.values, + onChanged: (v) => notifier.setStreamQuality(v!), + labelBuilder: (v) => switch (v) { + StreamQuality.low => 'Faible (1.5 Mbps)', + StreamQuality.medium => 'Moyen (3 Mbps)', + StreamQuality.high => 'Élevé (5 Mbps)', + }, + ), + ], + ), + const SizedBox(height: 16), + + // Buffer Section + _buildSectionCard( + context: context, + title: 'Buffer & Latence', + icon: Icons.speed, + children: [ + _buildDropdownTile( + title: 'Taille du buffer', + subtitle: 'Segments HLS: ${settings.hlsSegmentDuration}s', + value: settings.bufferSize, + items: BufferSize.values, + onChanged: (v) => notifier.setBufferSize(v!), + labelBuilder: (v) => switch (v) { + BufferSize.low => 'Faible (latence min)', + BufferSize.medium => 'Moyen (équilibré)', + BufferSize.high => 'Élevé (stabilité max)', + }, + ), + ], + ), + const SizedBox(height: 16), + + // Connection Section + _buildSectionCard( + context: context, + title: 'Connexion', + icon: Icons.wifi, + children: [ + _buildDropdownTile( + title: 'Timeout de connexion', + subtitle: '${settings.timeoutSeconds} secondes', + value: settings.connectionTimeout, + items: ConnectionTimeout.values, + onChanged: (v) => notifier.setConnectionTimeout(v!), + labelBuilder: (v) => switch (v) { + ConnectionTimeout.short => 'Court (15s)', + ConnectionTimeout.medium => 'Moyen (30s)', + ConnectionTimeout.long => 'Long (60s)', + }, + ), + const Divider(height: 1), + SwitchListTile( + title: Text( + 'Reconnexion automatique', + style: GoogleFonts.roboto(fontSize: 14), + ), + subtitle: Text( + 'Se reconnecter en cas de coupure', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey), + ), + value: settings.autoReconnect, + onChanged: (v) => notifier.setAutoReconnect(v), + ), + ], + ), + const SizedBox(height: 16), + + // Transcoding Section + _buildSectionCard( + context: context, + title: 'Transcodage', + icon: Icons.transform, + children: [ + _buildDropdownTile( + title: 'Mode de transcodage', + subtitle: 'Comment le flux est traité', + value: settings.transcodingMode, + items: TranscodingMode.values, + onChanged: (v) => notifier.setTranscodingMode(v!), + labelBuilder: (v) => switch (v) { + TranscodingMode.auto => 'Auto (recommandé)', + TranscodingMode.forced => 'Forcé (toujours transcoder)', + TranscodingMode.disabled => 'Désactivé (direct)', + }, + ), + const Divider(height: 1), + SwitchListTile( + title: Text( + 'Préférer lecture directe', + style: GoogleFonts.roboto(fontSize: 14), + ), + subtitle: Text( + 'Utiliser le flux original si compatible', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey), + ), + value: settings.preferDirectPlay, + onChanged: (v) => notifier.setPreferDirectPlay(v), + ), + ], + ), + const SizedBox(height: 16), + + // Cache Section + _buildSectionCard( + context: context, + title: 'Cache EPG', + icon: Icons.schedule, + children: [ + _buildDropdownTile( + title: 'Durée du cache EPG', + subtitle: '${settings.epgCacheMinutes} minutes', + value: settings.epgCacheDuration, + items: EpgCacheDuration.values, + onChanged: (v) => notifier.setEpgCacheDuration(v!), + labelBuilder: (v) => switch (v) { + EpgCacheDuration.short => 'Court (5 min)', + EpgCacheDuration.medium => 'Moyen (15 min)', + EpgCacheDuration.long => 'Long (1 heure)', + }, + ), + ], + ), + const SizedBox(height: 32), + + // Info card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.blue), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Les changements prennent effet immédiatement pour les nouveaux flux.', + style: GoogleFonts.roboto( + fontSize: 12, + color: Colors.blue.shade700, + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildSectionCard({ + required BuildContext context, + required String title, + required IconData icon, + required List children, + }) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: GoogleFonts.roboto( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const Divider(height: 1), + ...children, + ], + ), + ); + } + + Widget _buildDropdownTile({ + required String title, + required String subtitle, + required T value, + required List items, + required ValueChanged onChanged, + required String Function(T) labelBuilder, + }) { + return ListTile( + title: Text(title, style: GoogleFonts.roboto(fontSize: 14)), + subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey)), + trailing: DropdownButton( + value: value, + underline: const SizedBox(), + items: items.map((item) { + return DropdownMenuItem( + value: item, + child: Text( + labelBuilder(item), + style: GoogleFonts.roboto(fontSize: 13), + ), + ); + }).toList(), + onChanged: onChanged, + ), + ); + } +} diff --git a/web/favicon.png b/web/favicon.png index cac21c3..7be658c 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index cac21c3..7be658c 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index cac21c3..7be658c 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ