feat: implement initial mobile IPTV feature with player screen, live TV, movies, and series tabs, along with the Xtream API service.

This commit is contained in:
Michael committed 2026-03-09 13:16:29 +01:00
1 parent b1f887af69
commit 829c5751ca
6 files changed
+112 -80

No files matched your search

+56 -57
View File
@@ -1,25 +1,27 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:flutter/foundation.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../models/xtream_models.dart' as xm; import '../models/xtream_models.dart' as xm;
import 'dart:html' as html;
/// Xtream Codes API Service /// Xtream Codes API Service
/// ///
/// Handles all communication with Xtream API servers /// Handles all communication with Xtream API servers
class XtreamService { class XtreamService {
late final Dio _dio; late final Dio _dio;
late final CacheOptions _cacheOptions; late final CacheOptions _cacheOptions;
PlaylistConfig? _currentPlaylist; PlaylistConfig? _currentPlaylist;
XtreamService() { XtreamService() {
_dio = Dio(BaseOptions( _dio = Dio(
connectTimeout: const Duration(seconds: 15), BaseOptions(
receiveTimeout: const Duration(seconds: 15), connectTimeout: const Duration(seconds: 15),
),); receiveTimeout: const Duration(seconds: 15),
),
);
// Setup caching for API responses // Setup caching for API responses
_cacheOptions = CacheOptions( _cacheOptions = CacheOptions(
@@ -32,22 +34,21 @@ class XtreamService {
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
} }
/// Check if we're running on HTTPS (production) /// Get dynamic base URL for the local backend
bool get _isHttps { /// On Web: use localhost (proxy handles it)
return html.window.location.protocol == 'https:'; /// On Mobile: default to host machine IP for emulator
String get _backendBaseUrl {
if (kIsWeb) return 'http://localhost:8089';
// On Android emulator, 10.0.2.2 points to host machine's localhost
return 'http://10.0.2.2:8089';
} }
String get backendBaseUrl => _backendBaseUrl;
/// Wrap URL with proxy for all external IPTV URLs /// Wrap URL with proxy for all external IPTV URLs
///
/// Always use proxy for HTTP URLs to:
/// 1. Handle HTTPS->HTTP mixed content
/// 2. Provide Range header support for seeking
/// 3. Normalize headers and handle redirects
String _wrapWithProxy(String url) { String _wrapWithProxy(String url) {
// Always proxy HTTP URLs for consistent streaming behavior if (url.startsWith('http')) {
if (url.startsWith('http://')) { return '$_backendBaseUrl/api/xtream/$url';
final baseUrl = html.window.location.origin;
return '$baseUrl/api/xtream/$url';
} }
return url; return url;
} }
@@ -60,28 +61,19 @@ class XtreamService {
/// Generate stream URL for live TV /// Generate stream URL for live TV
String getLiveStreamUrl(String streamId) { String getLiveStreamUrl(String streamId) {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/live/$streamId.ts';
// Point to new dedicated Live API
final baseUrl = html.window.location.origin;
return '$baseUrl/api/live/$streamId.ts';
} }
/// Generate stream URL for VOD (movies) /// Generate stream URL for VOD (movies)
String getVodStreamUrl(String streamId, String containerExtension) { String getVodStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8';
// Point to new dedicated VOD API (HLS playlist)
final baseUrl = html.window.location.origin;
return '$baseUrl/api/vod/$streamId/playlist.m3u8';
} }
/// Generate stream URL for series episodes /// Generate stream URL for series episodes
String getSeriesStreamUrl(String streamId, String containerExtension) { String getSeriesStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8?type=series';
// Point to new dedicated VOD API (HLS playlist) with series type
final baseUrl = html.window.location.origin;
return '$baseUrl/api/vod/$streamId/playlist.m3u8?type=series';
} }
/// Authenticate and get server info /// Authenticate and get server info
@@ -138,7 +130,7 @@ class XtreamService {
} }
/// Get all live TV channels grouped by category /// Get all live TV channels grouped by category
/// ///
/// Returns a Map where key is category name and value is list of channels /// Returns a Map where key is category name and value is list of channels
Future<Map<String, List<Channel>>> getLiveChannels() async { Future<Map<String, List<Channel>>> getLiveChannels() async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
@@ -165,10 +157,10 @@ class XtreamService {
// Get category name from our mapping using category_id // Get category name from our mapping using category_id
final categoryId = data['category_id']?.toString() ?? ''; final categoryId = data['category_id']?.toString() ?? '';
final categoryName = categoryMap[categoryId] ?? 'Uncategorized'; final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
// Inject category_name into data before parsing // Inject category_name into data before parsing
data['category_name'] = categoryName; data['category_name'] = categoryName;
final channel = Channel.fromJson(data); final channel = Channel.fromJson(data);
if (!groupedChannels.containsKey(categoryName)) { if (!groupedChannels.containsKey(categoryName)) {
@@ -326,7 +318,8 @@ class XtreamService {
} }
/// Get movies with pagination support /// Get movies with pagination support
Future<List<xm.Movie>> getMoviesPaginated({int offset = 0, int limit = 100}) async { Future<List<xm.Movie>> getMoviesPaginated(
{int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
@@ -344,13 +337,15 @@ class XtreamService {
); );
final List<dynamic> allMovies = response.data as List<dynamic>; final List<dynamic> allMovies = response.data as List<dynamic>;
// Apply pagination // Apply pagination
final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit; final endIndex = (offset + limit) > allMovies.length
? allMovies.length
: offset + limit;
if (offset >= allMovies.length) return []; if (offset >= allMovies.length) return [];
final paginatedMovies = allMovies.sublist(offset, endIndex); final paginatedMovies = allMovies.sublist(offset, endIndex);
return paginatedMovies.map((movieData) { return paginatedMovies.map((movieData) {
final data = movieData as Map<String, dynamic>; final data = movieData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? ''; final categoryId = data['category_id']?.toString() ?? '';
@@ -382,7 +377,7 @@ class XtreamService {
final List<dynamic> allMovies = response.data as List<dynamic>; final List<dynamic> allMovies = response.data as List<dynamic>;
final queryLower = query.toLowerCase(); final queryLower = query.toLowerCase();
// Filter by search query // Filter by search query
return allMovies return allMovies
.where((m) { .where((m) {
@@ -403,7 +398,8 @@ class XtreamService {
} }
/// Get series with pagination support (returns flat list) /// Get series with pagination support (returns flat list)
Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async { Future<List<xm.Series>> getSeriesPaginated(
{int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
@@ -421,13 +417,15 @@ class XtreamService {
); );
final List<dynamic> allSeries = response.data as List<dynamic>; final List<dynamic> allSeries = response.data as List<dynamic>;
// Apply pagination // Apply pagination
final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit; final endIndex = (offset + limit) > allSeries.length
? allSeries.length
: offset + limit;
if (offset >= allSeries.length) return []; if (offset >= allSeries.length) return [];
final paginatedSeries = allSeries.sublist(offset, endIndex); final paginatedSeries = allSeries.sublist(offset, endIndex);
return paginatedSeries.map((seriesData) { return paginatedSeries.map((seriesData) {
final data = seriesData as Map<String, dynamic>; final data = seriesData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? ''; final categoryId = data['category_id']?.toString() ?? '';
@@ -459,7 +457,7 @@ class XtreamService {
final List<dynamic> allSeries = response.data as List<dynamic>; final List<dynamic> allSeries = response.data as List<dynamic>;
final queryLower = query.toLowerCase(); final queryLower = query.toLowerCase();
// Filter by search query // Filter by search query
return allSeries return allSeries
.where((s) { .where((s) {
@@ -502,7 +500,7 @@ class XtreamService {
} }
/// Get VOD (movie) info including duration /// Get VOD (movie) info including duration
/// ///
/// Returns detailed movie information including duration in seconds /// Returns detailed movie information including duration in seconds
Future<int?> getVodDuration(String vodId) async { Future<int?> getVodDuration(String vodId) async {
if (_currentPlaylist == null) return null; if (_currentPlaylist == null) return null;
@@ -520,18 +518,18 @@ class XtreamService {
); );
final data = response.data as Map<String, dynamic>; final data = response.data as Map<String, dynamic>;
// Try to get duration from movie_properties or info // Try to get duration from movie_properties or info
final movieData = data['movie_data'] as Map<String, dynamic>?; final movieData = data['movie_data'] as Map<String, dynamic>?;
final info = data['info'] as Map<String, dynamic>?; final info = data['info'] as Map<String, dynamic>?;
// Duration can be in different formats: seconds (int), "HH:MM:SS", or minutes // Duration can be in different formats: seconds (int), "HH:MM:SS", or minutes
String? durationStr = movieData?['duration']?.toString() ?? String? durationStr = movieData?['duration']?.toString() ??
info?['duration']?.toString() ?? info?['duration']?.toString() ??
info?['duration_secs']?.toString(); info?['duration_secs']?.toString();
if (durationStr == null || durationStr.isEmpty) return null; if (durationStr == null || durationStr.isEmpty) return null;
// Parse duration - could be "01:30:00" format or seconds // Parse duration - could be "01:30:00" format or seconds
if (durationStr.contains(':')) { if (durationStr.contains(':')) {
// HH:MM:SS format // HH:MM:SS format
@@ -548,7 +546,7 @@ class XtreamService {
return minutes * 60 + seconds; return minutes * 60 + seconds;
} }
} }
// Try parsing as seconds directly // Try parsing as seconds directly
return int.tryParse(durationStr); return int.tryParse(durationStr);
} catch (e) { } catch (e) {
@@ -558,7 +556,7 @@ class XtreamService {
} }
/// Get short EPG for a specific stream /// Get short EPG for a specific stream
/// ///
/// Returns "Now" and "Next" program info /// Returns "Now" and "Next" program info
Future<List<EpgEntry>> getShortEpg(String streamId) async { Future<List<EpgEntry>> getShortEpg(String streamId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
@@ -585,7 +583,8 @@ class XtreamService {
return []; return [];
} }
final List<dynamic> epgData = response.data['epg_listings'] as List<dynamic>; final List<dynamic> epgData =
response.data['epg_listings'] as List<dynamic>;
return epgData return epgData
.map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>)) .map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../features/iptv/screens/player_screen.dart'; import '../../../../features/iptv/screens/player_screen.dart';
export '../../../../features/iptv/screens/player_screen.dart' show StreamType; export '../../../../features/iptv/screens/player_screen.dart' show StreamType;
import '../../../../core/models/playlist_config.dart'; import '../../../../core/models/playlist_config.dart';
import '../../../../features/iptv/widgets/lite_player_view.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
/// Mobile wrapper for the PlayerScreen. class MobilePlayerScreen extends ConsumerWidget {
/// Currently reuses the desktop PlayerScreen as it adapts well to mobile (video player),
/// but allows for future mobile-specific overlays or gesture controls.
class MobilePlayerScreen extends StatelessWidget {
final String streamId; final String streamId;
final String title; final String title;
final StreamType streamType; final StreamType streamType;
@@ -23,15 +23,35 @@ class MobilePlayerScreen extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
// For now, we reuse the existing PlayerScreen which is responsive. final service = ref.read(xtreamServiceProvider(playlist));
// In the future, we can add Mobile-specific gesture detectors here. String streamUrl = '';
return PlayerScreen(
streamId: streamId, if (streamType == StreamType.live) {
title: title, streamUrl = service.getLiveStreamUrl(streamId);
playlist: playlist, } else if (streamType == StreamType.vod) {
streamType: streamType, streamUrl = service.getVodStreamUrl(streamId, containerExtension);
containerExtension: containerExtension, } else if (streamType == StreamType.series) {
streamUrl = service.getSeriesStreamUrl(streamId, containerExtension);
}
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(title, style: const TextStyle(color: Colors.white)),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
body: Center(
child: LitePlayerView(
streamUrl: streamUrl,
isLive: streamType == StreamType.live,
),
),
); );
} }
} }
@@ -53,7 +53,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
.where((cat) => settings.matchesLiveTvFilter(cat)) .where((cat) => settings.matchesLiveTvFilter(cat))
.toList(); .toList();
} }
categories.sort(); // REMOVED alphabetic sort to respect server/settings order
List<Channel> displayedChannels = []; List<Channel> displayedChannels = [];
if (_searchQuery.isNotEmpty) { if (_searchQuery.isNotEmpty) {
@@ -70,6 +70,12 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
if (_selectedCategory == null && categories.isNotEmpty) { if (_selectedCategory == null && categories.isNotEmpty) {
_selectedCategory = categories.first; _selectedCategory = categories.first;
} }
// If the selected category is no longer available (due to filters), reset it
if (_selectedCategory != null &&
!categories.contains(_selectedCategory)) {
_selectedCategory =
categories.isNotEmpty ? categories.first : null;
}
if (_selectedCategory != null) { if (_selectedCategory != null) {
displayedChannels = groupedChannels[_selectedCategory] ?? []; displayedChannels = groupedChannels[_selectedCategory] ?? [];
} }
@@ -129,10 +129,12 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return rating; return rating;
} }
String _getProxiedImageUrl(String? originalUrl) { String _getProxiedImageUrl(String? originalUrl, WidgetRef ref) {
if (originalUrl == null || originalUrl.isEmpty) return ''; if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) { if (originalUrl.startsWith('http')) {
return '/api/xtream/$originalUrl'; final service = ref.read(xtreamServiceProvider(widget.playlist));
// Re-use core _wrapWithProxy logic (via a new public method or direct construction)
return '${service.backendBaseUrl}/api/xtream/$originalUrl';
} }
return originalUrl; return originalUrl;
} }
@@ -176,7 +178,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
(m) => HeroItem( (m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon), imageUrl: _getProxiedImageUrl(m.streamIcon, ref),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () => _playMovie(m), onMoreInfo: () => _playMovie(m),
), ),
@@ -296,7 +298,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
child: movie.streamIcon != null && child: movie.streamIcon != null &&
movie.streamIcon!.isNotEmpty movie.streamIcon!.isNotEmpty
? Image.network( ? Image.network(
_getProxiedImageUrl(movie.streamIcon), _getProxiedImageUrl(movie.streamIcon, ref),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
Container(color: Colors.white10), Container(color: Colors.white10),
@@ -127,10 +127,11 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
return rating; return rating;
} }
String _getProxiedImageUrl(String? originalUrl) { String _getProxiedImageUrl(String? originalUrl, WidgetRef ref) {
if (originalUrl == null || originalUrl.isEmpty) return ''; if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) { if (originalUrl.startsWith('http')) {
return '/api/xtream/$originalUrl'; final service = ref.read(xtreamServiceProvider(widget.playlist));
return '${service.backendBaseUrl}/api/xtream/$originalUrl';
} }
return originalUrl; return originalUrl;
} }
@@ -168,7 +169,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
(s) => HeroItem( (s) => HeroItem(
id: s.seriesId.toString(), id: s.seriesId.toString(),
title: s.name, title: s.name,
imageUrl: _getProxiedImageUrl(s.cover), imageUrl: _getProxiedImageUrl(s.cover, ref),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s), onMoreInfo: () => _openSeries(s),
), ),
+4
View File
@@ -17,6 +17,8 @@ Refonte du Guide TV pour inclure les catégories et corriger l'EPG.
- [x] Suppression du tri alphabétique forcé - [x] Suppression du tri alphabétique forcé
- [x] Refonte du Guide TV (Catégories + EPG détaillé) - [x] Refonte du Guide TV (Catégories + EPG détaillé)
- [x] Correction de l'endpoint EPG Backend (Fallback) - [x] Correction de l'endpoint EPG Backend (Fallback)
- [x] Correction du lecteur Mobile (LitePlayerView)
- [x] Alignement UI Mobile (Tri, EPG, URLs)
## Progress Log ## Progress Log
@@ -28,3 +30,5 @@ Refonte du Guide TV pour inclure les catégories et corriger l'EPG.
- [x] Suppression du tri alphabétique forcé - [x] Suppression du tri alphabétique forcé
- [x] Refonte du Guide TV avec catégories - [x] Refonte du Guide TV avec catégories
- [x] Implémentation du fallback EPG Backend vers get_short_epg - [x] Implémentation du fallback EPG Backend vers get_short_epg
- [x] Diagnostic de l'incompatibilité du lecteur mobile
- [x] Remplacement du lecteur Web par LitePlayerView sur mobile