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

+26 -25
View File
@@ -1,10 +1,8 @@
/// Adaptive Bitrate (ABR) Streaming Handler for HLS
///
///
/// Supports multi-bitrate HLS playlists for adaptive streaming
/// with fallback options for poor network conditions
import 'dart:async';
import 'dart:io';
library;
/// Represents a video quality level for adaptive bitrate streaming
class QualityLevel {
@@ -108,23 +106,23 @@ class QualityProfiles {
/// Get default profiles for balanced streaming
static List<QualityLevel> getBalancedProfiles() => [
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
];
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
];
/// Get all available profiles
static List<QualityLevel> getAllProfiles() => [
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
uhd2k,
uhd4k,
];
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
uhd2k,
uhd4k,
];
/// Get profiles suitable for streaming type
static List<QualityLevel> getProfiles({
@@ -159,8 +157,8 @@ class QualityProfiles {
/// Bandwidth detector for adaptive bitrate selection
class BandwidthDetector {
final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded
int _totalBytesLastPeriod = 0;
Duration _sampleWindow = const Duration(seconds: 10);
final int _totalBytesLastPeriod = 0;
final Duration _sampleWindow = const Duration(seconds: 10);
/// Record bytes downloaded at current time
void recordBytes(int bytes) {
@@ -177,7 +175,9 @@ class BandwidthDetector {
if (_samples.isEmpty) return 0;
int totalBytes = 0;
_samples.values.forEach((bytes) => totalBytes += bytes);
for (var bytes in _samples.values) {
totalBytes += bytes;
}
final timeSpan = DateTime.now()
.difference(
@@ -236,10 +236,10 @@ class HlsMasterPlaylistGenerator {
// Variant streams (quality levels)
for (final quality in qualityLevels) {
buffer.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
'RESOLUTION=${quality.width}x${quality.height}');
buffer
.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
'RESOLUTION=${quality.width}x${quality.height}');
buffer.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
}
return buffer.toString();
@@ -317,7 +317,8 @@ class QualitySelector {
// Only switch if significantly different from current
final overhead = _currentQuality.bitrateBps * 1.2;
if (bandwidthBps > overhead || bandwidthBps < _currentQuality.bitrateBps * 0.5) {
if (bandwidthBps > overhead ||
bandwidthBps < _currentQuality.bitrateBps * 0.5) {
final bestQuality =
BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles);
setQuality(bestQuality);
+17 -18
View File
@@ -12,8 +12,8 @@ class CacheEntry<T> {
required this.data,
required this.sizeEstimate,
}) : timestamp = DateTime.now(),
lastAccess = DateTime.now(),
accessCount = 0;
lastAccess = DateTime.now(),
accessCount = 0;
bool isExpired({Duration ttl = const Duration(hours: 24)}) {
return DateTime.now().difference(timestamp) > ttl;
@@ -52,7 +52,7 @@ class CacheService {
/// Get cached data
T? get<T>(String key) {
final entry = _cache[key] as CacheEntry?;
final entry = _cache[key];
if (entry != null && !entry.isExpired()) {
entry.markAccess();
return entry.data as T;
@@ -68,11 +68,12 @@ class CacheService {
/// Get cache stats
Map<String, dynamic> getStats() => {
'totalSize': _totalSizeBytes,
'totalSizeMb': _totalSizeBytes / (1024 * 1024),
'itemCount': _cache.length,
'averageSizePerItem': _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
};
'totalSize': _totalSizeBytes,
'totalSizeMb': _totalSizeBytes / (1024 * 1024),
'itemCount': _cache.length,
'averageSizePerItem':
_cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
};
/// Clear all cache
void clear() {
@@ -96,7 +97,7 @@ class CacheService {
/// Trim cache by removing least recently used entries
void _trimCacheIfNeeded() {
final maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
const maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
if (_totalSizeBytes > maxSize) {
// Remove least recently accessed entries
final sorted = _cache.entries.toList()
@@ -173,10 +174,10 @@ class ImageCacheManager {
/// Get cache stats
Map<String, dynamic> getStats() => {
'totalMemoryMb': _totalMemoryBytes / (1024 * 1024),
'cachedImages': _imageCache.length,
'maxImages': _maxImages,
};
'totalMemoryMb': _totalMemoryBytes / (1024 * 1024),
'cachedImages': _imageCache.length,
'maxImages': _maxImages,
};
/// Clear all image cache
void clear() {
@@ -196,9 +197,8 @@ final imageCacheManagerProvider = Provider<ImageCacheManager>((ref) {
});
/// Provider for cache statistics
final cacheStatsProvider = StateNotifierProvider<
CacheStatsNotifier,
Map<String, dynamic>>((ref) {
final cacheStatsProvider =
StateNotifierProvider<CacheStatsNotifier, Map<String, dynamic>>((ref) {
final cacheService = ref.watch(cacheServiceProvider);
return CacheStatsNotifier(cacheService);
});
@@ -206,8 +206,7 @@ final cacheStatsProvider = StateNotifierProvider<
class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> {
final CacheService _cacheService;
CacheStatsNotifier(this._cacheService)
: super(_cacheService.getStats());
CacheStatsNotifier(this._cacheService) : super(_cacheService.getStats());
void refreshStats() {
state = _cacheService.getStats();
+8 -4
View File
@@ -203,7 +203,9 @@ class _RetryInterceptor extends Interceptor {
@override
Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
DioException err,
ErrorInterceptorHandler handler,
) async {
// Only retry on timeout or connection errors
if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout) {
@@ -248,9 +250,11 @@ final networkConfigProvider =
class NetworkConfigNotifier extends StateNotifier<NetworkConfig> {
NetworkConfigNotifier()
: super(const NetworkConfig(
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
));
: super(
const NetworkConfig(
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
),
);
void setProxy(String? proxyUrl) {
state = state.copyWith(proxyUrl: proxyUrl);
+13 -15
View File
@@ -23,10 +23,7 @@ class StreamingMetrics {
});
double get totalPlaybackSeconds =>
(endTime ?? DateTime.now())
.difference(startTime)
.inSeconds
.toDouble();
(endTime ?? DateTime.now()).difference(startTime).inSeconds.toDouble();
double get qualityScore {
// Score based on metrics
@@ -67,7 +64,7 @@ class StreamingOptimizer {
StreamController.broadcast();
late Timer _metricsTimer;
int _segmentStartTime = 0;
final int _segmentStartTime = 0;
int _totalBytesDownloaded = 0;
int _rebufferCount = 0;
int _lastBufferNotificationTime = 0;
@@ -98,8 +95,7 @@ class StreamingOptimizer {
final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000;
_currentMetrics = StreamingMetrics(
averageNetworkBitrate: _totalBytesDownloaded > 0
? (_totalBytesDownloaded * 8 /
_currentMetrics.totalPlaybackSeconds)
? (_totalBytesDownloaded * 8 / _currentMetrics.totalPlaybackSeconds)
: 0,
currentNetworkBitrate: bandwidthBps,
bufferDurationMs: _currentMetrics.bufferDurationMs,
@@ -235,14 +231,16 @@ class StreamingOptimizerNotifier extends StateNotifier<StreamingMetrics> {
late StreamingOptimizer _optimizer;
StreamingOptimizerNotifier(this._qualitySelector)
: super(StreamingMetrics(
averageNetworkBitrate: 0,
currentNetworkBitrate: 0,
bufferDurationMs: 0,
segmentDownloadTimeMs: 0,
rebufferCount: 0,
startTime: DateTime.now(),
)) {
: super(
StreamingMetrics(
averageNetworkBitrate: 0,
currentNetworkBitrate: 0,
bufferDurationMs: 0,
segmentDownloadTimeMs: 0,
rebufferCount: 0,
startTime: DateTime.now(),
),
) {
_optimizer = StreamingOptimizer(qualitySelector: _qualitySelector);
// Listen to metrics updates