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 a01bbb1a1c
commit b76dc6995b
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 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)
// Many IPTV servers check User-Agent to prevent web playback
final proxyHeaders = {
// Use VLC Media Player User-Agent - most IPTV servers whitelist this
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
'Accept': '*/*',
'Accept-Encoding': 'identity',
'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;
if (request.method == 'GET') {
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
///
/// Handles both relative and absolute URLs in HLS playlists
@@ -355,14 +405,22 @@ Handler _createStreamHandler() {
// Start FFmpeg process with VLC User-Agent
// Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome)
// Using fast preset for lower CPU usage
// Added resilience flags for corrupted/discontinuous streams
final process = await Process.start('ffmpeg', [
'-y', // Overwrite output files
'-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',
'-headers', 'Accept: */*\r\nConnection: keep-alive\r\n',
'-reconnect', '1',
'-reconnect_streamed', '1',
'-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,
// Video: transcode to H.264 for browser compatibility
'-c:v', 'libx264',
@@ -373,10 +431,13 @@ Handler _createStreamHandler() {
'-b:v', '2500k', // 2.5 Mbps video bitrate
'-maxrate', '3000k',
'-bufsize', '6000k',
'-vsync', 'passthrough', // Pass through timestamps as-is (fixes discontinuity)
'-avoid_negative_ts', 'make_zero', // Handle negative timestamps
// Audio: transcode to AAC
'-c:a', 'aac',
'-b:a', '128k',
'-ar', '44100',
'-async', '1', // Audio sync (stretch/compression allowed)
// HLS output settings
'-f', 'hls',
'-hls_time', '2', // 2 second segments
@@ -1,55 +1,167 @@
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 {
/// Category filter keywords (comma-separated)
/// Example: "FR,FRANCE,HD" will show only categories containing one of these words
final String categoryFilter;
/// Category filter keywords for Live TV (comma-separated)
final String liveTvCategoryFilter;
/// Category filter keywords for Movies (comma-separated)
final String moviesCategoryFilter;
/// Category filter keywords for Series (comma-separated)
final String seriesCategoryFilter;
const IptvSettings({
this.categoryFilter = '',
this.liveTvCategoryFilter = '',
this.moviesCategoryFilter = '',
this.seriesCategoryFilter = '',
});
IptvSettings copyWith({
String? categoryFilter,
String? liveTvCategoryFilter,
String? moviesCategoryFilter,
String? seriesCategoryFilter,
}) {
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)
List<String> get filterKeywords {
if (categoryFilter.isEmpty) return [];
return categoryFilter
/// Get list of filter keywords for a given filter string
static List<String> _parseKeywords(String filter) {
if (filter.isEmpty) return [];
return filter
.split(',')
.map((s) => s.trim().toUpperCase())
.where((s) => s.isNotEmpty)
.toList();
}
/// Check if a category name matches the filter
/// Returns true if no filter is set or if the category contains any keyword
bool matchesFilter(String categoryName) {
final keywords = filterKeywords;
/// Get live TV filter keywords
List<String> get liveTvKeywords => _parseKeywords(liveTvCategoryFilter);
/// 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;
final upperName = categoryName.toUpperCase();
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> {
IptvSettingsNotifier() : super(const IptvSettings());
SharedPreferences? _prefs;
bool _initialized = false;
void setCategoryFilter(String filter) {
state = state.copyWith(categoryFilter: filter);
IptvSettingsNotifier() : super(const IptvSettings()) {
_loadSettings();
}
void clearCategoryFilter() {
state = state.copyWith(categoryFilter: '');
/// Load settings from SharedPreferences
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
+3 -3
View File
@@ -70,8 +70,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Filter categories based on keywords
var categories = groupedChannels.keys.toList();
if (settings.filterKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesFilter(cat)).toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
}
categories.sort();
@@ -88,7 +88,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
),
const SizedBox(height: 8),
Text(
'Filter: ${settings.categoryFilter}',
'Filter: ${settings.liveTvCategoryFilter}',
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 '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/player_screen.dart';
class MoviesTab extends ConsumerStatefulWidget {
@@ -78,10 +79,38 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
@override
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) {
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(
controller: _scrollController,
padding: const EdgeInsets.all(8),
@@ -91,13 +120,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
mainAxisSpacing: 6,
childAspectRatio: 0.65,
),
itemCount: _movies.length + (_hasMore ? 1 : 0),
itemCount: filteredMovies.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _movies.length) {
if (index >= filteredMovies.length) {
return const Center(child: CircularProgressIndicator());
}
final movie = _movies[index];
final movie = filteredMovies[index];
return _MovieCard(
movie: movie,
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 '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/series_detail_screen.dart';
class SeriesTab extends ConsumerStatefulWidget {
@@ -78,10 +79,38 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
@override
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) {
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(
controller: _scrollController,
padding: const EdgeInsets.all(8),
@@ -91,13 +120,13 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
mainAxisSpacing: 6,
childAspectRatio: 0.65,
),
itemCount: _series.length + (_hasMore ? 1 : 0),
itemCount: filteredSeries.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _series.length) {
if (index >= filteredSeries.length) {
return const Center(child: CircularProgressIndicator());
}
final serie = _series[index];
final serie = filteredSeries[index];
return _SeriesCard(series: serie, playlist: widget.playlist);
},
);
+164 -83
View File
@@ -13,29 +13,49 @@ class SettingsTab extends ConsumerStatefulWidget {
}
class _SettingsTabState extends ConsumerState<SettingsTab> {
late TextEditingController _filterController;
late TextEditingController _liveTvController;
late TextEditingController _moviesController;
late TextEditingController _seriesController;
bool _initialized = false;
@override
void initState() {
super.initState();
_filterController = TextEditingController();
_liveTvController = TextEditingController();
_moviesController = TextEditingController();
_seriesController = TextEditingController();
}
@override
void dispose() {
_filterController.dispose();
_liveTvController.dispose();
_moviesController.dispose();
_seriesController.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
Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser;
final settings = ref.watch(iptvSettingsProvider);
// Sync controller with state
if (_filterController.text != settings.categoryFilter) {
_filterController.text = settings.categoryFilter;
}
// Sync controllers with persisted state (only on first load)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings);
});
return ListView(
padding: const EdgeInsets.all(16),
@@ -62,84 +82,70 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
),
const SizedBox(height: 16),
// Category filter section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(),
),
],
],
),
// Category filters section
Text(
'Category Filters',
style: GoogleFonts.roboto(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
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),
// 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
intl: ^0.19.0
equatable: ^2.0.5
shared_preferences: ^2.2.2
# Server dependencies (for backend proxy)
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