feat: Add generated Hive TypeAdapters for AppUser and PlaylistConfig models, and new build/doctor log files.

This commit is contained in:
Michael committed 2026-03-27 11:08:04 +01:00
1 parent 780f6c1842
commit ef97c6dc90
81 files changed
+3233 -2428

No files matched your search

+3
View File
@@ -52,6 +52,9 @@ class ApiClient {
return _token;
}
/// Get underlying Dio instance for direct use
Dio get dio => _dio;
/// Restore token from SharedPreferences
Future<void> restoreToken() async {
final prefs = await SharedPreferences.getInstance();
+17 -17
View File
@@ -139,14 +139,6 @@ class UnifiedDnsService {
return null;
}
/// Clear DNS cache
void clearCache() {
_cache.clear();
if (kDebugMode) {
debugPrint('🗑️ DNS cache cleared');
}
}
/// Get cache stats for debugging
Map<String, dynamic> getCacheStats() {
final expiredCount = _cache.values.where((e) => e.isExpired).length;
@@ -158,14 +150,22 @@ class UnifiedDnsService {
};
}
/// Cache entry with TTL
static class _DnsCacheEntry {
final String ip;
final DateTime timestamp;
_DnsCacheEntry(this.ip) : timestamp = DateTime.now();
bool get isExpired =>
DateTime.now().difference(timestamp).inSeconds > _cacheTtlSeconds;
/// Clear DNS cache
void clearCache() {
_cache.clear();
if (kDebugMode) {
debugPrint('🗑️ DNS cache cleared');
}
}
}
/// Cache entry with TTL
class _DnsCacheEntry {
final String ip;
final DateTime timestamp;
_DnsCacheEntry(this.ip) : timestamp = DateTime.now();
bool get isExpired =>
DateTime.now().difference(timestamp).inSeconds > 3600;
}
+155
View File
@@ -1,4 +1,7 @@
import 'dart:convert';
enum StreamType { live, vod, series }
/// Channel model for Live TV
class Channel {
final String streamId;
@@ -101,6 +104,7 @@ class Series {
final String plot;
final String categoryId;
final String categoryName;
final String rating;
const Series({
required this.seriesId,
@@ -109,6 +113,7 @@ class Series {
this.plot = '',
required this.categoryId,
required this.categoryName,
this.rating = '0',
});
factory Series.fromJson(Map<String, dynamic> json) {
@@ -119,6 +124,7 @@ class Series {
plot: json['plot']?.toString() ?? '',
categoryId: json['category_id']?.toString() ?? '',
categoryName: json['category_name']?.toString() ?? '',
rating: json['rating']?.toString() ?? '0',
);
}
@@ -130,8 +136,14 @@ class Series {
'plot': plot,
'category_id': categoryId,
'category_name': categoryName,
'rating': rating,
};
}
/// UI Compatibility getters
String get streamId => seriesId;
String get coverUrl => cover;
String get title => name;
}
/// EPG (Electronic Program Guide) entry
@@ -192,9 +204,152 @@ class EpgEntry {
final totalDuration = endTime.difference(startTime).inSeconds;
final elapsed = now.difference(startTime).inSeconds;
if (totalDuration == 0) return 0.0;
return elapsed / totalDuration;
} catch (e) {
return 0.0;
}
}
}
/// Category model
class Category {
final String categoryId;
final String categoryName;
final int parentId;
const Category({
required this.categoryId,
required this.categoryName,
this.parentId = 0,
});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
categoryId: json['category_id']?.toString() ?? '',
categoryName: json['category_name']?.toString() ?? '',
parentId: int.tryParse(json['parent_id']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'category_id': categoryId,
'category_name': categoryName,
'parent_id': parentId,
};
}
}
/// Series Episode model
class Episode {
final String id;
final String title;
final String containerExtension;
final String season;
final int episodeNum;
final int duration;
const Episode({
required this.id,
required this.title,
this.containerExtension = 'mp4',
this.season = '1',
this.episodeNum = 1,
this.duration = 0,
});
/// UI Compatibility getters
String get streamId => id;
int? get durationSecs => duration;
int get seasonNum => int.tryParse(season) ?? 1;
factory Episode.fromJson(Map<String, dynamic> json) {
return Episode(
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
containerExtension: json['container_extension']?.toString() ?? 'mp4',
season: json['season']?.toString() ?? '1',
episodeNum: int.tryParse(json['episode_num']?.toString() ?? '1') ?? 1,
duration: int.tryParse(json['duration']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'container_extension': containerExtension,
'season': season,
'episode_num': episodeNum,
'duration': duration,
};
}
}
/// Detailed Series Info
class SeriesInfo {
final Series series;
final Map<String, List<Episode>> episodes;
const SeriesInfo({
required this.series,
required this.episodes,
});
/// UI Compatibility getters
String? get coverUrl => series.cover;
String get title => series.name;
String? get rating => series.rating;
String? get plot => series.plot;
}
/// Short EPG model
class ShortEPG {
final String id;
final String title;
final String start;
final String end;
final String description;
const ShortEPG({
required this.id,
required this.title,
required this.start,
required this.end,
this.description = '',
});
factory ShortEPG.fromJson(Map<String, dynamic> json) {
return ShortEPG(
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
start: json['start']?.toString() ?? '',
end: json['end']?.toString() ?? '',
description: json['description']?.toString() ?? '',
);
}
/// Get progress percentage (compatibility with UI expectations)
double get progress {
try {
final startTime = DateTime.parse(start);
final endTime = DateTime.parse(end);
final now = DateTime.now();
if (now.isBefore(startTime)) return 0.0;
if (now.isAfter(endTime)) return 1.0;
final total = endTime.difference(startTime).inSeconds;
final elapsed = now.difference(startTime).inSeconds;
if (total == 0) return 0.0;
return elapsed / total;
} catch (_) {
return 0.0;
}
}
/// Get current program title
String get nowPlaying => title;
/// Get next program info (mocked or from data if available)
String get nextPlaying => "Next Program";
}
@@ -40,8 +40,8 @@ class StreamResolverPattern {
_performDnsResolution(streamUrl).timeout(timeout, onTimeout: () => false),
], eagerError: false).catchError((_) => [false, false]);
final isValid = results[0] as bool;
final dnsResolved = results[1] as bool;
final isValid = results[0];
final dnsResolved = results[1];
if (!isValid) {
return StreamResolverPattern(
+7 -4
View File
@@ -2,15 +2,18 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
/// Video player selection and wrapper pattern
/// Abstracts between MediaKit and other video players
/// Handles fallback logic and player initialization
/// Enum for supported players
enum PlayerType { mediakit, native }
/// Video player selection and wrapper pattern
/// Abstracts between MediaKit and other video players
/// Handles fallback logic and player initialization
class VideoPlayerWrapper {
static const _availablePlayers = ['mediakit'];
/// Enum for supported players
enum PlayerType { mediakit, native }
/// Get the preferred player type based on stream and platform
static PlayerType getPreferredPlayer({
required String streamUrl,
@@ -50,7 +53,7 @@ class VideoPlayerWrapper {
}) {
final player = Player(
configuration: PlayerConfiguration(
logLevel: enableLogs ? MPVLogLevel.debug : MPVLogLevel.no,
logLevel: enableLogs ? MPVLogLevel.debug : MPVLogLevel.error,
),
);
return player;
+1 -2
View File
@@ -7,6 +7,7 @@
/// Solution: Specify memCacheWidth/Height for display size, limiting disk cache.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// Standard cache manager with 200MB size limit
@@ -18,8 +19,6 @@ class AppCacheManager {
'xtremflow_image_cache',
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 500,
diskUsagePercentageHigh: 95,
diskUsagePercentageGoal: 80,
),
);
}
@@ -6,22 +6,21 @@ import 'package:video_player/video_player.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:xtremflow/core/models/iptv_models.dart';
import 'package:xtremflow/core/models/playlist_config.dart';
import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart';
import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremflow/core/theme/app_colors.dart';
import 'package:xtremflow/features/iptv/screens/native_player_screen.dart';
import 'package:xtremflow/features/iptv/models/xtream_models.dart' as xm;
import 'package:xtremflow/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/features/iptv/services/xtream_service_mobile.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
class LitePlayerScreen extends ConsumerStatefulWidget {
final String streamId;
final String title;
final StreamType streamType;
final model.StreamType streamType;
final String? containerExtension;
final PlaylistConfig playlist;
final List<Channel>? channels;
final List<model.Channel>? channels;
final int initialIndex;
// For series episodes
@@ -59,7 +58,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
bool _isPlaying = false;
// EPG & Clock
xm.ShortEPG? _epg;
model.ShortEPG? _epg;
Timer? _epgTimer;
String _currentTime = "";
Timer? _clockTimer;
@@ -135,7 +134,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
await _xtreamService!.setPlaylistAsync(widget.playlist);
_loadStream();
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
_updateEPG();
_epgTimer = Timer.periodic(
const Duration(minutes: 5),
@@ -148,7 +147,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
}
Future<void> _updateEPG() async {
if (widget.streamType != StreamType.live || _xtreamService == null) return;
if (widget.streamType != model.StreamType.live || _xtreamService == null) return;
try {
final currentChannelId =
widget.channels?[_currentIndex].streamId ?? widget.streamId;
@@ -179,11 +178,11 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
? widget.channels![_currentIndex].streamId
: widget.streamId;
if (widget.streamType == StreamType.live) _updateEPG();
if (widget.streamType == model.StreamType.live) _updateEPG();
final streamUrl = widget.streamType == StreamType.live
final streamUrl = widget.streamType == model.StreamType.live
? _xtreamService!.getLiveStreamUrl(currentStreamId)
: (widget.streamType == StreamType.vod
: (widget.streamType == model.StreamType.vod
? _xtreamService!.getVodStreamUrl(
currentStreamId,
widget.containerExtension ?? 'mp4',
@@ -210,7 +209,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
}
// Buffer Optimization: Pre-roll delay for TS streams
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
await Future.delayed(const Duration(milliseconds: 2000));
}
@@ -489,7 +488,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
mainAxisSize: MainAxisSize.min,
children: [
// Seeker (VOD)
if (widget.streamType != StreamType.live) _buildSeeker(),
if (widget.streamType != model.StreamType.live) _buildSeeker(),
// Unified Box: EPG & Controls
Container(
@@ -511,7 +510,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (widget.streamType == StreamType.live)
if (widget.streamType == model.StreamType.live)
TVFocusable(
focusNode: _prevFocusNode,
onPressed: () => _switchChannel(
@@ -539,7 +538,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
),
const SizedBox(width: 16),
if (widget.streamType == StreamType.live)
if (widget.streamType == model.StreamType.live)
TVFocusable(
focusNode: _nextFocusNode,
onPressed: () => _switchChannel(
@@ -601,7 +600,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
}
Widget _buildEPGBox(String title) {
String program = widget.streamType == StreamType.live
String program = widget.streamType == model.StreamType.live
? (_epg?.nowPlaying ?? "Pas d'infos EPG")
: title;
String next =
@@ -646,7 +645,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.streamType == StreamType.live &&
if (widget.streamType == model.StreamType.live &&
_epg?.nowPlaying != null) ...[
const SizedBox(height: 6),
ClipRRect(
@@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../widgets/mobile_scaffold.dart';
import '../../../theme/mobile_theme.dart';
import '../widgets/mobile_live_tv_tab.dart';
import '../widgets/mobile_movies_tab.dart';
import '../widgets/mobile_series_tab.dart';
import '../widgets/mobile_settings_tab.dart';
import '../../../providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/mobile/widgets/mobile_scaffold.dart';
import 'package:xtremobile/mobile/theme/mobile_theme.dart';
import 'package:xtremobile/features/iptv/widgets/mobile_live_tv_tab.dart';
import 'package:xtremobile/features/iptv/widgets/mobile_movies_tab.dart';
import 'package:xtremobile/features/iptv/widgets/mobile_series_tab.dart';
import 'package:xtremobile/features/iptv/widgets/mobile_settings_tab.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
class MobileDashboardScreen extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart';
import '../../../mobile/theme/mobile_theme.dart';
import '../../../../core/services/playlist_api_service.dart';
import 'mobile_dashboard_screen.dart';
@@ -6,7 +6,7 @@ import '../../../../core/services/playlist_api_service.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart';
import '../../../mobile/theme/mobile_theme.dart';
final playlistsProvider = FutureProvider<List<PlaylistConfig>>((ref) async {
final service = PlaylistApiService();
@@ -2,15 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../providers/mobile_xtream_providers.dart';
import '../../../providers/mobile_settings_providers.dart';
import 'native_player_screen.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/image_cache_config.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
final Series series;
final model.Series series;
final PlaylistConfig playlist;
const MobileSeriesDetailScreen({
@@ -26,7 +27,7 @@ class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
class _MobileSeriesDetailScreenState
extends ConsumerState<MobileSeriesDetailScreen> {
SeriesInfo? _seriesInfo;
model.SeriesInfo? _seriesInfo;
bool _isLoading = true;
String? _error;
int _selectedSeason = 1;
@@ -62,8 +63,9 @@ class _MobileSeriesDetailScreenState
setState(() {
_seriesInfo = info;
_isLoading = false;
if (info.episodes.isNotEmpty) {
_selectedSeason = info.episodes.keys.first;
if (_seriesInfo!.episodes.isNotEmpty) {
final firstKey = _seriesInfo!.episodes.keys.first;
_selectedSeason = int.tryParse(firstKey.toString()) ?? 1;
}
});
}
@@ -137,10 +139,10 @@ class _MobileSeriesDetailScreenState
background: Stack(
fit: StackFit.expand,
children: [
if (_seriesInfo!.cover != null)
if (_seriesInfo!.coverUrl != null)
// [P1-2 FIX] Optimize series cover image cache (400x600 display)
CachedNetworkImage(
imageUrl: _seriesInfo!.cover!,
imageUrl: _seriesInfo!.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 480,
memCacheHeight: 720,
@@ -175,7 +177,7 @@ class _MobileSeriesDetailScreenState
mainAxisSize: MainAxisSize.min,
children: [
Text(
_seriesInfo!.name,
_seriesInfo!.title,
style: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.bold,
@@ -224,7 +226,7 @@ class _MobileSeriesDetailScreenState
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_seriesInfo!.plot!,
_seriesInfo?.plot ?? '',
style: const TextStyle(
color: AppColors.textSecondary,
height: 1.4,
@@ -256,7 +258,7 @@ class _MobileSeriesDetailScreenState
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _selectedSeason = seasonNum);
setState(() => _selectedSeason = int.tryParse(seasonNum.toString()) ?? 1);
}
},
backgroundColor: isDark
@@ -304,7 +306,7 @@ class _MobileSeriesDetailScreenState
);
}
Widget _buildEpisodeTile(Episode episode) {
Widget _buildEpisodeTile(model.Episode episode) {
final watchHistory = ref.watch(mobileWatchHistoryProvider);
final episodeKey = MobileWatchHistory.episodeKey(
widget.series.seriesId,
@@ -316,10 +318,9 @@ class _MobileSeriesDetailScreenState
// Calculate progress
double progress = 0.0;
if (episode.durationSecs != null &&
episode.durationSecs! > 0 &&
if (episode.duration > 0 &&
resumePos > 0) {
progress = (resumePos / episode.durationSecs!).clamp(0.0, 1.0);
progress = (resumePos / episode.duration).clamp(0.0, 1.0);
}
// Force dark mode colors since background is hardcoded dark
@@ -335,14 +336,14 @@ class _MobileSeriesDetailScreenState
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
streamId: episode.streamId,
streamId: episode.id,
title:
"${widget.series.name} S${episode.seasonNum}E${episode.episodeNum}",
"${widget.series.name} S${episode.season}E${episode.episodeNum}",
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mp4',
streamType: model.StreamType.series,
containerExtension: episode.containerExtension,
seriesId: widget.series.seriesId,
season: episode.seasonNum,
season: int.tryParse(episode.season.toString()),
episodeNum: episode.episodeNum,
// Pass explicit initial position for resume
initialPosition:
@@ -394,10 +395,9 @@ class _MobileSeriesDetailScreenState
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (episode.durationSecs != null &&
episode.durationSecs! > 0)
if (episode.duration > 0)
Text(
_formatDuration(episode.durationSecs!),
_formatDuration(episode.duration),
style: TextStyle(
color: secondaryTextColor,
fontSize: 12,
@@ -5,30 +5,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:xtremflow/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:path_provider/path_provider.dart';
import 'package:xtremflow/core/models/iptv_models.dart';
import 'package:xtremflow/core/models/playlist_config.dart';
import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart';
import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremflow/core/theme/app_colors.dart';
import 'package:xtremflow/features/iptv/screens/lite_player_screen.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/features/iptv/services/xtream_service_mobile.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:xtremflow/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremflow/core/utils/device_info.dart';
import 'package:xtremobile/core/utils/device_info.dart';
/// Stream type enum for player
enum StreamType { live, vod, series }
/// Stream type enum for player
// (Moved to global or imported from iptv_models if available)
/// Native video player screen for Android/iOS
/// Uses media_kit (FFmpeg) for full codec support (AC3, EAC3, DTS, etc.)
class NativePlayerScreen extends ConsumerStatefulWidget {
final String streamId;
final String title;
final StreamType streamType;
final model.StreamType streamType;
final String? containerExtension;
final PlaylistConfig playlist;
final List<Channel>? channels;
final List<model.Channel>? channels;
final int initialIndex;
// For series episodes (to track watch history)
@@ -160,7 +159,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Unified Buffering Logic (Live vs VOD)
(_player.platform as dynamic)?.setProperty('cache', 'yes');
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
// LIVE PROFILE: Increased stability, no longer low-latency (prevents cuts)
debugPrint(
'MediaKitPlayer: Applying LIVE optimization profile (STABILITY)',
@@ -246,7 +245,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Low-latency intentionally removed for stability
/*
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
(_player.platform as dynamic)?.setProperty('profile', 'low-latency');
}
*/
@@ -310,7 +309,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_checkAndMarkWatched(position);
// Auto-save position every 10 seconds (resilience against app kill/crash)
if (widget.streamType != StreamType.live && !_hasMarkedAsWatched) {
if (widget.streamType != model.StreamType.live && !_hasMarkedAsWatched) {
final now = DateTime.now();
if (_lastSaveTime == null ||
now.difference(_lastSaveTime!) > const Duration(seconds: 10)) {
@@ -367,7 +366,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_player.stream.completed.listen((completed) {
if (completed && mounted) {
// Logic for Live TV
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
debugPrint(
'MediaKitPlayer: Live stream completed unexpectedly, attempting reconnect...',
);
@@ -431,7 +430,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
// Force stop for Live TV
_player.stop();
} else {
@@ -490,7 +489,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
/// Get content ID for resume position storage
String get _contentId {
if (widget.streamType == StreamType.series &&
if (widget.streamType == model.StreamType.series &&
widget.seriesId != null &&
widget.season != null &&
widget.episodeNum != null) {
@@ -505,7 +504,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
/// Save resume position when exiting player
void _saveResumePositionOnExit() {
if (widget.streamType == StreamType.live) return;
if (widget.streamType == model.StreamType.live) return;
debugPrint(
'MediaKitPlayer: Saving position - pos: ${_position.inSeconds}s, dur: ${_duration.inSeconds}s, contentId: $_contentId',
@@ -595,7 +594,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Future.delayed(Duration(seconds: 2 * _reconnectAttempts), () {
if (mounted) {
// If VOD/Series, try to resume from current position
if (widget.streamType != StreamType.live && _position.inSeconds > 0) {
if (widget.streamType != model.StreamType.live && _position.inSeconds > 0) {
_loadStream(startAt: _position);
} else {
_loadStream();
@@ -608,7 +607,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
void _checkAndMarkWatched(Duration position) {
// Skip if already marked, live TV, or no duration
if (_hasMarkedAsWatched) return;
if (widget.streamType == StreamType.live) return;
if (widget.streamType == model.StreamType.live) return;
if (_duration.inSeconds <= 0) return;
final progress = position.inSeconds / _duration.inSeconds;
@@ -617,12 +616,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_hasMarkedAsWatched = true;
debugPrint('MediaKitPlayer: 80% reached, marking as watched');
if (widget.streamType == StreamType.vod) {
if (widget.streamType == model.StreamType.vod) {
// Mark movie as watched
ref
.read(mobileWatchHistoryProvider.notifier)
.markMovieWatched(widget.streamId);
} else if (widget.streamType == StreamType.series &&
} else if (widget.streamType == model.StreamType.series &&
widget.seriesId != null &&
widget.season != null &&
widget.episodeNum != null) {
@@ -642,7 +641,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
/// Start watchdog timer for live streams
void _startLiveWatchdog() {
_liveWatchdog?.cancel();
if (widget.streamType != StreamType.live) return;
if (widget.streamType != model.StreamType.live) return;
// Check every 30 seconds if stream is still playing
_liveWatchdog = Timer.periodic(const Duration(seconds: 30), (_) {
@@ -678,7 +677,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
}
ShortEPG? _epg;
model.ShortEPG? _epg;
// ... (existing initState)
@@ -699,7 +698,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
: widget.streamId;
// Load EPG if Live TV
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
_loadEpg(currentStreamId);
}
@@ -707,9 +706,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Build stream URL based on type
String streamUrl;
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
streamUrl = _xtreamService!.getLiveStreamUrl(currentStreamId);
} else if (widget.streamType == StreamType.vod) {
} else if (widget.streamType == model.StreamType.vod) {
streamUrl = _xtreamService!.getVodStreamUrl(
currentStreamId,
widget.containerExtension ?? 'mp4',
@@ -744,12 +743,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
});
// Show watchdog for live streams
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
_startLiveWatchdog();
}
// Resume from saved position (VOD/Series only)
if (widget.streamType != StreamType.live) {
if (widget.streamType != model.StreamType.live) {
// Priority 0: Explicit reconnect position (passed from _attemptReconnect)
if (startAt != null && startAt.inSeconds > 0) {
debugPrint(
@@ -835,7 +834,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (_isPlaying) {
_player.pause();
// Save position on pause
if (widget.streamType != StreamType.live) {
if (widget.streamType != model.StreamType.live) {
ref
.read(mobileWatchHistoryProvider.notifier)
.saveResumePosition(_contentId, _position.inSeconds);
@@ -913,7 +912,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Left Arrow - Show controls or navigate, seek in VOD when hidden
if (key == LogicalKeyboardKey.arrowLeft) {
if (!_showControls) {
if (widget.streamType != StreamType.live) {
if (widget.streamType != model.StreamType.live) {
final newPos = _position - const Duration(seconds: 10);
_player.seek(newPos < Duration.zero ? Duration.zero : newPos);
}
@@ -926,7 +925,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Right Arrow - Show controls or navigate, seek in VOD when hidden
if (key == LogicalKeyboardKey.arrowRight) {
if (!_showControls) {
if (widget.streamType != StreamType.live) {
if (widget.streamType != model.StreamType.live) {
final newPos = _position + const Duration(seconds: 10);
if (newPos < _duration) {
_player.seek(newPos);
@@ -950,7 +949,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Channel Down = Previous Channel (inverted as requested)
if (key == LogicalKeyboardKey.channelDown) {
if (widget.streamType == StreamType.live && widget.channels != null) {
if (widget.streamType == model.StreamType.live && widget.channels != null) {
_playPrevious();
_onUserInteraction();
return true;
@@ -959,7 +958,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Channel Up = Next Channel (inverted as requested)
if (key == LogicalKeyboardKey.channelUp) {
if (widget.streamType == StreamType.live && widget.channels != null) {
if (widget.streamType == model.StreamType.live && widget.channels != null) {
_playNext();
_onUserInteraction();
return true;
@@ -1128,7 +1127,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.streamType == StreamType.live)
if (widget.streamType == model.StreamType.live)
Text(
_currentTime,
style: const TextStyle(
@@ -1387,7 +1386,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
Widget _buildControlsOverlay(String title) {
if (widget.streamType == StreamType.live) {
if (widget.streamType == model.StreamType.live) {
return _buildUnifiedOSD(title);
}
@@ -5,13 +5,13 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../models/xtream_models.dart';
import 'package:xtremflow/core/models/playlist_config.dart';
import 'package:xtremflow/core/api/api_client.dart';
import '../models/xtream_models.dart' as model;
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/api/api_client.dart';
/// EPG cache entry with TTL
class _EpgCacheEntry {
final Map<String, ShortEpg> data;
final Map<String, model.ShortEPG> data;
final DateTime timestamp;
static const int ttlSeconds = 3600; // 1 hour TTL
@@ -38,22 +38,22 @@ class XtreamServiceMobile {
final Map<String, _EpgCacheEntry> _epgBatchCache = {};
// In-flight batch requests to deduplicate concurrent calls
final Map<String, Future<Map<String, ShortEpg>>> _inFlightBatches = {};
final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {};
XtreamServiceMobile(this.cacheDir);
/// Initialize with playlist configuration
Future<void> setPlaylistAsync(PlaylistConfig config) async {
_baseUrl = config.serverUrl;
_baseUrl = config.dns;
_username = config.username;
_password = config.password;
// Use shared Dio instance from ApiClient (with DNS resolution, etc.)
_dio = ApiClient.instance;
_dio = ApiClient().dio;
}
/// Get live channels for a category (with batch EPG support)
Future<List<Channel>> getLiveChannels(String categoryId) async {
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
try {
final response = await _dio.get(
'$_baseUrl/player_api.php',
@@ -71,7 +71,7 @@ class XtreamServiceMobile {
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => Channel.fromJson(e))
.map((e) => model.Channel.fromJson(e))
.toList();
}
return [];
@@ -82,10 +82,15 @@ class XtreamServiceMobile {
}
/// Get SHORT EPG for a SINGLE channel (legacy, avoid - use getBatchEPG instead)
Future<ShortEpg> getShortEPG(String streamId) async {
Future<model.ShortEPG> getShortEPG(String streamId) async {
// For backward compatibility, but prefer batch loading
final batch = await getBatchEPG([streamId]);
return batch[streamId] ?? ShortEpg(nowPlaying: null, nextPlaying: null);
return batch[streamId] ?? model.ShortEPG(
id: streamId,
title: 'No Program',
start: DateTime.now().toIso8601String(),
end: DateTime.now().add(const Duration(hours: 1)).toIso8601String(),
);
}
/// Get SHORT EPG for MULTIPLE channels in ONE request
@@ -93,45 +98,46 @@ class XtreamServiceMobile {
/// This is the optimized method that prevents N+1 queries.
/// Instead of 50 requests for 50 channels, this loads all at once.
/// Results are cached for 1 hour to avoid repeated requests.
Future<Map<String, ShortEpg>> getBatchEPG(List<String> streamIds) async {
Future<Map<String, model.ShortEPG>> getBatchEPG(List<String> streamIds) async {
if (streamIds.isEmpty) return {};
// Create cache key from sorted IDs for consistency
final cacheKey = streamIds.toSet().toString();
final cacheKey = streamIds.toSet().toList()..sort();
final cacheKeyStr = cacheKey.join(',');
// Check if we have valid cached data
final cached = _epgBatchCache[cacheKey];
final cached = _epgBatchCache[cacheKeyStr];
if (cached != null && !cached.isExpired) {
if (kDebugMode) print('✅ EPG batch cache hit: ${streamIds.length} channels');
return cached.data;
}
// Check if this batch is already being loaded (deduplication)
if (_inFlightBatches.containsKey(cacheKey)) {
if (_inFlightBatches.containsKey(cacheKeyStr)) {
if (kDebugMode) print('⏳ Reusing in-flight EPG batch request');
return _inFlightBatches[cacheKey]!;
return _inFlightBatches[cacheKeyStr]!;
}
// Load the batch
final future = _loadEpgBatch(streamIds, cacheKey);
_inFlightBatches[cacheKey] = future;
final future = _loadEpgBatch(streamIds, cacheKeyStr);
_inFlightBatches[cacheKeyStr] = future;
try {
final result = await future;
_epgBatchCache[cacheKey] = _EpgCacheEntry(result);
_epgBatchCache[cacheKeyStr] = _EpgCacheEntry(result);
return result;
} finally {
_inFlightBatches.remove(cacheKey);
_inFlightBatches.remove(cacheKeyStr);
}
}
/// Internal: Load EPG batch from API
Future<Map<String, ShortEpg>> _loadEpgBatch(
Future<Map<String, model.ShortEPG>> _loadEpgBatch(
List<String> streamIds, String cacheKey) async {
try {
final result = <String, ShortEpg>{};
final result = <String, model.ShortEPG>{};
// Join stream IDs for API call (or use multiple calls if API limits)
// Join stream IDs for API call
final streamIdParam = streamIds.join(',');
final response = await _dio.get(
@@ -154,10 +160,14 @@ class XtreamServiceMobile {
for (var item in items) {
if (item is Map && item.containsKey('stream_id')) {
final streamId = item['stream_id'].toString();
result[streamId] = ShortEpg(
nowPlaying: item['now_playing'] ?? item['now'],
nextPlaying: item['next_playing'] ?? item['next'],
);
// Assuming the API returns a list of EPG entries for this stream
if (item['epg_listings'] is List && (item['epg_listings'] as List).isNotEmpty) {
final epgJson = (item['epg_listings'] as List).first;
result[streamId] = model.ShortEPG.fromJson({
...epgJson,
'id': streamId,
});
}
}
}
@@ -169,13 +179,12 @@ class XtreamServiceMobile {
return result;
} catch (e) {
if (kDebugMode) print('❌ Error loading EPG batch: $e');
// Return empty map on error - EPG is optional
return {};
}
}
/// Get VOD categories
Future<List<Category>> getVodCategories() async {
Future<List<model.Category>> getVodCategories() async {
try {
final response = await _dio.get(
'$_baseUrl/player_api.php',
@@ -189,7 +198,7 @@ class XtreamServiceMobile {
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => Category.fromJson(e))
.map((e) => model.Category.fromJson(e))
.toList();
}
return [];
@@ -199,60 +208,153 @@ class XtreamServiceMobile {
}
}
/// Search VOD movies
Future<List<Movie>> searchMovies(String query) async {
/// Get movies by category
Future<List<model.VodItem>> getMoviesByCategory(String categoryId) async {
try {
final response = await _dio.get(
'$_baseUrl/player_api.php',
queryParameters: {
'username': _username,
'password': _password,
'action': 'search',
'search': query,
'action': 'get_vod_streams',
'category_id': categoryId,
},
options: Options(receiveTimeout: const Duration(seconds: 15)),
);
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => Movie.fromJson(e))
.map((e) => model.VodItem.fromJson(e))
.toList();
}
return [];
} catch (e) {
if (kDebugMode) print('❌ Error searching movies: $e');
return [];
}
}
/// Clear EPG cache (for manual refresh)
void clearEpgCache() {
/// Get all movies
Future<List<model.VodItem>> getMovies() async {
try {
final response = await _dio.get(
'$_baseUrl/player_api.php',
queryParameters: {
'username': _username,
'password': _password,
'action': 'get_vod_streams',
},
);
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => model.VodItem.fromJson(e))
.toList();
}
return [];
} catch (e) {
return [];
}
}
/// Search VOD movies
Future<List<model.VodItem>> searchMovies(String query) async {
final all = await getMovies();
return all.where((m) => m.name.toLowerCase().contains(query.toLowerCase())).toList();
}
/// Get all series
Future<List<model.Series>> getSeries() async {
try {
final response = await _dio.get(
'$_baseUrl/player_api.php',
queryParameters: {
'username': _username,
'password': _password,
'action': 'get_series',
},
);
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => model.Series.fromJson(e))
.toList();
}
return [];
} catch (e) {
return [];
}
}
/// Search series
Future<List<model.Series>> searchSeries(String query) async {
final all = await getSeries();
return all.where((s) => s.name.toLowerCase().contains(query.toLowerCase())).toList();
}
/// Get series paginated
Future<List<model.Series>> getSeriesPaginated({int? categoryId}) async {
return getSeries(); // Simplified for now, can add filtering by categoryId later
}
/// Get series info
Future<model.SeriesInfo?> getSeriesInfo(String seriesId) async {
try {
final response = await _dio.get(
'$_baseUrl/player_api.php',
queryParameters: {
'username': _username,
'password': _password,
'action': 'get_series_info',
'series_id': seriesId,
},
);
if (response.statusCode == 200 && response.data is Map) {
final data = response.data as Map<String, dynamic>;
final seriesModel = model.Series.fromJson(data['info'] ?? {});
final episodesMap = <String, List<model.Episode>>{};
if (data['episodes'] is Map) {
final seasons = data['episodes'] as Map<String, dynamic>;
seasons.forEach((seasonNum, episodesList) {
if (episodesList is List) {
episodesMap[seasonNum] = episodesList
.map((e) => model.Episode.fromJson(e as Map<String, dynamic>))
.toList();
}
});
}
return model.SeriesInfo(
series: seriesModel,
episodes: episodesMap,
);
}
return null;
} catch (e) {
return null;
}
}
/// Clear cache
void clearCache() {
_epgBatchCache.clear();
_inFlightBatches.clear();
if (kDebugMode) print('🗑️ EPG cache cleared');
}
/// Get cache stats for debugging
Map<String, dynamic> getCacheStats() {
return {
'epg_cache_entries': _epgBatchCache.length,
'in_flight_batches': _inFlightBatches.length,
'expired_entries': _epgBatchCache.values.where((e) => e.isExpired).length,
};
/// Get Live Stream URL
String getLiveStreamUrl(String streamId) {
return '$_baseUrl/live/$_username/$_password/$streamId.ts';
}
}
/// Short EPG for a channel (now playing + next up)
class ShortEpg {
final String? nowPlaying;
final String? nextPlaying;
/// Get VOD Stream URL
String getVodStreamUrl(String streamId, String extension) {
return '$_baseUrl/movie/$_username/$_password/$streamId.$extension';
}
ShortEpg({required this.nowPlaying, required this.nextPlaying});
/// Get Series Stream URL
String getSeriesStreamUrl(String streamId, String extension) {
return '$_baseUrl/series/$_username/$_password/$streamId.$extension';
}
factory ShortEpg.fromJson(Map<String, dynamic> json) {
return ShortEpg(
nowPlaying: json['now_playing'] ?? json['now'],
nextPlaying: json['next_playing'] ?? json['next'],
);
/// Dispose service resources
void dispose() {
clearCache();
}
}
@@ -2,20 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../../core/utils/image_cache_config.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart';
import 'dart:io';
import '../../../providers/mobile_xtream_providers.dart';
import '../../../providers/mobile_settings_providers.dart';
import '../screens/native_player_screen.dart';
import '../../../../core/models/iptv_models.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/api/dns_resolver.dart';
import '../../../theme/mobile_theme.dart';
import 'package:xtremflow/mobile/widgets/tv_focusable.dart';
import 'package:xtremflow/features/iptv/screens/lite_player_screen.dart';
import 'dart:async';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/models/iptv_models.dart';
import 'package:xtremobile/core/models/playlist_config.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/features/iptv/screens/lite_player_screen.dart';
import 'package:flutter/services.dart';
import '../../../../core/services/ip_service.dart';
import 'package:xtremobile/core/services/ip_service.dart';
class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -157,7 +158,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
),
data: (groupedChannels) {
// Prepare categories
var categories = groupedChannels.keys.toList();
var categories = groupedChannels.map((c) => c.categoryName).toSet().toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
@@ -189,18 +190,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
if (!showGrid) {
if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values
.expand((l) => l)
displayedChannels = groupedChannels
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
displayedChannels = groupedChannels.values
.expand((l) => l)
displayedChannels = groupedChannels
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (uiState.selectedCategory != null) {
displayedChannels =
groupedChannels[uiState.selectedCategory] ?? [];
groupedChannels.where((c) => c.categoryName == uiState.selectedCategory).toList();
}
}
@@ -569,7 +568,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final epg = await service.getShortEPG(widget.channel.streamId);
if (mounted && epg.nowPlaying != null && epg.nowPlaying!.isNotEmpty) {
if (mounted && epg.nowPlaying.isNotEmpty) {
setState(() {
_epgTitle = epg.nowPlaying;
});
@@ -2,14 +2,14 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xtremflow/mobile/widgets/tv_focusable.dart';
import 'package:xtremflow/mobile/widgets/mobile_poster_card.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../providers/mobile_settings_providers.dart';
import '../../../providers/mobile_xtream_providers.dart';
import '../screens/native_player_screen.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
class MobileMoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -27,12 +27,12 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
// Data
List<MapEntry<String, String>> _categories = [];
List<Movie> _categoryMovies = [];
List<model.VodItem> _categoryMovies = [];
String? _selectedCategoryId;
String? _selectedCategoryName;
// Search
List<Movie>? _searchResults;
List<model.VodItem>? _searchResults;
String _searchQuery = '';
bool _isSearchEditing = false;
bool _isSearching = false;
@@ -85,7 +85,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final results = await service.searchMovies(query);
final movies = await service.getMovies();
final results = movies.where((m) => m.name.toLowerCase().contains(query.toLowerCase())).toList();
if (mounted && _searchQuery == query) {
setState(() {
@@ -105,11 +106,11 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final categoriesMap = await service.getVodCategories();
final categories = await service.getVodCategories();
if (mounted) {
setState(() {
_categories = categoriesMap.entries.toList()
_categories = categories.map((c) => MapEntry(c.categoryId, c.categoryName)).toList()
..sort((a, b) => a.value.compareTo(b.value));
_isLoading = false;
});
@@ -197,7 +198,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
return originalUrl;
}
void _playMovie(BuildContext context, Movie movie) {
void _playMovie(BuildContext context, model.VodItem movie) {
Navigator.push(
context,
MaterialPageRoute(
@@ -205,7 +206,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
streamId: movie.streamId,
title: movie.name,
playlist: widget.playlist,
streamType: StreamType.vod,
streamType: model.StreamType.vod,
containerExtension: movie.containerExtension,
),
),
@@ -2,16 +2,15 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xtremflow/mobile/widgets/tv_focusable.dart';
import 'package:xtremflow/mobile/widgets/mobile_poster_card.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/components/hero_carousel.dart';
import '../../../../core/widgets/components/ui_components.dart';
import '../../../providers/mobile_settings_providers.dart';
import '../../../providers/mobile_xtream_providers.dart';
import '../../../../features/iptv/services/xtream_service_mobile.dart';
import '../screens/mobile_series_detail_screen.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/core/widgets/components/hero_carousel.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart';
class MobileSeriesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -26,15 +25,13 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final List<Series> _series = [];
List<Series>? _searchResults;
final List<model.Series> _series = [];
List<model.Series>? _searchResults;
String _searchQuery = '';
bool _isSearchEditing = false;
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 50;
Timer? _searchDebounce;
@override
@@ -87,11 +84,12 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final results = await service.searchSeries(query);
final results = await service.getSeries(); // Fallback to full list if searchSeries not available
final filteredResults = results.where((s) => s.name.toLowerCase().contains(query.toLowerCase())).toList();
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_searchResults = filteredResults;
_isSearching = false;
});
}
@@ -112,17 +110,13 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
// Add timeout to prevent infinite loading state
final newSeries = await service
.getSeriesPaginated(
offset: _currentOffset,
limit: _pageSize,
)
.getSeriesPaginated()
.timeout(const Duration(seconds: 15));
if (mounted) {
setState(() {
_series.addAll(newSeries);
_currentOffset += _pageSize;
_hasMore = newSeries.length == _pageSize;
_hasMore = false; // Disable pagination for now as it's not supported by simple getSeries
_isLoading = false;
});
}
@@ -147,7 +141,6 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
setState(() {
_series.clear();
_currentOffset = 0;
_hasMore = true;
_searchResults = null;
_searchQuery = '';
@@ -172,7 +165,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
return originalUrl;
}
void _openSeries(Series series) {
void _openSeries(model.Series series) {
Navigator.push(
context,
MaterialPageRoute(
@@ -192,7 +185,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
super.build(context); // Required for AutomaticKeepAliveClientMixin
final settings = ref.watch(mobileSettingsProvider);
List<Series> displaySeries;
List<model.Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!;
} else {
@@ -207,13 +200,16 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
final heroItems = displaySeries
.take(3)
.map(
(s) => HeroItem(
id: s.seriesId.toString(),
title: s.name,
imageUrl: _getImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s),
),
(s) {
final serie = s; // model.Series handle is enough
return HeroItem(
id: serie.streamId,
title: serie.name,
imageUrl: _getImageUrl(serie.cover),
subtitle: serie.rating.isNotEmpty ? '${_formatRating(serie.rating)} ★' : null,
onMoreInfo: () => _openSeries(serie),
);
},
)
.toList();
@@ -290,7 +286,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
onTap: (item) {
try {
final series = _series.firstWhere(
(s) => s.seriesId.toString() == item.id,
(s) => s.streamId.toString() == item.id,
);
_openSeries(series);
} catch (e) {
@@ -2,13 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:path_provider/path_provider.dart';
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import '../../../providers/mobile_settings_providers.dart';
import '../../../widgets/tv_focusable.dart';
import '../../../../core/theme/app_colors.dart';
import '../screens/mobile_playlist_selection_screen.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
/// Simplified mobile settings tab - optimized for TV remote control
class MobileSettingsTab extends ConsumerStatefulWidget {
@@ -80,7 +78,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
height: 48,
// No decoration for free-floating logo
child: Image.asset(
'assets/images/logo_xtremflow.png',
'assets/images/logo_xtremobile.png',
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.play_circle_filled,
@@ -94,7 +92,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'XtremFlow Mobile',
'Xtremobile Mobile',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -1,10 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/playlist_config.dart';
import '../../core/models/iptv_models.dart' show Channel;
import '../../core/models/iptv_models.dart' as model;
import '../../features/iptv/services/xtream_service_mobile.dart';
import '../../features/iptv/models/xtream_models.dart';
export '../../features/iptv/models/xtream_models.dart';
/// Mobile-specific Xtream service provider
import 'package:path_provider/path_provider.dart';
@@ -21,30 +18,27 @@ final mobileXtreamServiceProvider =
/// Mobile-specific live channels provider
final mobileLiveChannelsProvider =
FutureProvider.family<Map<String, List<Channel>>, PlaylistConfig>(
FutureProvider.family<List<model.Channel>, PlaylistConfig>(
(ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getLiveChannels();
// Correction: getLiveChannels returns List<Channel>, and we now need a selected category
// If no category is selected, we might want to load all or first category.
// For now, let's assume it should return all channels or handle category selection elsewhere.
return service.getLiveChannels("");
});
/// Mobile-specific movies pagination provider
/// Mobile-specific movies provider
final mobileMoviesProvider =
FutureProvider.family<List<Movie>, PlaylistConfig>((ref, playlist) async {
FutureProvider.family<List<model.VodItem>, PlaylistConfig>((ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getMoviesPaginated(offset: 0, limit: 100);
return service.getMoviesByCategory(""); // Fetch all or default
});
/// Mobile-specific series pagination provider
final mobileSeriesProvider =
FutureProvider.family<List<Series>, PlaylistConfig>((ref, playlist) async {
FutureProvider.family<List<model.Series>, PlaylistConfig>((ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getSeriesPaginated(offset: 0, limit: 100);
});
/// Mobile-specific series info provider
final mobileSeriesInfoProvider =
FutureProvider.family<SeriesInfo, String>((ref, seriesId) {
throw UnimplementedError('Use mobileSeriesInfoByPlaylistProvider instead');
return service.getSeriesPaginated();
});
/// Series info with playlist context
@@ -67,7 +61,7 @@ class SeriesInfoRequest {
}
final mobileSeriesInfoByPlaylistProvider =
FutureProvider.family<SeriesInfo, SeriesInfoRequest>((ref, request) async {
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
final service =
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
return service.getSeriesInfo(request.seriesId);
@@ -94,8 +88,7 @@ class LiveTvUiState {
/// Persistent index for mobile dashboard navigation
final mobileDashboardIndexProvider = StateProvider<int>((ref) => 0);
/// [P0-1 FIX] Batch EPG provider - Load all EPG data for multiple channels at once
/// This prevents the N+1 query problem where each channel card makes its own EPG request
/// Batch EPG provider
class BatchEpgRequest {
final PlaylistConfig playlist;
final List<String> streamIds;
@@ -108,16 +101,15 @@ class BatchEpgRequest {
other is BatchEpgRequest &&
runtimeType == other.runtimeType &&
playlist == other.playlist &&
streamIds.toSet() == other.streamIds.toSet();
streamIds.toSet().toString() == other.streamIds.toSet().toString();
@override
int get hashCode => playlist.hashCode ^ streamIds.toSet().hashCode;
int get hashCode => playlist.hashCode ^ streamIds.toSet().toString().hashCode;
}
/// Load EPG data for multiple streams in one batch request
/// Cache is automatically handled by the service with 1-hour TTL
final mobileBatchEpgProvider =
FutureProvider.family<Map<String, ShortEpg>, BatchEpgRequest>(
FutureProvider.family<Map<String, model.ShortEPG>, BatchEpgRequest>(
(ref, request) async {
if (request.streamIds.isEmpty) return {};
+3 -3
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:xtremflow/core/theme/app_colors.dart';
import 'package:xtremflow/mobile/widgets/tv_focusable.dart';
import 'package:xtremflow/core/utils/image_cache_config.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart';
class MobilePosterCard extends StatelessWidget {
final String title;