mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: Implement MobileMoviesTab for displaying IPTV movies with category browsing and search functionality.
This commit is contained in:
1 parent
0010fe12ba
commit
f85f43e42f
17 files changed
+268
-136
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user