feat: add mobile-optimized IPTV player with HLS and MpegTS support

This commit is contained in:
Michael committed 2026-03-29 10:19:36 +02:00
1 parent 3c9a9c7be7
commit fa6ae90d8d
38 files changed
+1280 -895

No files matched your search

@@ -33,20 +33,23 @@ class RecommendationService {
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
for (final item in allContent) {
final historyKey = item.streamId.toString();
if (watchHistory.containsKey(historyKey)) {
final lastPosition = watchHistory[historyKey] as double? ?? 0.0;
// Only show if watched more than 5 seconds ago and less than 95% complete
if (lastPosition > 5 && lastPosition < 95) {
recommendations.add(Recommendation(
item: item,
type: RecommendationType.continueWatching,
score: 100.0 - lastPosition, // Score higher if recently started
reason: 'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
));
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.continueWatching,
score: 100.0 - lastPosition, // Score higher if recently started
reason:
'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
),
);
}
}
}
@@ -62,16 +65,18 @@ class RecommendationService {
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
for (final item in allContent) {
final views = viewCounts[item.streamId.toString()] ?? 0;
if (views > 0) {
recommendations.add(Recommendation(
item: item,
type: RecommendationType.trending,
score: views.toDouble(),
reason: '$views views - Trending now',
));
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.trending,
score: views.toDouble(),
reason: '$views views - Trending now',
),
);
}
}
@@ -82,7 +87,7 @@ class RecommendationService {
/// Get recently added content
static List<Recommendation> getRecentlyAdded(List<Playlist> allContent) {
final recommendations = <Recommendation>[];
// Sort by added date (most recent first)
final sorted = [...allContent];
sorted.sort((a, b) {
@@ -95,13 +100,15 @@ class RecommendationService {
final daysAgo = DateTime.now()
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
.inDays;
recommendations.add(Recommendation(
item: item,
type: RecommendationType.recentlyAdded,
score: (20 - daysAgo).toDouble().clamp(0, 100),
reason: 'Added $daysAgo days ago',
));
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.recentlyAdded,
score: (20 - daysAgo).toDouble().clamp(0, 100),
reason: 'Added $daysAgo days ago',
),
);
}
return recommendations.take(10).toList();
@@ -113,7 +120,7 @@ class RecommendationService {
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
// Collect watched categories
final watchedCategories = <String>{};
watchHistory.forEach((key, value) {
@@ -125,12 +132,14 @@ class RecommendationService {
// Recommend similar content
for (final item in allContent) {
if (watchedCategories.contains(item.categoryId)) {
recommendations.add(Recommendation(
item: item,
type: RecommendationType.forYou,
score: 75.0,
reason: 'Based on your interests',
));
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.forYou,
score: 75.0,
reason: 'Based on your interests',
),
);
}
}
@@ -146,12 +155,14 @@ class RecommendationService {
final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
return rating >= 7.5;
}).take(5)) {
recommendations.add(Recommendation(
item: item,
type: RecommendationType.topRated,
score: double.tryParse(item.rating ?? '0') ?? 0.0,
reason: 'Highly rated - ${item.rating}/10',
));
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.topRated,
score: double.tryParse(item.rating ?? '0') ?? 0.0,
reason: 'Highly rated - ${item.rating}/10',
),
);
}
// Remove duplicates and limit
@@ -169,9 +180,8 @@ class RecommendationService {
}
/// Provider for watch history
final watchHistoryProvider = StateNotifierProvider<
WatchHistoryNotifier,
Map<String, dynamic>>((ref) {
final watchHistoryProvider =
StateNotifierProvider<WatchHistoryNotifier, Map<String, dynamic>>((ref) {
return WatchHistoryNotifier();
});
@@ -194,9 +204,8 @@ class WatchHistoryNotifier extends StateNotifier<Map<String, dynamic>> {
}
/// Provider for view counts/trending
final trendingProvider = StateNotifierProvider<
TrendingNotifier,
Map<String, int>>((ref) {
final trendingProvider =
StateNotifierProvider<TrendingNotifier, Map<String, int>>((ref) {
return TrendingNotifier();
});
@@ -213,11 +222,11 @@ class TrendingNotifier extends StateNotifier<Map<String, int>> {
}
/// Provider for recommendations
final recommendationsProvider =
FutureProvider.family<List<Recommendation>, (Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
final recommendationsProvider = FutureProvider.family<List<Recommendation>,
(Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
(ref, params) async {
final (playlist, watchHistory, trending, allContent) = params;
// Combine all recommendation types
final allRecommendations = <Recommendation>[
...RecommendationService.getContinueWatching(watchHistory, allContent),
@@ -229,7 +238,7 @@ final recommendationsProvider =
// Deduplicate and score
final seen = <int>{};
final scored = <Recommendation>[];
for (final rec in allRecommendations) {
if (!seen.contains(rec.item.streamId)) {
scored.add(rec);
@@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
@@ -25,7 +24,13 @@ class DashboardScreen extends ConsumerStatefulWidget {
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
int _selectedIndex = 0;
final List<String> _tabs = ['Live TV', 'Movies', 'Series', 'Recordings', 'Settings'];
final List<String> _tabs = [
'Live TV',
'Movies',
'Series',
'Recordings',
'Settings'
];
final List<IconData> _icons = [
Icons.live_tv_rounded,
Icons.movie_rounded,
+30 -21
View File
@@ -30,7 +30,7 @@ class EpgProgram {
});
Duration get duration => endTime.difference(startTime);
double get progress {
if (!isNow) return 0;
final now = DateTime.now();
@@ -45,9 +45,9 @@ class EpgGridScreen extends ConsumerStatefulWidget {
final Playlist playlist;
const EpgGridScreen({
Key? key,
super.key,
required this.playlist,
}) : super(key: key);
});
@override
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
@@ -112,7 +112,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
}
List<EpgProgram> _parseEpgPrograms(
Map<String, dynamic> epgData, String channelId) {
Map<String, dynamic> epgData,
String channelId,
) {
final programs = <EpgProgram>[];
final now = DateTime.now();
@@ -130,17 +132,19 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
final start = _parseEpgTime(startStr);
final end = _parseEpgTime(endStr);
programs.add(EpgProgram(
id: '${channelId}_${start.millisecondsSinceEpoch}',
channelId: channelId,
title: title,
description: desc,
startTime: start,
endTime: end,
isNow: now.isAfter(start) && now.isBefore(end),
isNext: start.isAfter(now) &&
start.difference(now).inMinutes < 60,
));
programs.add(
EpgProgram(
id: '${channelId}_${start.millisecondsSinceEpoch}',
channelId: channelId,
title: title,
description: desc,
startTime: start,
endTime: end,
isNow: now.isAfter(start) && now.isBefore(end),
isNext:
start.isAfter(now) && start.difference(now).inMinutes < 60,
),
);
}
} catch (e) {
continue;
@@ -192,7 +196,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(day, style: const TextStyle(fontSize: 11)),
Text(dateNum, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
Text(dateNum,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.bold)),
],
),
);
@@ -250,7 +256,8 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
child: Center(
child: Text(
'$hour:00',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.bold),
),
),
);
@@ -335,7 +342,8 @@ class _EpgChannelRow extends StatelessWidget {
// Programs
...programs.map((program) {
final duration = program.duration.inMinutes;
final width = (duration / 60 * 100).clamp(40, double.infinity).toDouble();
final width =
(duration / 60 * 100).clamp(40, double.infinity).toDouble();
return GestureDetector(
onTap: () => onProgramTap(program),
@@ -397,8 +405,7 @@ class _EpgChannelRow extends StatelessWidget {
value: program.progress / 100,
minHeight: 3,
backgroundColor: Colors.white24,
valueColor:
const AlwaysStoppedAnimation(Colors.red),
valueColor: const AlwaysStoppedAnimation(Colors.red),
),
),
),
@@ -455,7 +462,9 @@ class _ProgramDetailSheet extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
program.description.isEmpty ? 'No description available' : program.description,
program.description.isEmpty
? 'No description available'
: program.description,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 24),
+37 -41
View File
@@ -11,11 +11,8 @@ import '../providers/playback_positions_provider.dart';
// ... (existing imports)
// ... (existing imports)
import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/widgets/tv_focusable_card.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/theme/app_colors.dart';
import '../widgets/epg_overlay.dart';
enum StreamType { live, vod, series }
@@ -57,7 +54,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
StreamSubscription? _messageSubscription;
final String _aspectRatio = 'contain';
bool _isSeeking = false;
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString();
late final String _viewIdPrefix =
DateTime.now().millisecondsSinceEpoch.toString();
String _viewId = 'iptv-player';
String? _currentStreamUrl;
String _statusMessage = 'Loading...';
@@ -134,7 +132,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Store URL for Lite Player
final isMobile = MediaQuery.of(context).size.width < 600;
final isLiveTV = widget.streamType == StreamType.live;
// TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping
if (isLiveTV && !isMobile) {
streamUrl = service.getLiveStreamUrlTs(currentStreamId);
@@ -152,15 +150,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Force player choice based on stream type:
// - Live TV: Player Lite (simple TS playback with mpegts.js)
// - VOD/Series: Player Standard (fuller controls, HLS support)
// Cache buster to force reload of updated player.html
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod';
final playerFile = isMobile ? 'player_mobile.html' : (isLiveTV ? 'player_lite.html' : 'player.html');
final streamTypeParam =
widget.streamType == StreamType.live ? 'live' : 'vod';
final playerFile = isMobile
? 'player_mobile.html'
: (isLiveTV ? 'player_lite.html' : 'player.html');
// Inject Turbo-Start flag
var playerSrc = '$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
var playerSrc =
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
if (startTimeOverride != null) {
playerSrc += '&t=$startTimeOverride';
@@ -300,31 +302,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void _toggleFullscreen() {
try {
final doc = html.document;
final isFullscreen = doc.fullscreenElement != null ||
(doc as dynamic).webkitFullscreenElement != null ||
(doc as dynamic).mozFullScreenElement != null;
final isFullscreen = doc.fullscreenElement != null ||
(doc as dynamic).webkitFullscreenElement != null ||
(doc as dynamic).mozFullScreenElement != null;
if (isFullscreen) {
if (doc.exitFullscreen != null) {
doc.exitFullscreen();
} else if ((doc as dynamic).webkitExitFullscreen != null) {
(doc as dynamic).webkitExitFullscreen();
} else if ((doc as dynamic).mozCancelFullScreen != null) {
(doc as dynamic).mozCancelFullScreen();
}
doc.exitFullscreen();
} else {
final element = doc.documentElement;
if (element != null) {
if (element.requestFullscreen != null) {
element.requestFullscreen();
} else if ((element as dynamic).webkitRequestFullscreen != null) {
(element as dynamic).webkitRequestFullscreen();
} else if ((element as dynamic).mozRequestFullScreen != null) {
(element as dynamic).mozRequestFullScreen();
} else {
// Fallback: Message the player if browser API fails
_sendMessage({'type': 'request_fullscreen'});
}
element.requestFullscreen();
}
}
} catch (e) {
@@ -442,9 +429,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [
Text(
widget.channels != null
? (widget.channels![_currentIndex]
? (widget
.channels![
_currentIndex]
.name ??
widget.channels![
widget
.channels![
_currentIndex]
.title ??
'Unknown')
@@ -478,12 +468,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Unified Bottom Bar - EPG + Controls in one bar
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
bottom: _showControls ? 24 : -150, // More offset for stacked layout
bottom: _showControls
? 24
: -150, // More offset for stacked layout
left: 16,
right: 16,
child: LayoutBuilder(
builder: (context, constraints) {
final isSmallScreen = constraints.maxWidth < 600;
final isSmallScreen =
constraints.maxWidth < 600;
return Container(
padding: EdgeInsets.symmetric(
horizontal: isSmallScreen ? 16 : 20,
@@ -494,13 +487,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: AppColors.background.withOpacity(0.3),
color: AppColors.background
.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 2,
),
],
border: Border.all(
color: AppColors.textPrimary.withOpacity(0.15),
color: AppColors.textPrimary
.withOpacity(0.15),
),
),
child: isSmallScreen
@@ -510,14 +505,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// EPG Info (top in mobile)
_buildInlineEpg(),
const SizedBox(height: 12),
const Divider(color: Colors.white10),
const Divider(
color: Colors.white10),
const SizedBox(height: 8),
// Control Buttons
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: _buildControlButtons(
isSmallScreen),
isSmallScreen,
),
),
],
)
@@ -532,7 +529,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Row(
mainAxisSize: MainAxisSize.min,
children: _buildControlButtons(
isSmallScreen),
isSmallScreen,
),
),
],
),
@@ -851,7 +849,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
onTap: _toggleMute,
size: buttonSize,
),
];
}
@@ -1059,5 +1056,4 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
},
);
}
}
@@ -37,8 +37,10 @@ class PlaylistSelectionScreen extends ConsumerWidget {
actions: [
if (currentUser?.isAdmin ?? false)
IconButton(
icon: const Icon(Icons.admin_panel_settings,
color: AppColors.textSecondary),
icon: const Icon(
Icons.admin_panel_settings,
color: AppColors.textSecondary,
),
onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel',
),
@@ -75,8 +77,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
!constraints.maxHeight.isFinite ||
constraints.maxHeight <= 0) {
return const Center(
child:
CircularProgressIndicator(color: AppColors.primary));
child: CircularProgressIndicator(color: AppColors.primary),
);
}
return playlistsAsync.when(
@@ -87,8 +89,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 64, color: AppColors.error),
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
Text(
'Error loading playlists',
@@ -120,12 +120,15 @@ class DownloadService {
/// Get download progress
double getDownloadProgress(String id) {
final task = _downloads.firstWhere((t) => t.id == id, orElse: () => DownloadTask(
id: id,
title: '',
url: '',
filePath: '',
));
final task = _downloads.firstWhere(
(t) => t.id == id,
orElse: () => DownloadTask(
id: id,
title: '',
url: '',
filePath: '',
),
);
return task.progress;
}
@@ -151,13 +154,12 @@ class DownloadService {
if (available < 1024 * 1024 * 1024) {
// Less than 1GB free
final dir = Directory(_downloadDir);
final files = dir.listSync()
.whereType<File>()
.toList();
final files = dir.listSync().whereType<File>().toList();
// Sort by modification time, delete oldest first
files.sort((a, b) =>
a.statSync().modified.compareTo(b.statSync().modified));
files.sort(
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
);
for (final file in files.take(files.length ~/ 3)) {
try {
@@ -216,7 +218,8 @@ class DownloadService {
}
// Check if paused or cancelled
final currentTask = _downloads.firstWhere((t) => t.id == task.id, orElse: () => task);
final currentTask =
_downloads.firstWhere((t) => t.id == task.id, orElse: () => task);
if (currentTask.status == DownloadStatus.paused ||
!_activeDownloads.contains(task.id)) {
break;
@@ -301,8 +304,6 @@ class DownloadsNotifier extends StateNotifier<List<DownloadTask>> {
}
void clearCompleted() {
state = state
.where((d) => d.status != DownloadStatus.completed)
.toList();
state = state.where((d) => d.status != DownloadStatus.completed).toList();
}
}
@@ -17,12 +17,12 @@ class SubtitleTrack {
});
SubtitleTrack copyWith({bool? isEnabled}) => SubtitleTrack(
id: id,
name: name,
language: language,
url: url,
isEnabled: isEnabled ?? this.isEnabled,
);
id: id,
name: name,
language: language,
url: url,
isEnabled: isEnabled ?? this.isEnabled,
);
}
/// Subtitle content with timing
@@ -42,16 +42,17 @@ class SubtitleEntry {
/// Service for handling subtitles
class SubtitleService {
static const _srtPattern = r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)';
static const _srtPattern =
r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)';
/// Parse SRT subtitle content
static List<SubtitleEntry> parseSrt(String content) {
final entries = <SubtitleEntry>[];
final blocks = content.split('\n\n');
for (final block in blocks) {
if (block.trim().isEmpty) continue;
final lines = block.trim().split('\n');
if (lines.length < 3) continue;
@@ -67,12 +68,14 @@ class SubtitleService {
final startTime = _parseTime(times[0].trim());
final endTime = _parseTime(times[1].trim());
entries.add(SubtitleEntry(
index: index,
startTime: startTime,
endTime: endTime,
text: text,
));
entries.add(
SubtitleEntry(
index: index,
startTime: startTime,
endTime: endTime,
text: text,
),
);
} catch (e) {
continue;
}
@@ -85,10 +88,10 @@ class SubtitleService {
static List<SubtitleEntry> parseWebVtt(String content) {
final entries = <SubtitleEntry>[];
final blocks = content.split('\n\n');
for (final block in blocks) {
if (block.trim().isEmpty || block.startsWith('WEBVTT')) continue;
final lines = block.trim().split('\n');
if (lines.length < 2) continue;
@@ -103,12 +106,14 @@ class SubtitleService {
final startTime = _parseWebVttTime(times[0].trim());
final endTime = _parseWebVttTime(times[1].trim());
entries.add(SubtitleEntry(
index: entries.length,
startTime: startTime,
endTime: endTime,
text: text,
));
entries.add(
SubtitleEntry(
index: entries.length,
startTime: startTime,
endTime: endTime,
text: text,
),
);
} catch (e) {
continue;
}
@@ -120,7 +125,8 @@ class SubtitleService {
/// Get subtitle at specific time
static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) {
for (final entry in entries) {
if (time.compareTo(entry.startTime) >= 0 && time.compareTo(entry.endTime) <= 0) {
if (time.compareTo(entry.startTime) >= 0 &&
time.compareTo(entry.endTime) <= 0) {
return entry.text;
}
}
+39 -22
View File
@@ -183,11 +183,14 @@ class XtreamService {
/// Get all live TV channels grouped by category
///
/// Returns a Map where key is category name and value is list of channels
Future<Map<String, List<Channel>>> getLiveChannels({bool refresh = false}) async {
Future<Map<String, List<Channel>>> getLiveChannels(
{bool refresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
final options = refresh
? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra();
try {
@@ -226,7 +229,8 @@ class XtreamService {
return groupedChannels;
} catch (e) {
// Try to return from local cache if network fails
final cached = _loadFromLocalCache<Channel>('live_channels', Channel.fromJson);
final cached =
_loadFromLocalCache<Channel>('live_channels', Channel.fromJson);
if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch live channels: $e');
}
@@ -236,27 +240,30 @@ class XtreamService {
if (!kIsWeb) return;
try {
final jsonStr = jsonEncode(data);
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] = jsonStr;
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
jsonStr;
} catch (e) {
debugPrint('Error saving to local cache ($key): $e');
}
}
Map<String, List<T>> _loadFromLocalCache<T>(
String key, T Function(Map<String, dynamic>) fromJson) {
String key,
T Function(Map<String, dynamic>) fromJson,
) {
if (!kIsWeb) return {};
try {
final jsonStr = html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'];
final jsonStr =
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'];
if (jsonStr == null) return {};
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
final result = <String, List<T>>{};
data.forEach((cat, items) {
if (items is List) {
result[cat] = items
.map((i) => fromJson(i as Map<String, dynamic>))
.toList();
result[cat] =
items.map((i) => fromJson(i as Map<String, dynamic>)).toList();
}
});
return result;
@@ -301,8 +308,10 @@ class XtreamService {
Future<Map<String, List<VodItem>>> getVodItems({bool refresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
final options = refresh
? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra();
try {
@@ -342,7 +351,8 @@ class XtreamService {
return groupedVods;
} catch (e) {
// Try to return from local cache if network fails
final cached = _loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson);
final cached =
_loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson);
if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch VOD items: $e');
}
@@ -383,8 +393,10 @@ class XtreamService {
Future<Map<String, List<Series>>> getSeries({bool refresh = false}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
final options = refresh
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
final options = refresh
? _cacheOptions
.copyWith(policy: CachePolicy.refreshForceCache)
.toExtra()
: _cacheOptions.toExtra();
try {
@@ -424,15 +436,18 @@ class XtreamService {
return groupedSeries;
} catch (e) {
// Try to return from local cache if network fails
final cached = _loadFromLocalCache<Series>('series_items', Series.fromJson);
final cached =
_loadFromLocalCache<Series>('series_items', Series.fromJson);
if (cached.isNotEmpty) return cached;
throw Exception('Failed to fetch series: $e');
}
}
/// Get movies with pagination support
Future<List<xm.Movie>> getMoviesPaginated(
{int offset = 0, int limit = 100}) async {
Future<List<xm.Movie>> getMoviesPaginated({
int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
@@ -511,8 +526,10 @@ class XtreamService {
}
/// Get series with pagination support (returns flat list)
Future<List<xm.Series>> getSeriesPaginated(
{int offset = 0, int limit = 100}) async {
Future<List<xm.Series>> getSeriesPaginated({
int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
+6 -2
View File
@@ -79,7 +79,9 @@ class _ChannelCardState extends State<ChannelCard>
child: GestureDetector(
onTap: widget.onTap,
child: ScaleTransition(
scale: widget.isActive ? _pulseAnimation : AlwaysStoppedAnimation(1.0),
scale: widget.isActive
? _pulseAnimation
: const AlwaysStoppedAnimation(1.0),
child: AnimatedContainer(
duration: AppTheme.durationMd,
curve: AppTheme.curveDefault,
@@ -90,7 +92,9 @@ class _ChannelCardState extends State<ChannelCard>
border: Border.all(
color: widget.isActive
? AppColors.primary
: (_isHovered ? AppColors.border : AppColors.border.withOpacity(0.5)),
: (_isHovered
? AppColors.border
: AppColors.border.withOpacity(0.5)),
width: widget.isActive ? 2.5 : 1.5,
),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
@@ -12,11 +12,11 @@ class ContinueWatchingWidget extends ConsumerStatefulWidget {
final VoidCallback? onItemTap;
const ContinueWatchingWidget({
Key? key,
super.key,
required this.playlist,
required this.content,
this.onItemTap,
}) : super(key: key);
});
@override
ConsumerState<ContinueWatchingWidget> createState() =>
@@ -68,12 +68,12 @@ class _ContinueWatchingWidgetState
const SizedBox(width: 16),
...continueWatching.take(5).map((rec) {
final item = rec.item;
final percentage = watchHistory[item.streamId.toString()]
is Map
? (watchHistory[item.streamId.toString()]
as Map)['percentage'] as double? ??
0.0
: 0.0;
final percentage =
watchHistory[item.streamId.toString()] is Map
? (watchHistory[item.streamId.toString()]
as Map)['percentage'] as double? ??
0.0
: 0.0;
return Container(
margin: const EdgeInsets.only(right: 12),
@@ -132,9 +132,10 @@ class _ContinueWatchingCard extends StatelessWidget {
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) =>
Icon(Icons.image_not_supported,
color: Colors.grey[600]),
errorWidget: (context, url, error) => Icon(
Icons.image_not_supported,
color: Colors.grey[600],
),
),
),
@@ -239,11 +240,11 @@ class TrendingWidget extends ConsumerWidget {
final VoidCallback? onItemTap;
const TrendingWidget({
Key? key,
super.key,
required this.playlist,
required this.content,
this.onItemTap,
}) : super(key: key);
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -265,12 +266,15 @@ class TrendingWidget extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
const Row(
children: [
const Icon(Icons.local_fire_department,
color: Colors.red, size: 24),
const SizedBox(width: 8),
const Text(
Icon(
Icons.local_fire_department,
color: Colors.red,
size: 24,
),
SizedBox(width: 8),
Text(
'Trending Now',
style: TextStyle(
fontSize: 18,
@@ -354,9 +358,10 @@ class _TrendingCard extends StatelessWidget {
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) =>
Icon(Icons.image_not_supported,
color: Colors.grey[600]),
errorWidget: (context, url, error) => Icon(
Icons.image_not_supported,
color: Colors.grey[600],
),
),
),
),
@@ -379,8 +384,11 @@ class _TrendingCard extends StatelessWidget {
if (item.rating != null)
Row(
children: [
const Icon(Icons.star,
color: Colors.amber, size: 12),
const Icon(
Icons.star,
color: Colors.amber,
size: 12,
),
const SizedBox(width: 4),
Text(
item.rating ?? '0',
@@ -444,11 +452,11 @@ class RecentlyAddedWidget extends ConsumerWidget {
final VoidCallback? onItemTap;
const RecentlyAddedWidget({
Key? key,
super.key,
required this.playlist,
required this.content,
this.onItemTap,
}) : super(key: key);
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -463,9 +471,9 @@ class RecentlyAddedWidget extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Recently Added',
style: TextStyle(
fontSize: 18,
+9 -4
View File
@@ -389,9 +389,11 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(
0.05), // Subtle transparent background
0.05,
), // Subtle transparent background
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12)),
top: Radius.circular(12),
),
),
padding: const EdgeInsets.all(16),
child: Center(
@@ -418,7 +420,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Footer
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
horizontal: 12,
vertical: 8,
),
color: Colors.black54,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -464,8 +468,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
EpgEntry.parseDateTime(entry.start);
final end =
EpgEntry.parseDateTime(entry.end);
if (start == null || end == null)
if (start == null || end == null) {
return false;
}
return now.isAfter(start) &&
now.isBefore(end);
} catch (e) {
@@ -8,10 +8,10 @@ class QualitySelectorWidget extends ConsumerStatefulWidget {
final VoidCallback onClose;
const QualitySelectorWidget({
Key? key,
super.key,
required this.qualitySelector,
required this.onClose,
}) : super(key: key);
});
@override
ConsumerState<QualitySelectorWidget> createState() =>
@@ -43,7 +43,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.white24),
),
@@ -109,13 +109,16 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: isSelected
? const Icon(Icons.check_circle, color: Colors.blue)
: const Icon(Icons.radio_button_unchecked,
color: Colors.white30),
: const Icon(
Icons.radio_button_unchecked,
color: Colors.white30,
),
title: Text(
quality.label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(
@@ -136,7 +139,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Container(
decoration: BoxDecoration(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Colors.white24),
),
@@ -177,10 +180,10 @@ class QualityIndicator extends ConsumerWidget {
final VoidCallback onTap;
const QualityIndicator({
Key? key,
super.key,
required this.qualitySelector,
required this.onTap,
}) : super(key: key);
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -219,9 +222,9 @@ class BandwidthMonitor extends ConsumerWidget {
final QualitySelector qualitySelector;
const BandwidthMonitor({
Key? key,
super.key,
required this.qualitySelector,
}) : super(key: key);
});
@override
Widget build(BuildContext context, WidgetRef ref) {
File diff suppressed because it is too large. Load diff
@@ -47,7 +47,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
_isRecording = true;
_status = '🔴 Recording for $minutes min';
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ Recording started!')),
@@ -80,7 +80,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
if (response.statusCode == 200) {
setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ Recording scheduled!')),
@@ -253,10 +253,12 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
value: _durationMinutes,
dropdownColor: Colors.grey[800],
items: [30, 60, 120, 240, 480]
.map((m) => DropdownMenuItem(
value: m,
child: Text('$m min'),
))
.map(
(m) => DropdownMenuItem(
value: m,
child: Text('$m min'),
),
)
.toList(),
onChanged: (v) {
if (v != null) {