mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-21 14:08:04 +02:00
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:
1 parent
b1f887af69
commit
829c5751ca
6 files changed
+91
-59
No files matched your search
@@ -1,10 +1,10 @@
|
|||||||
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
|
||||||
///
|
///
|
||||||
@@ -16,10 +16,12 @@ class XtreamService {
|
|||||||
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
|
||||||
@@ -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 {
|
||||||
@@ -346,7 +339,9 @@ 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);
|
||||||
@@ -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 {
|
||||||
@@ -423,7 +419,9 @@ 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);
|
||||||
@@ -527,8 +525,8 @@ class XtreamService {
|
|||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user