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

@@ -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 {