mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-17 12:18:42 +02:00
feat: Add generated Hive TypeAdapters for AppUser and PlaylistConfig models, and new build/doctor log files.
This commit is contained in:
1 parent
780f6c1842
commit
ef97c6dc90
81 files changed
+3233
-2428
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user