Files
xtremobile/lib/mobile/providers/mobile_xtream_providers.dart
T
MichaelandClaude Opus 5 8d74bbb320 feat: XMLTV EPG fallback, Warm Cinema theme, background release
Align the Android app with xtremflow on three fronts.

EPG: add XmltvService, a streaming XMLTV parser (gunzip-aware, indexed to a
-3h/+18h window, JSON disk cache with a 6h TTL). XtreamServiceMobile now falls
back through the provider's xmltv.php, a user-supplied URL, then a community
guide whenever get_short_epg returns nothing. Channels are matched on
epg_channel_id first, then on a normalized name (accents, HD/FHD/4K suffixes,
"FR:" and "|FR|" prefixes). A streamId -> Channel index populated by
getLiveChannels supplies those keys, so no getShortEPG call site changed.
EPG requests are also funnelled through a 3-slot semaphore, as in xtremflow:
a 30-tile grid used to fire 30 parallel player_api.php calls, which rate-limited
panels answered with errors, emptying the guide for the whole screen.

Theme: port the Warm Cinema charter (ember/charcoal/cream, Fraunces + Karla)
into app_colors, app_theme and app_decorations. The 21 Apple TV colour names
already in use are kept as compatibility aliases. mobile_theme now derives from
AppTheme instead of duplicating its 390 lines.

Playback lifecycle: stop reacting to AppLifecycleState.inactive, which fires on
transient Android interruptions such as pulling down the notification shade and
was killing playback. Both engines now release on paused/hidden/detached via
_releasePlayback(): stream stopped, decoder released, timers cancelled, wakelock
dropped. An _isReleased latch keeps the watchdog and reconnect paths from
restarting a stream from the background.

Long-run stability: cancel the six player stream subscriptions on dispose, cap
demuxer-max-back-bytes at 8MB (mpv defaults to 50MiB, which combined with the
150MB readahead pushed the native heap toward an Android memory kill after a few
hours), rebuild on position only while the controls are visible, and dispose the
Lite player's focus nodes.

Startup: prewarmHost() pre-resolves DNS and opens then closes a bare TCP/TLS
socket before playback. No HTTP request is issued, so no provider connection
slot is consumed. Capped at 1.5s.

Verified: flutter analyze clean on all touched files; 19/19 tests, including a
run over a real 46MB/69802-programme dump (517 channels indexed in 1.1s, 5/5
name probes matched); flutter build apk --release succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:25:33 +02:00

230 lines
7.7 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/playlist_config.dart';
import '../../core/models/iptv_models.dart' as model;
import '../../features/iptv/services/xtream_service_mobile.dart';
import 'mobile_settings_providers.dart';
/// Mobile-specific Xtream service provider
import 'package:path_provider/path_provider.dart';
final mobileXtreamServiceProvider =
FutureProvider.family<XtreamServiceMobile, PlaylistConfig>(
(ref, playlist) async {
final dir = await getApplicationDocumentsDirectory();
final service = XtreamServiceMobile(dir.path);
await service
.setPlaylistAsync(playlist); // Async to resolve DNS before API calls
// EPG fallback configuration follows the settings live, without tearing the
// service (and its caches) down on every unrelated settings change.
void applyEpgSettings(MobileSettings settings) => service.setXmltvConfig(
customUrl: settings.xmltvUrl,
allowPublicFallback: settings.useCommunityEpg,
);
applyEpgSettings(ref.read(mobileSettingsProvider));
ref.listen<MobileSettings>(mobileSettingsProvider, (previous, next) {
if (previous?.xmltvUrl != next.xmltvUrl ||
previous?.useCommunityEpg != next.useCommunityEpg) {
applyEpgSettings(next);
}
});
ref.onDispose(service.dispose);
return service;
});
/// Mobile-specific live categories provider (for Live TV category grid)
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 =
FutureProvider.family<List<model.Channel>, PlaylistConfig>(
(ref, playlist) async {
print('📺 [LiveTV] Starting to load ALL channels...');
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
// Step 1: Load categories
print('🔄 [LiveTV] Loading categories...');
final categories = await service.getLiveCategories();
print('✅ [LiveTV] Loaded ${categories.length} categories');
if (categories.isEmpty) {
print('❌ [LiveTV] No categories found!');
return [];
}
// Step 2: Load channels in SMALL BATCHES (5 at a time for balance)
final allChannels = <model.Channel>[];
const batchSize = 5;
int loaded = 0;
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);
}
loaded += batch.length;
print(
'✅ [LiveTV] Batch complete: $loaded/${categories.length} categories, '
'${allChannels.length} total channels',
);
}
print(
'🎉 [LiveTV] ALL DONE! ${categories.length} categories, '
'${allChannels.length} total channels',
);
return allChannels;
});
/// Mobile-specific movies provider
final mobileMoviesProvider =
FutureProvider.family<List<model.VodItem>, PlaylistConfig>((ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getMoviesByCategory(""); // Fetch all or default
});
/// Mobile-specific series categories provider
final mobileSeriesCategoriesProvider =
FutureProvider.family<List<model.Category>, PlaylistConfig>(
(ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getSeriesCategories();
});
/// Mobile-specific series pagination provider
final mobileSeriesProvider = FutureProvider.family<List<model.Series>,
(PlaylistConfig, String?)>((ref, params) async {
final (playlist, categoryId) = params;
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getSeriesPaginated(categoryId: categoryId);
});
/// Series info with playlist context
class SeriesInfoRequest {
final PlaylistConfig playlist;
final String seriesId;
SeriesInfoRequest({required this.playlist, required this.seriesId});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SeriesInfoRequest &&
runtimeType == other.runtimeType &&
playlist == other.playlist &&
seriesId == other.seriesId;
@override
int get hashCode => playlist.hashCode ^ seriesId.hashCode;
}
final mobileSeriesInfoByPlaylistProvider =
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
final service =
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
return service.getSeriesInfo(request.seriesId);
});
// UI State Provider for persistent navigation
final mobileLiveTvUiStateProvider =
StateProvider<LiveTvUiState>((ref) => LiveTvUiState());
class LiveTvUiState {
final String? selectedCategory; // category NAME (for display)
final String? selectedCategoryId; // category ID (for API calls)
final bool isCategoryView;
LiveTvUiState({
this.selectedCategory,
this.selectedCategoryId,
this.isCategoryView = true,
});
LiveTvUiState copyWith({
String? selectedCategory,
String? selectedCategoryId,
bool? isCategoryView,
}) {
return LiveTvUiState(
selectedCategory: selectedCategory ?? this.selectedCategory,
selectedCategoryId: selectedCategoryId ?? this.selectedCategoryId,
isCategoryView: isCategoryView ?? this.isCategoryView,
);
}
}
/// Persistent index for mobile dashboard navigation
final mobileDashboardIndexProvider = StateProvider<int>((ref) => 0);
/// Batch EPG provider
class BatchEpgRequest {
final PlaylistConfig playlist;
final List<String> streamIds;
BatchEpgRequest({required this.playlist, required this.streamIds});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BatchEpgRequest &&
runtimeType == other.runtimeType &&
playlist == other.playlist &&
streamIds.toSet().toString() == other.streamIds.toSet().toString();
@override
int get hashCode => playlist.hashCode ^ streamIds.toSet().toString().hashCode;
}
/// Load EPG data for multiple streams in one batch request
final mobileBatchEpgProvider =
FutureProvider.family<Map<String, model.ShortEPG>, BatchEpgRequest>(
(ref, request) async {
if (request.streamIds.isEmpty) return {};
final service =
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
return service.getBatchEPG(request.streamIds);
});