feat: Implement MobileMoviesTab for displaying IPTV movies with category browsing and search functionality.

This commit is contained in:
Michael committed 2026-03-27 12:10:23 +01:00
1 parent 0010fe12ba
commit f85f43e42f
17 files changed
+268 -136

No files matched your search

+2 -2
View File
@@ -9,6 +9,7 @@
/// - Result: Same hostname resolved twice, wasting network calls /// - Result: Same hostname resolved twice, wasting network calls
/// ///
/// Solution: Single unified service with shared cache /// Solution: Single unified service with shared cache
library;
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -166,6 +167,5 @@ class _DnsCacheEntry {
_DnsCacheEntry(this.ip) : timestamp = DateTime.now(); _DnsCacheEntry(this.ip) : timestamp = DateTime.now();
bool get isExpired => bool get isExpired => DateTime.now().difference(timestamp).inSeconds > 3600;
DateTime.now().difference(timestamp).inSeconds > 3600;
} }
@@ -35,10 +35,14 @@ class StreamResolverPattern {
} }
// Parallel resolution attempts // Parallel resolution attempts
final results = await Future.wait([ final results = await Future.wait(
_validateUrl(streamUrl).timeout(timeout, onTimeout: () => false), [
_performDnsResolution(streamUrl).timeout(timeout, onTimeout: () => false), _validateUrl(streamUrl).timeout(timeout, onTimeout: () => false),
], eagerError: false).catchError((_) => [false, false]); _performDnsResolution(streamUrl)
.timeout(timeout, onTimeout: () => false),
],
eagerError: false,
).catchError((_) => [false, false]);
final isValid = results[0]; final isValid = results[0];
final dnsResolved = results[1]; final dnsResolved = results[1];
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
/// Video player selection and wrapper pattern /// Video player selection and wrapper pattern
/// Abstracts between MediaKit and other video players /// Abstracts between MediaKit and other video players
+1
View File
@@ -2,6 +2,7 @@
/// ///
/// Detects device RAM and adjusts player buffer size accordingly /// Detects device RAM and adjusts player buffer size accordingly
/// to prevent OOM crashes on low-end devices /// to prevent OOM crashes on low-end devices
library;
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
+26 -13
View File
@@ -13,7 +13,7 @@ class HiveEncryptionBenchmark {
String valueSize = 'medium', // small, medium, large String valueSize = 'medium', // small, medium, large
}) async { }) async {
if (kDebugMode) { if (kDebugMode) {
print('\n' + ('=' * 60)); print('\n${'=' * 60}');
print('🔐 HIVE ENCRYPTION BENCHMARK'); print('🔐 HIVE ENCRYPTION BENCHMARK');
print('=' * 60); print('=' * 60);
print('Write Operations: $writeCount'); print('Write Operations: $writeCount');
@@ -42,7 +42,10 @@ class HiveEncryptionBenchmark {
} }
static Future<void> _benchmarkUnencrypted( static Future<void> _benchmarkUnencrypted(
int writes, int reads, String testValue) async { int writes,
int reads,
String testValue,
) async {
const boxName = 'benchmark_unencrypted'; const boxName = 'benchmark_unencrypted';
try { try {
// Clean up if exists // Clean up if exists
@@ -62,7 +65,7 @@ class HiveEncryptionBenchmark {
// Read benchmark // Read benchmark
final readStop = Stopwatch()..start(); final readStop = Stopwatch()..start();
for (int i = 0; i < reads; i++) { for (int i = 0; i < reads; i++) {
await box.get('key_${i % writes}'); box.get('key_${i % writes}');
} }
readStop.stop(); readStop.stop();
@@ -93,7 +96,10 @@ class HiveEncryptionBenchmark {
} }
static Future<void> _benchmarkEncrypted( static Future<void> _benchmarkEncrypted(
int writes, int reads, String testValue) async { int writes,
int reads,
String testValue,
) async {
const boxName = 'benchmark_encrypted'; const boxName = 'benchmark_encrypted';
try { try {
// Clean up if exists // Clean up if exists
@@ -118,7 +124,7 @@ class HiveEncryptionBenchmark {
// Read benchmark // Read benchmark
final readStop = Stopwatch()..start(); final readStop = Stopwatch()..start();
for (int i = 0; i < reads; i++) { for (int i = 0; i < reads; i++) {
await box.get('key_${i % writes}'); box.get('key_${i % writes}');
} }
readStop.stop(); readStop.stop();
@@ -157,21 +163,25 @@ class HiveEncryptionBenchmark {
final readOverhead = ((enc.readTimeMs / unenc.readTimeMs - 1) * 100); final readOverhead = ((enc.readTimeMs / unenc.readTimeMs - 1) * 100);
if (kDebugMode) { if (kDebugMode) {
print('\n' + ('=' * 60)); print('\n${'=' * 60}');
print('📊 PERFORMANCE COMPARISON'); print('📊 PERFORMANCE COMPARISON');
print('=' * 60); print('=' * 60);
print('\nWrite Performance:'); print('\nWrite Performance:');
print( print(
' Unencrypted: ${unenc.writeTimeMs}ms (${(unenc.writeTimeMs / unenc.operationCount).toStringAsFixed(2)}µs/op)'); ' Unencrypted: ${unenc.writeTimeMs}ms (${(unenc.writeTimeMs / unenc.operationCount).toStringAsFixed(2)}µs/op)',
);
print( print(
' Encrypted: ${enc.writeTimeMs}ms (${(enc.writeTimeMs / enc.operationCount).toStringAsFixed(2)}µs/op)'); ' Encrypted: ${enc.writeTimeMs}ms (${(enc.writeTimeMs / enc.operationCount).toStringAsFixed(2)}µs/op)',
);
print(' Overhead: ${writeOverhead.toStringAsFixed(1)}%'); print(' Overhead: ${writeOverhead.toStringAsFixed(1)}%');
print('\nRead Performance:'); print('\nRead Performance:');
print( print(
' Unencrypted: ${unenc.readTimeMs}ms (${(unenc.readTimeMs / unenc.operationCount).toStringAsFixed(2)}µs/op)'); ' Unencrypted: ${unenc.readTimeMs}ms (${(unenc.readTimeMs / unenc.operationCount).toStringAsFixed(2)}µs/op)',
);
print( print(
' Encrypted: ${enc.readTimeMs}ms (${(enc.readTimeMs / enc.operationCount).toStringAsFixed(2)}µs/op)'); ' Encrypted: ${enc.readTimeMs}ms (${(enc.readTimeMs / enc.operationCount).toStringAsFixed(2)}µs/op)',
);
print(' Overhead: ${readOverhead.toStringAsFixed(1)}%'); print(' Overhead: ${readOverhead.toStringAsFixed(1)}%');
// Status // Status
@@ -180,8 +190,10 @@ class HiveEncryptionBenchmark {
final readStatus = readOverhead > acceptableThreshold ? '⚠️' : '✅'; final readStatus = readOverhead > acceptableThreshold ? '⚠️' : '✅';
print('\nStatus:'); print('\nStatus:');
print(' $writeStatus Write overhead: ${writeOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)'); print(
print(' $readStatus Read overhead: ${readOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)'); ' $writeStatus Write overhead: ${writeOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)');
print(
' $readStatus Read overhead: ${readOverhead.toStringAsFixed(1)}% (target: <$acceptableThreshold%)');
print('=' * 60 + '\n'); print('=' * 60 + '\n');
} }
} }
@@ -229,7 +241,8 @@ class EncryptionBenchmarkResult {
void print() { void print() {
print(' Name: $name'); print(' Name: $name');
print(' Write: ${writeTimeMs}ms (${writePerOpUs.toStringAsFixed(2)}µs/op)'); print(
' Write: ${writeTimeMs}ms (${writePerOpUs.toStringAsFixed(2)}µs/op)');
print(' Read: ${readTimeMs}ms (${readPerOpUs.toStringAsFixed(2)}µs/op)'); print(' Read: ${readTimeMs}ms (${readPerOpUs.toStringAsFixed(2)}µs/op)');
print(' Delete: ${deleteTimeMs}ms'); print(' Delete: ${deleteTimeMs}ms');
} }
+2 -1
View File
@@ -5,6 +5,7 @@
/// but displays them at 40-100px sizes, wasting memory and disk space. /// but displays them at 40-100px sizes, wasting memory and disk space.
/// ///
/// Solution: Specify memCacheWidth/Height for display size, limiting disk cache. /// Solution: Specify memCacheWidth/Height for display size, limiting disk cache.
library;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -29,7 +30,7 @@ class ImageCacheConfigs {
static const ImageCacheConfig channelIcon = ImageCacheConfig( static const ImageCacheConfig channelIcon = ImageCacheConfig(
displayWidth: 40, displayWidth: 40,
displayHeight: 40, displayHeight: 40,
cacheWidth: 50, // Slight buffer for upscaling cacheWidth: 50, // Slight buffer for upscaling
cacheHeight: 50, cacheHeight: 50,
); );
+11 -8
View File
@@ -35,7 +35,8 @@ class MemoryProfiler {
count++; count++;
}); });
if (kDebugMode) { if (kDebugMode) {
print('⏰ PERIODIC MEMORY MONITORING STARTED (${interval.inSeconds}s interval)'); print(
'⏰ PERIODIC MEMORY MONITORING STARTED (${interval.inSeconds}s interval)');
} }
} }
@@ -50,9 +51,10 @@ class MemoryProfiler {
/// Analyze memory growth between snapshots /// Analyze memory growth between snapshots
static MemoryAnalysis analyzeGrowth(String startLabel, String endLabel) { static MemoryAnalysis analyzeGrowth(String startLabel, String endLabel) {
final start = final start = _snapshots.firstWhere((s) => s.label == startLabel,
_snapshots.firstWhere((s) => s.label == startLabel, orElse: () => _snapshots.first); orElse: () => _snapshots.first);
final end = _snapshots.firstWhere((s) => s.label == endLabel, orElse: () => _snapshots.last); final end = _snapshots.firstWhere((s) => s.label == endLabel,
orElse: () => _snapshots.last);
return MemoryAnalysis( return MemoryAnalysis(
startLabel: startLabel, startLabel: startLabel,
@@ -65,7 +67,7 @@ class MemoryProfiler {
/// Generate report /// Generate report
static void printReport() { static void printReport() {
if (kDebugMode) { if (kDebugMode) {
print('\n' + ('=' * 60)); print('\n${'=' * 60}');
print('📊 MEMORY PROFILING REPORT'); print('📊 MEMORY PROFILING REPORT');
print('=' * 60); print('=' * 60);
for (int i = 0; i < _snapshots.length; i++) { for (int i = 0; i < _snapshots.length; i++) {
@@ -75,19 +77,20 @@ class MemoryProfiler {
// Show growth analysis // Show growth analysis
if (_snapshots.length >= 2) { if (_snapshots.length >= 2) {
print('\n' + ('-' * 60)); print('\n${'-' * 60}');
print('📈 GROWTH ANALYSIS'); print('📈 GROWTH ANALYSIS');
print('-' * 60); print('-' * 60);
for (int i = 0; i < _snapshots.length - 1; i++) { for (int i = 0; i < _snapshots.length - 1; i++) {
final snapshot = _snapshots[i]; final snapshot = _snapshots[i];
final nextSnapshot = _snapshots[i + 1]; final nextSnapshot = _snapshots[i + 1];
final growth = nextSnapshot.description.compareTo(snapshot.description); final growth =
nextSnapshot.description.compareTo(snapshot.description);
print(' ${snapshot.label} → ${nextSnapshot.label}: $growth'); print(' ${snapshot.label} → ${nextSnapshot.label}: $growth');
} }
} }
print('\n' + ('=' * 60) + '\n'); print('\n${'=' * 60}\n');
} }
} }
+1 -1
View File
@@ -35,7 +35,7 @@ class StartupProfiler {
/// Report all metrics /// Report all metrics
static Future<void> reportAll() async { static Future<void> reportAll() async {
if (kDebugMode) { if (kDebugMode) {
print('\n' + ('=' * 50)); print('\n${'=' * 50}');
print('⏱️ STARTUP METRICS'); print('⏱️ STARTUP METRICS');
print('=' * 50); print('=' * 50);
_timings.forEach((key, watch) { _timings.forEach((key, watch) {
+4 -6
View File
@@ -32,8 +32,7 @@ class VideoProfiler {
static void markFirstFrame(String streamName) { static void markFirstFrame(String streamName) {
final m = metrics[streamName]; final m = metrics[streamName];
if (m != null) { if (m != null) {
m.firstFrameTime = m.firstFrameTime = DateTime.now().difference(m.loadStart).inMilliseconds;
DateTime.now().difference(m.loadStart).inMilliseconds;
if (kDebugMode) { if (kDebugMode) {
print('🎬 FIRST FRAME: ${m.firstFrameTime}ms'); print('🎬 FIRST FRAME: ${m.firstFrameTime}ms');
} }
@@ -78,10 +77,9 @@ class VideoProfiler {
final m = metrics[streamName]; final m = metrics[streamName];
if (m != null) { if (m != null) {
m.endTime = DateTime.now(); m.endTime = DateTime.now();
m.totalDurationMs = m.totalDurationMs = m.endTime!.difference(m.startTime).inMilliseconds;
m.endTime!.difference(m.startTime).inMilliseconds;
if (kDebugMode) { if (kDebugMode) {
print('\n' + ('=' * 50)); print('\n${'=' * 50}');
print('📊 VIDEO METRICS: $streamName'); print('📊 VIDEO METRICS: $streamName');
print('=' * 50); print('=' * 50);
m.printMetrics(); m.printMetrics();
@@ -93,7 +91,7 @@ class VideoProfiler {
/// Get summary of all metrics /// Get summary of all metrics
static void printSummary() { static void printSummary() {
if (kDebugMode) { if (kDebugMode) {
print('\n' + ('=' * 50)); print('\n${'=' * 50}');
print('📈 VIDEO PROFILING SUMMARY'); print('📈 VIDEO PROFILING SUMMARY');
print('=' * 50); print('=' * 50);
metrics.forEach((key, m) { metrics.forEach((key, m) {
@@ -147,7 +147,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
} }
Future<void> _updateEPG() async { Future<void> _updateEPG() async {
if (widget.streamType != model.StreamType.live || _xtreamService == null) return; if (widget.streamType != model.StreamType.live || _xtreamService == null)
return;
try { try {
final currentChannelId = final currentChannelId =
widget.channels?[_currentIndex].streamId ?? widget.streamId; widget.channels?[_currentIndex].streamId ?? widget.streamId;
@@ -198,10 +199,12 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
); );
_controller = controller; _controller = controller;
await controller.initialize().timeout(const Duration(seconds: 15), await controller.initialize().timeout(
onTimeout: () { const Duration(seconds: 15),
throw Exception('Connection timed out'); onTimeout: () {
}); throw Exception('Connection timed out');
},
);
if (!mounted) { if (!mounted) {
await controller.dispose(); await controller.dispose();
@@ -221,11 +224,12 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_resetControlsTimer(); _resetControlsTimer();
controller.addListener(_videoListener); controller.addListener(_videoListener);
} catch (e) { } catch (e) {
if (mounted && currentLoadId == _loadId) if (mounted && currentLoadId == _loadId) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_errorMessage = "Playback Error: $e"; _errorMessage = "Playback Error: $e";
}); });
}
} }
} }
@@ -233,8 +237,9 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
if (_controller == null || !mounted) return; if (_controller == null || !mounted) return;
final value = _controller!.value; final value = _controller!.value;
if (value.isPlaying != _isPlaying) if (value.isPlaying != _isPlaying) {
setState(() => _isPlaying = value.isPlaying); setState(() => _isPlaying = value.isPlaying);
}
// Stabilization Masking: spinner stays until 1.5s of smooth play // Stabilization Masking: spinner stays until 1.5s of smooth play
// Fix: If playing, ignore isBuffering to prevent infinite spinner on some TS streams // Fix: If playing, ignore isBuffering to prevent infinite spinner on some TS streams
@@ -302,16 +307,18 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
final key = event.logicalKey; final key = event.logicalKey;
if (key == LogicalKeyboardKey.channelUp) { if (key == LogicalKeyboardKey.channelUp) {
if (widget.channels != null) if (widget.channels != null) {
_switchChannel((_currentIndex + 1) % widget.channels!.length); _switchChannel((_currentIndex + 1) % widget.channels!.length);
}
return true; return true;
} }
if (key == LogicalKeyboardKey.channelDown) { if (key == LogicalKeyboardKey.channelDown) {
if (widget.channels != null) if (widget.channels != null) {
_switchChannel( _switchChannel(
(_currentIndex - 1 + widget.channels!.length) % (_currentIndex - 1 + widget.channels!.length) %
widget.channels!.length, widget.channels!.length,
); );
}
return true; return true;
} }
@@ -393,7 +400,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: const BoxDecoration(
color: Colors.black45, color: Colors.black45,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
@@ -408,7 +415,9 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
if (settings.showClock) if (settings.showClock)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black45, color: Colors.black45,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -460,9 +469,10 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
Widget _buildAspectRatioWrapper() { Widget _buildAspectRatioWrapper() {
final mode = ref.read(mobileSettingsProvider).aspectRatioMode; final mode = ref.read(mobileSettingsProvider).aspectRatioMode;
if (mode == 'fill') if (mode == 'fill') {
return SizedBox.expand(child: VideoPlayer(_controller!)); return SizedBox.expand(child: VideoPlayer(_controller!));
if (mode == 'cover') }
if (mode == 'cover') {
return SizedBox.expand( return SizedBox.expand(
child: FittedBox( child: FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -473,6 +483,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
), ),
), ),
); );
}
return AspectRatio( return AspectRatio(
aspectRatio: _controller!.value.aspectRatio, aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!), child: VideoPlayer(_controller!),
@@ -16,7 +16,7 @@ import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:xtremobile/core/utils/device_info.dart'; import 'package:xtremobile/core/utils/device_info.dart';
/// Stream type enum for player /// Stream type enum for player
// (Moved to global or imported from iptv_models if available) // (Moved to global or imported from iptv_models if available)
/// Native video player screen for Android/iOS /// Native video player screen for Android/iOS
@@ -165,7 +165,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
'MediaKitPlayer: Applying LIVE optimization profile (STABILITY)', 'MediaKitPlayer: Applying LIVE optimization profile (STABILITY)',
); );
(_player.platform as dynamic)?.setProperty( (_player.platform as dynamic)?.setProperty(
'cache-secs', '10'); // Reduced to 10s for faster startup 'cache-secs',
'10',
); // Reduced to 10s for faster startup
(_player.platform as dynamic)?.setProperty( (_player.platform as dynamic)?.setProperty(
'demuxer-max-bytes', 'demuxer-max-bytes',
'32000000', '32000000',
@@ -190,7 +192,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
final backBytes = (bufferBytes / 10).toInt(); // 10% for backward buffer final backBytes = (bufferBytes / 10).toInt(); // 10% for backward buffer
debugPrint( debugPrint(
'MediaKitPlayer: VOD profile - ${deviceInfo.getDeviceProfile()}, buffer=${bufferBytes ~/ (1024 * 1024)}MB'); 'MediaKitPlayer: VOD profile - ${deviceInfo.getDeviceProfile()}, buffer=${bufferBytes ~/ (1024 * 1024)}MB',
);
(_player.platform as dynamic)?.setProperty('cache-secs', '100'); (_player.platform as dynamic)?.setProperty('cache-secs', '100');
(_player.platform as dynamic) (_player.platform as dynamic)
@@ -309,7 +312,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_checkAndMarkWatched(position); _checkAndMarkWatched(position);
// Auto-save position every 10 seconds (resilience against app kill/crash) // Auto-save position every 10 seconds (resilience against app kill/crash)
if (widget.streamType != model.StreamType.live && !_hasMarkedAsWatched) { if (widget.streamType != model.StreamType.live &&
!_hasMarkedAsWatched) {
final now = DateTime.now(); final now = DateTime.now();
if (_lastSaveTime == null || if (_lastSaveTime == null ||
now.difference(_lastSaveTime!) > const Duration(seconds: 10)) { now.difference(_lastSaveTime!) > const Duration(seconds: 10)) {
@@ -594,7 +598,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Future.delayed(Duration(seconds: 2 * _reconnectAttempts), () { Future.delayed(Duration(seconds: 2 * _reconnectAttempts), () {
if (mounted) { if (mounted) {
// If VOD/Series, try to resume from current position // If VOD/Series, try to resume from current position
if (widget.streamType != model.StreamType.live && _position.inSeconds > 0) { if (widget.streamType != model.StreamType.live &&
_position.inSeconds > 0) {
_loadStream(startAt: _position); _loadStream(startAt: _position);
} else { } else {
_loadStream(); _loadStream();
@@ -949,7 +954,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Channel Down = Previous Channel (inverted as requested) // Channel Down = Previous Channel (inverted as requested)
if (key == LogicalKeyboardKey.channelDown) { if (key == LogicalKeyboardKey.channelDown) {
if (widget.streamType == model.StreamType.live && widget.channels != null) { if (widget.streamType == model.StreamType.live &&
widget.channels != null) {
_playPrevious(); _playPrevious();
_onUserInteraction(); _onUserInteraction();
return true; return true;
@@ -958,7 +964,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Channel Up = Next Channel (inverted as requested) // Channel Up = Next Channel (inverted as requested)
if (key == LogicalKeyboardKey.channelUp) { if (key == LogicalKeyboardKey.channelUp) {
if (widget.streamType == model.StreamType.live && widget.channels != null) { if (widget.streamType == model.StreamType.live &&
widget.channels != null) {
_playNext(); _playNext();
_onUserInteraction(); _onUserInteraction();
return true; return true;
@@ -1522,9 +1529,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
LogicalKeyboardKey.arrowLeft) { LogicalKeyboardKey.arrowLeft) {
final newPos = final newPos =
_position - const Duration(seconds: 10); _position - const Duration(seconds: 10);
_player.seek(newPos < Duration.zero _player.seek(
? Duration.zero newPos < Duration.zero
: newPos); ? Duration.zero
: newPos,
);
_resetControlsTimer(); _resetControlsTimer();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -2,13 +2,13 @@
/// ///
/// Handles Xtream API communication with batching and caching optimizations /// Handles Xtream API communication with batching and caching optimizations
/// for mobile platforms. Designed for performance with TiviMate-level efficiency. /// for mobile platforms. Designed for performance with TiviMate-level efficiency.
library;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:async'; import 'dart:async';
import '../models/xtream_models.dart' as model; import '../models/xtream_models.dart' as model;
import 'package:xtremobile/core/models/playlist_config.dart'; import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/api/api_client.dart';
/// EPG cache entry with TTL /// EPG cache entry with TTL
class _EpgCacheEntry { class _EpgCacheEntry {
@@ -18,7 +18,8 @@ class _EpgCacheEntry {
_EpgCacheEntry(this.data) : timestamp = DateTime.now(); _EpgCacheEntry(this.data) : timestamp = DateTime.now();
bool get isExpired => DateTime.now().difference(timestamp).inSeconds > ttlSeconds; bool get isExpired =>
DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
} }
/// Xtream API Service for Mobile /// Xtream API Service for Mobile
@@ -49,8 +50,18 @@ class XtreamServiceMobile {
_username = config.username; _username = config.username;
_password = config.password; _password = config.password;
// Use shared Dio instance from ApiClient (with DNS resolution, etc.) // Create independent Dio for Xtream API (avoid ApiClient baseUrl issues)
_dio = ApiClient().dio; _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 15),
),
);
if (kDebugMode) {
print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl');
}
} }
/// Get live channels for a category (with batch EPG support) /// Get live channels for a category (with batch EPG support)
@@ -81,23 +92,31 @@ class XtreamServiceMobile {
), ),
); );
if (response.statusCode == 200 && response.data is List) { if (response.statusCode == 200) {
final channels = (response.data as List) if (response.data is List) {
.map((e) => model.Channel.fromJson(e)) final channels = (response.data as List)
.toList(); .map((e) => model.Channel.fromJson(e))
.toList();
if (kDebugMode) { if (kDebugMode) {
final categories = channels.map((c) => c.categoryName).toSet(); final categories = channels.map((c) => c.categoryName).toSet();
print( print(
'✅ Loaded ${channels.length} channels with ${categories.length} categories: $categories', '✅ Loaded ${channels.length} channels with ${categories.length} categories: $categories',
); );
}
return channels;
} else if (response.data is Map) {
if (kDebugMode) print('⚠️ API returned a Map instead of List: ${response.data}');
return [];
} else {
if (kDebugMode) print('⚠️ API returned unexpected type: ${response.data.runtimeType}');
return [];
} }
return channels;
} }
if (kDebugMode) { if (kDebugMode) {
print('⚠️ Unexpected response status: ${response.statusCode}'); print('⚠️ Unexpected response status: ${response.statusCode}, body: ${response.data}');
} }
return []; return [];
} catch (e) { } catch (e) {
@@ -110,12 +129,13 @@ class XtreamServiceMobile {
Future<model.ShortEPG> getShortEPG(String streamId) async { Future<model.ShortEPG> getShortEPG(String streamId) async {
// For backward compatibility, but prefer batch loading // For backward compatibility, but prefer batch loading
final batch = await getBatchEPG([streamId]); final batch = await getBatchEPG([streamId]);
return batch[streamId] ?? model.ShortEPG( return batch[streamId] ??
id: streamId, model.ShortEPG(
title: 'No Program', id: streamId,
start: DateTime.now().toIso8601String(), title: 'No Program',
end: DateTime.now().add(const Duration(hours: 1)).toIso8601String(), start: DateTime.now().toIso8601String(),
); end: DateTime.now().add(const Duration(hours: 1)).toIso8601String(),
);
} }
/// Get SHORT EPG for MULTIPLE channels in ONE request /// Get SHORT EPG for MULTIPLE channels in ONE request
@@ -123,7 +143,8 @@ class XtreamServiceMobile {
/// This is the optimized method that prevents N+1 queries. /// This is the optimized method that prevents N+1 queries.
/// Instead of 50 requests for 50 channels, this loads all at once. /// Instead of 50 requests for 50 channels, this loads all at once.
/// Results are cached for 1 hour to avoid repeated requests. /// Results are cached for 1 hour to avoid repeated requests.
Future<Map<String, model.ShortEPG>> getBatchEPG(List<String> streamIds) async { Future<Map<String, model.ShortEPG>> getBatchEPG(
List<String> streamIds) async {
if (streamIds.isEmpty) return {}; if (streamIds.isEmpty) return {};
// Create cache key from sorted IDs for consistency // Create cache key from sorted IDs for consistency
@@ -133,7 +154,8 @@ class XtreamServiceMobile {
// Check if we have valid cached data // Check if we have valid cached data
final cached = _epgBatchCache[cacheKeyStr]; final cached = _epgBatchCache[cacheKeyStr];
if (cached != null && !cached.isExpired) { if (cached != null && !cached.isExpired) {
if (kDebugMode) print('✅ EPG batch cache hit: ${streamIds.length} channels'); if (kDebugMode)
print('✅ EPG batch cache hit: ${streamIds.length} channels');
return cached.data; return cached.data;
} }
@@ -158,7 +180,9 @@ class XtreamServiceMobile {
/// Internal: Load EPG batch from API /// Internal: Load EPG batch from API
Future<Map<String, model.ShortEPG>> _loadEpgBatch( Future<Map<String, model.ShortEPG>> _loadEpgBatch(
List<String> streamIds, String cacheKey) async { List<String> streamIds,
String cacheKey,
) async {
try { try {
final result = <String, model.ShortEPG>{}; final result = <String, model.ShortEPG>{};
@@ -186,18 +210,20 @@ class XtreamServiceMobile {
if (item is Map && item.containsKey('stream_id')) { if (item is Map && item.containsKey('stream_id')) {
final streamId = item['stream_id'].toString(); final streamId = item['stream_id'].toString();
// Assuming the API returns a list of EPG entries for this stream // Assuming the API returns a list of EPG entries for this stream
if (item['epg_listings'] is List && (item['epg_listings'] as List).isNotEmpty) { if (item['epg_listings'] is List &&
final epgJson = (item['epg_listings'] as List).first; (item['epg_listings'] as List).isNotEmpty) {
result[streamId] = model.ShortEPG.fromJson({ final epgJson = (item['epg_listings'] as List).first;
...epgJson, result[streamId] = model.ShortEPG.fromJson({
'id': streamId, ...epgJson,
}); 'id': streamId,
});
} }
} }
} }
if (kDebugMode) { if (kDebugMode) {
print('✅ Loaded EPG for ${result.length}/${streamIds.length} channels'); print(
'✅ Loaded EPG for ${result.length}/${streamIds.length} channels');
} }
} }
@@ -228,25 +254,34 @@ class XtreamServiceMobile {
) )
.timeout(const Duration(seconds: 8)); .timeout(const Duration(seconds: 8));
if (response.statusCode == 200 && response.data is List) { if (response.statusCode == 200) {
final categories = (response.data as List) if (response.data is List) {
.map((e) => model.Category.fromJson(e)) final categories = (response.data as List)
.toList(); .map((e) => model.Category.fromJson(e))
.toList();
if (kDebugMode) { if (kDebugMode) {
print('✅ Loaded ${categories.length} live categories'); print('✅ Loaded ${categories.length} live categories: ${categories.map((c) => c.categoryName).toList()}');
}
return categories;
} else if (response.data is Map) {
if (kDebugMode) print('⚠️ get_live_categories returned a Map (not a list): ${response.data}');
return [];
} else {
if (kDebugMode) print('⚠️ get_live_categories returned unexpected type: ${response.data.runtimeType}');
return [];
} }
return categories;
} }
if (kDebugMode) { if (kDebugMode) {
print('⚠️ Failed to load categories (status: ${response.statusCode})'); print('⚠️ Failed to load categories (status: ${response.statusCode}, body: ${response.data})');
} }
return []; return [];
} on TimeoutException { } on TimeoutException {
if (kDebugMode) { if (kDebugMode) {
print('⏱️ Timeout loading live categories - will fallback to loading all channels'); print(
'⏱️ Timeout loading live categories - will fallback to loading all channels');
} }
return []; return [];
} catch (e) { } catch (e) {
@@ -335,7 +370,9 @@ class XtreamServiceMobile {
/// Search VOD movies /// Search VOD movies
Future<List<model.VodItem>> searchMovies(String query) async { Future<List<model.VodItem>> searchMovies(String query) async {
final all = await getMovies(); final all = await getMovies();
return all.where((m) => m.name.toLowerCase().contains(query.toLowerCase())).toList(); return all
.where((m) => m.name.toLowerCase().contains(query.toLowerCase()))
.toList();
} }
/// Get all series /// Get all series
@@ -363,7 +400,9 @@ class XtreamServiceMobile {
/// Search series /// Search series
Future<List<model.Series>> searchSeries(String query) async { Future<List<model.Series>> searchSeries(String query) async {
final all = await getSeries(); final all = await getSeries();
return all.where((s) => s.name.toLowerCase().contains(query.toLowerCase())).toList(); return all
.where((s) => s.name.toLowerCase().contains(query.toLowerCase()))
.toList();
} }
/// Get series paginated /// Get series paginated
@@ -373,7 +412,7 @@ class XtreamServiceMobile {
/// Get series info /// Get series info
Future<model.SeriesInfo?> getSeriesInfo(String seriesId) async { Future<model.SeriesInfo?> getSeriesInfo(String seriesId) async {
try { try {
final response = await _dio.get( final response = await _dio.get(
'$_baseUrl/player_api.php', '$_baseUrl/player_api.php',
queryParameters: { queryParameters: {
@@ -387,7 +426,7 @@ class XtreamServiceMobile {
if (response.statusCode == 200 && response.data is Map) { if (response.statusCode == 200 && response.data is Map) {
final data = response.data as Map<String, dynamic>; final data = response.data as Map<String, dynamic>;
final seriesModel = model.Series.fromJson(data['info'] ?? {}); final seriesModel = model.Series.fromJson(data['info'] ?? {});
final episodesMap = <String, List<model.Episode>>{}; final episodesMap = <String, List<model.Episode>>{};
if (data['episodes'] is Map) { if (data['episodes'] is Map) {
final seasons = data['episodes'] as Map<String, dynamic>; final seasons = data['episodes'] as Map<String, dynamic>;
@@ -399,7 +438,7 @@ class XtreamServiceMobile {
} }
}); });
} }
return model.SeriesInfo( return model.SeriesInfo(
series: seriesModel, series: seriesModel,
episodes: episodesMap, episodes: episodesMap,
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart'; import 'package:xtremobile/core/utils/image_cache_config.dart';
import 'dart:io';
import 'dart:async'; import 'dart:async';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
@@ -11,8 +10,6 @@ import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/models/iptv_models.dart'; import 'package:xtremobile/core/models/iptv_models.dart';
import 'package:xtremobile/core/models/playlist_config.dart'; import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/theme/app_colors.dart'; import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/core/api/dns_resolver.dart';
import 'package:xtremobile/mobile/theme/mobile_theme.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart'; import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -158,7 +155,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
), ),
data: (groupedChannels) { data: (groupedChannels) {
// Prepare categories // Prepare categories
var categories = groupedChannels.map((c) => c.categoryName).toSet().toList(); var categories =
groupedChannels.map((c) => c.categoryName).toSet().toList();
if (settings.liveTvKeywords.isNotEmpty) { if (settings.liveTvKeywords.isNotEmpty) {
categories = categories categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat)) .where((cat) => settings.matchesLiveTvFilter(cat))
@@ -198,8 +196,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
.where((c) => favorites.contains(c.streamId)) .where((c) => favorites.contains(c.streamId))
.toList(); .toList();
} else if (uiState.selectedCategory != null) { } else if (uiState.selectedCategory != null) {
displayedChannels = displayedChannels = groupedChannels
groupedChannels.where((c) => c.categoryName == uiState.selectedCategory).toList(); .where((c) => c.categoryName == uiState.selectedCategory)
.toList();
} }
} }
@@ -86,7 +86,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
final service = final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future); await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final movies = await service.getMovies(); final movies = await service.getMovies();
final results = movies.where((m) => m.name.toLowerCase().contains(query.toLowerCase())).toList(); final results = movies
.where((m) => m.name.toLowerCase().contains(query.toLowerCase()))
.toList();
if (mounted && _searchQuery == query) { if (mounted && _searchQuery == query) {
setState(() { setState(() {
@@ -110,7 +112,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
if (mounted) { if (mounted) {
setState(() { setState(() {
_categories = categories.map((c) => MapEntry(c.categoryId, c.categoryName)).toList() _categories = categories
.map((c) => MapEntry(c.categoryId, c.categoryName))
.toList()
..sort((a, b) => a.value.compareTo(b.value)); ..sort((a, b) => a.value.compareTo(b.value));
_isLoading = false; _isLoading = false;
}); });
@@ -400,7 +404,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
isDense: true, isDense: true,
contentPadding: EdgeInsets.only(bottom: 11), contentPadding:
const EdgeInsets.only(bottom: 11),
), ),
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
onSubmitted: (_) => onSubmitted: (_) =>
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.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';
-2
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/providers/theme_provider.dart'; import 'core/providers/theme_provider.dart';
@@ -75,4 +74,3 @@ class XtremFlowApp extends ConsumerWidget {
); );
} }
} }
@@ -16,35 +16,88 @@ final mobileXtreamServiceProvider =
return service; return service;
}); });
/// Mobile-specific live channels provider /// Mobile-specific live categories provider (for Live TV category grid)
/// Loads ALL channels from ALL categories final mobileLiveCategoriesProvider =
FutureProvider.family<List<model.Category>, PlaylistConfig>(
(ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getLiveCategories();
});
/// Mobile-specific live channels provider (loads channels for a specific category)
final mobileLiveChannelsByCategoryProvider = FutureProvider.family<
List<model.Channel>,
(PlaylistConfig, String)>((ref, params) async {
final (playlist, categoryId) = params;
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getLiveChannels(categoryId);
});
/// Load ALL live channels efficiently
/// Strategy: Load categories, then load channels in SMALL BATCHES (5 at a time)
/// This balances speed with robustness - not too slow, not too many parallel requests
final mobileLiveChannelsProvider = final mobileLiveChannelsProvider =
FutureProvider.family<List<model.Channel>, PlaylistConfig>( FutureProvider.family<List<model.Channel>, PlaylistConfig>(
(ref, playlist) async { (ref, playlist) async {
print('📺 [LiveTV] Starting to load ALL channels...');
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
// Strategy: Try to load categories first (with timeout) // Step 1: Load categories
// If categories load successfully, load channels for each category print('🔄 [LiveTV] Loading categories...');
// If categories fail/timeout, load all channels at once final categories = await service.getLiveCategories();
List<model.Category> categories = []; print('✅ [LiveTV] Loaded ${categories.length} categories');
try {
categories = await service.getLiveCategories(); if (categories.isEmpty) {
} catch (e) { print('❌ [LiveTV] No categories found!');
// Silently fall back to loading all channels return [];
} }
if (categories.isNotEmpty) { // Step 2: Load channels in SMALL BATCHES (5 at a time for balance)
// Load channels from each category and combine them final allChannels = <model.Channel>[];
final allChannels = <model.Channel>[]; const batchSize = 5;
for (final category in categories) { int loaded = 0;
final channels = await service.getLiveChannels(category.categoryId); int totalBatches = (categories.length / batchSize).ceil();
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
int startIdx = batchNum * batchSize;
int endIdx =
(startIdx + batchSize < categories.length) ? startIdx + batchSize : categories.length;
final batch = categories.sublist(startIdx, endIdx);
print(
'🔄 [LiveTV] Batch ${batchNum + 1}/$totalBatches (${batch.length} categories)...',
);
// Load this batch in parallel
final futures = batch
.map(
(cat) => service
.getLiveChannels(cat.categoryId)
.timeout(const Duration(seconds: 15))
.catchError((_) => <model.Channel>[]),
)
.toList();
final batchResults = await Future.wait(futures);
// Combine results from this batch
for (final channels in batchResults) {
allChannels.addAll(channels); allChannels.addAll(channels);
} }
return allChannels;
loaded += batch.length;
print(
'✅ [LiveTV] Batch complete: $loaded/${categories.length} categories, '
'${allChannels.length} total channels',
);
} }
// Fallback: Load all channels without category filter print(
return service.getLiveChannels(""); '🎉 [LiveTV] ALL DONE! ${categories.length} categories, '
'${allChannels.length} total channels',
);
return allChannels;
}); });
/// Mobile-specific movies provider /// Mobile-specific movies provider