mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 13:38:05 +02:00
feat: add mobile-optimized IPTV player with HLS and MpegTS support
This commit is contained in:
1 parent
3c9a9c7be7
commit
fa6ae90d8d
38 files changed
+1236
-851
No files matched your search
@@ -29,8 +29,9 @@ class EpgApi {
|
|||||||
final playlist = await _getPlaylist(request);
|
final playlist = await _getPlaylist(request);
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
return Response.forbidden(
|
return Response.forbidden(
|
||||||
json.encode({'error': 'Playlist non trouvée'}),
|
json.encode({'error': 'Playlist non trouvée'}),
|
||||||
headers: {'Content-Type': 'application/json'});
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dns = playlist.dns;
|
final dns = playlist.dns;
|
||||||
@@ -43,7 +44,7 @@ class EpgApi {
|
|||||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||||
Map<String, dynamic> epgData = {
|
Map<String, dynamic> epgData = {
|
||||||
'channel_id': channelId,
|
'channel_id': channelId,
|
||||||
'programmes': []
|
'programmes': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -72,8 +73,10 @@ class EpgApi {
|
|||||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(jsonStr,
|
return Response.ok(
|
||||||
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'});
|
jsonStr,
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Response.internalServerError(
|
return Response.internalServerError(
|
||||||
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
|
body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../models/playlist.dart';
|
|
||||||
import '../models/playlist_config.dart';
|
import '../models/playlist_config.dart';
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
|
|
||||||
@@ -34,7 +33,8 @@ class ProxyHandler {
|
|||||||
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
|
Future<PlaylistConfig?> _getCachedPlaylist(Request request) async {
|
||||||
// Basic caching to avoid DB overhead on every video segment
|
// Basic caching to avoid DB overhead on every video segment
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final cacheKey = 'global_playlist'; // Currently app has one primary playlist per user/global
|
const cacheKey =
|
||||||
|
'global_playlist'; // Currently app has one primary playlist per user/global
|
||||||
|
|
||||||
if (_playlistCache.containsKey(cacheKey)) {
|
if (_playlistCache.containsKey(cacheKey)) {
|
||||||
final (cached, expiry) = _playlistCache[cacheKey]!;
|
final (cached, expiry) = _playlistCache[cacheKey]!;
|
||||||
@@ -134,7 +134,8 @@ class ProxyHandler {
|
|||||||
final playlist = await _getCachedPlaylist(request);
|
final playlist = await _getCachedPlaylist(request);
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
return Response.forbidden(
|
return Response.forbidden(
|
||||||
'No active playlist configuration found to validate request');
|
'No active playlist configuration found to validate request',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final targetHost = targetUrl.host.toLowerCase();
|
final targetHost = targetUrl.host.toLowerCase();
|
||||||
@@ -142,9 +143,11 @@ class ProxyHandler {
|
|||||||
|
|
||||||
if (targetHost != allowedHost) {
|
if (targetHost != allowedHost) {
|
||||||
print(
|
print(
|
||||||
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)');
|
'[Proxy] Blocked SSRF attempt to $targetHost (Allowed: $allowedHost)',
|
||||||
|
);
|
||||||
return Response.forbidden(
|
return Response.forbidden(
|
||||||
'Access to this domain is forbidden by policy');
|
'Access to this domain is forbidden by policy',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +218,8 @@ class ProxyHandler {
|
|||||||
targetUrl?.path.endsWith('.jpg') == true) {
|
targetUrl?.path.endsWith('.jpg') == true) {
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
base64Decode(
|
base64Decode(
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||||
|
),
|
||||||
headers: {'content-type': 'image/png'},
|
headers: {'content-type': 'image/png'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,19 +36,25 @@ class SeasonPassesApi {
|
|||||||
final streamUrl = data['stream_url'] as String?;
|
final streamUrl = data['stream_url'] as String?;
|
||||||
|
|
||||||
if (showTitle == null || showTitle.isEmpty) {
|
if (showTitle == null || showTitle.isEmpty) {
|
||||||
return Response(400,
|
return Response(
|
||||||
body: json.encode({'error': 'show_title est requis'}),
|
400,
|
||||||
headers: {'Content-Type': 'application/json'});
|
body: json.encode({'error': 'show_title est requis'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (channelId == null || channelId.isEmpty) {
|
if (channelId == null || channelId.isEmpty) {
|
||||||
return Response(400,
|
return Response(
|
||||||
body: json.encode({'error': 'channel_id est requis'}),
|
400,
|
||||||
headers: {'Content-Type': 'application/json'});
|
body: json.encode({'error': 'channel_id est requis'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (streamUrl == null || streamUrl.isEmpty) {
|
if (streamUrl == null || streamUrl.isEmpty) {
|
||||||
return Response(400,
|
return Response(
|
||||||
body: json.encode({'error': 'stream_url est requis'}),
|
400,
|
||||||
headers: {'Content-Type': 'application/json'});
|
body: json.encode({'error': 'stream_url est requis'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer l'utilisateur depuis le contexte
|
// Récupérer l'utilisateur depuis le contexte
|
||||||
@@ -57,13 +63,18 @@ class SeasonPassesApi {
|
|||||||
|
|
||||||
// Vérifier si un season pass identique existe déjà
|
// Vérifier si un season pass identique existe déjà
|
||||||
final existing = _db.getAllSeasonPasses();
|
final existing = _db.getAllSeasonPasses();
|
||||||
final duplicate = existing.any((p) =>
|
final duplicate = existing.any(
|
||||||
(p['show_title'] as String).toLowerCase() == showTitle.toLowerCase() &&
|
(p) =>
|
||||||
p['channel_id'] == channelId);
|
(p['show_title'] as String).toLowerCase() ==
|
||||||
|
showTitle.toLowerCase() &&
|
||||||
|
p['channel_id'] == channelId,
|
||||||
|
);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
return Response(409,
|
return Response(
|
||||||
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
409,
|
||||||
headers: {'Content-Type': 'application/json'});
|
body: json.encode({'error': 'Un Season Pass identique existe déjà'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final pass = _db.createSeasonPass(
|
final pass = _db.createSeasonPass(
|
||||||
@@ -74,9 +85,11 @@ class SeasonPassesApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
print('[SeasonPass] Créé: "$showTitle" sur chaîne $channelId');
|
||||||
return Response(201,
|
return Response(
|
||||||
body: json.encode(pass),
|
201,
|
||||||
headers: {'Content-Type': 'application/json'});
|
body: json.encode(pass),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Response.internalServerError(
|
return Response.internalServerError(
|
||||||
body: json.encode({'error': 'Erreur: $e'}),
|
body: json.encode({'error': 'Erreur: $e'}),
|
||||||
|
|||||||
@@ -153,11 +153,14 @@ Handler createLiveStreamHandler(
|
|||||||
'-i', targetUrl,
|
'-i', targetUrl,
|
||||||
// Video
|
// Video
|
||||||
if (useNvidiaGpu) ...[
|
if (useNvidiaGpu) ...[
|
||||||
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', // Better quality preset for NVENC
|
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune',
|
||||||
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k', // Quality native / high bitrate
|
'hq', // Better quality preset for NVENC
|
||||||
|
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize',
|
||||||
|
'16000k', // Quality native / high bitrate
|
||||||
'-g', '50',
|
'-g', '50',
|
||||||
] else ...[
|
] else ...[
|
||||||
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency', // Better detail than ultrafast
|
'-c:v', 'libx264', '-preset', 'medium', '-tune',
|
||||||
|
'zerolatency', // Better detail than ultrafast
|
||||||
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
|
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
|
||||||
'-g', '50',
|
'-g', '50',
|
||||||
],
|
],
|
||||||
@@ -201,15 +204,19 @@ Handler createLiveStreamHandler(
|
|||||||
retries++;
|
retries++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file.existsSync())
|
if (!file.existsSync()) {
|
||||||
return Response.internalServerError(body: 'Timeout starting live HLS');
|
return Response.internalServerError(body: 'Timeout starting live HLS');
|
||||||
|
}
|
||||||
|
|
||||||
return Response.ok(file.openRead(), headers: {
|
return Response.ok(
|
||||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
file.openRead(),
|
||||||
'Access-Control-Allow-Origin': '*',
|
headers: {
|
||||||
'Cache-Control': 'no-cache',
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||||
'X-Content-Type-Options': 'nosniff',
|
'Access-Control-Allow-Origin': '*',
|
||||||
});
|
'Cache-Control': 'no-cache',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback)
|
// Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback)
|
||||||
@@ -245,11 +252,14 @@ Handler createLiveStreamHandler(
|
|||||||
final file = File('${_hlsTempDir.path}/live_$streamId/$segment');
|
final file = File('${_hlsTempDir.path}/live_$streamId/$segment');
|
||||||
if (!file.existsSync()) return Response.notFound('Segment not found');
|
if (!file.existsSync()) return Response.notFound('Segment not found');
|
||||||
|
|
||||||
return Response.ok(file.openRead(), headers: {
|
return Response.ok(
|
||||||
'Content-Type': 'video/mp2t',
|
file.openRead(),
|
||||||
'Access-Control-Allow-Origin': '*',
|
headers: {
|
||||||
'Cache-Control': 'max-age=60',
|
'Content-Type': 'video/mp2t',
|
||||||
});
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'max-age=60',
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ class AppDatabase {
|
|||||||
startTime.toIso8601String(),
|
startTime.toIso8601String(),
|
||||||
endTime.toIso8601String(),
|
endTime.toIso8601String(),
|
||||||
now,
|
now,
|
||||||
now
|
now,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -489,7 +489,8 @@ class AppDatabase {
|
|||||||
|
|
||||||
/// Lister tous les enregistrements (pour le Scheduler et l'admin)
|
/// Lister tous les enregistrements (pour le Scheduler et l'admin)
|
||||||
List<Recording> getAllRecordings() {
|
List<Recording> getAllRecordings() {
|
||||||
final result = _db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
final result =
|
||||||
|
_db.select('SELECT * FROM tv_recordings ORDER BY start_time ASC');
|
||||||
return result.map((row) => Recording.fromMap(row)).toList();
|
return result.map((row) => Recording.fromMap(row)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +511,8 @@ class AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
|
/// Mettre à jour le statut et éventuellement le chemin d'un enregistrement
|
||||||
void updateRecordingStatus(String id, String status, {String? filePath, String? errorReason}) {
|
void updateRecordingStatus(String id, String status,
|
||||||
|
{String? filePath, String? errorReason}) {
|
||||||
final now = DateTime.now().toIso8601String();
|
final now = DateTime.now().toIso8601String();
|
||||||
_db.execute(
|
_db.execute(
|
||||||
'''
|
'''
|
||||||
@@ -542,12 +544,21 @@ class AppDatabase {
|
|||||||
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[id, userId, showTitle, channelId, streamUrl, now],
|
[id, userId, showTitle, channelId, streamUrl, now],
|
||||||
);
|
);
|
||||||
return {'id': id, 'user_id': userId, 'show_title': showTitle, 'channel_id': channelId, 'stream_url': streamUrl, 'enabled': 1, 'created_at': now};
|
return {
|
||||||
|
'id': id,
|
||||||
|
'user_id': userId,
|
||||||
|
'show_title': showTitle,
|
||||||
|
'channel_id': channelId,
|
||||||
|
'stream_url': streamUrl,
|
||||||
|
'enabled': 1,
|
||||||
|
'created_at': now
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lister tous les Season Passes
|
/// Lister tous les Season Passes
|
||||||
List<Map<String, dynamic>> getAllSeasonPasses() {
|
List<Map<String, dynamic>> getAllSeasonPasses() {
|
||||||
final result = _db.select('SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC');
|
final result = _db.select(
|
||||||
|
'SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC');
|
||||||
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
return result.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -46,7 +46,7 @@ void main(List<String> args) async {
|
|||||||
|
|
||||||
// Injecter la config playlist dans le scheduler pour les Season Passes
|
// Injecter la config playlist dans le scheduler pour les Season Passes
|
||||||
// (on prend la playlist du premier utilisateur disponible)
|
// (on prend la playlist du premier utilisateur disponible)
|
||||||
Future<void> _injectPlaylistToScheduler() async {
|
Future<void> injectPlaylistToScheduler() async {
|
||||||
final users = db.getAllUsers();
|
final users = db.getAllUsers();
|
||||||
if (users.isNotEmpty) {
|
if (users.isNotEmpty) {
|
||||||
final playlists = db.getPlaylists(users[0].id);
|
final playlists = db.getPlaylists(users[0].id);
|
||||||
@@ -61,7 +61,7 @@ void main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Injecter après 5s pour attendre l'initialisation complète
|
// Injecter après 5s pour attendre l'initialisation complète
|
||||||
Future.delayed(const Duration(seconds: 5), _injectPlaylistToScheduler);
|
Future.delayed(const Duration(seconds: 5), injectPlaylistToScheduler);
|
||||||
|
|
||||||
// Initialize Streaming Subsystem
|
// Initialize Streaming Subsystem
|
||||||
await initStreaming();
|
await initStreaming();
|
||||||
@@ -84,14 +84,16 @@ void main(List<String> args) async {
|
|||||||
if (users.isNotEmpty) {
|
if (users.isNotEmpty) {
|
||||||
final playlists = db.getPlaylists(users[0].id);
|
final playlists = db.getPlaylists(users[0].id);
|
||||||
print(
|
print(
|
||||||
'[getPlaylist] User ${users[0].username} has ${playlists.length} playlists');
|
'[getPlaylist] User ${users[0].username} has ${playlists.length} playlists',
|
||||||
|
);
|
||||||
if (playlists.isNotEmpty) playlist = playlists.first;
|
if (playlists.isNotEmpty) playlist = playlists.first;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
print(
|
print(
|
||||||
'[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})');
|
'[getPlaylist] Returning playlist: ${playlist.name} (DNS: ${playlist.serverUrl})',
|
||||||
|
);
|
||||||
return PlaylistConfig(
|
return PlaylistConfig(
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
name: playlist.name,
|
name: playlist.name,
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ class RecordingScheduler {
|
|||||||
RecordingScheduler(this._db);
|
RecordingScheduler(this._db);
|
||||||
|
|
||||||
void start() {
|
void start() {
|
||||||
print('[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
|
print(
|
||||||
|
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV');
|
||||||
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
|
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
|
||||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => _checkAndRunRecordings());
|
_timer = Timer.periodic(
|
||||||
|
const Duration(seconds: 10), (_) => _checkAndRunRecordings());
|
||||||
// Season Passes : vérifier toutes les 4 heures
|
// Season Passes : vérifier toutes les 4 heures
|
||||||
_seasonPassTimer = Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
|
_seasonPassTimer =
|
||||||
|
Timer.periodic(const Duration(hours: 4), (_) => _checkSeasonPasses());
|
||||||
// Lancer une première vérification immédiatement
|
// Lancer une première vérification immédiatement
|
||||||
_checkAndRunRecordings();
|
_checkAndRunRecordings();
|
||||||
// Vérification initiale des season passes après 30s (laisser le serveur démarrer)
|
// Vérification initiale des season passes après 30s (laisser le serveur démarrer)
|
||||||
@@ -52,45 +55,56 @@ class RecordingScheduler {
|
|||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
|
|
||||||
final recordings = _db.getAllRecordings();
|
final recordings = _db.getAllRecordings();
|
||||||
print('[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}');
|
print(
|
||||||
|
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}');
|
||||||
|
|
||||||
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter
|
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter
|
||||||
if (_currentRecording != null) {
|
if (_currentRecording != null) {
|
||||||
if (now.isAfter(_currentRecording!.endTime.toUtc())) {
|
if (now.isAfter(_currentRecording!.endTime.toUtc())) {
|
||||||
print('[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
|
print(
|
||||||
|
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}');
|
||||||
await _stopCurrentRecording();
|
await _stopCurrentRecording();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rechercher les enregistrements planifiés
|
// Rechercher les enregistrements planifiés
|
||||||
for (final recording in recordings) {
|
for (final recording in recordings) {
|
||||||
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
|
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
|
||||||
if (recording.status == 'recording' && _currentRecording?.id != recording.id) {
|
if (recording.status == 'recording' &&
|
||||||
print('[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}');
|
_currentRecording?.id != recording.id) {
|
||||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Interruption inattendue du serveur');
|
print(
|
||||||
continue;
|
'[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}');
|
||||||
|
_db.updateRecordingStatus(recording.id, 'failed',
|
||||||
|
errorReason: 'Interruption inattendue du serveur');
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recording.status == 'scheduled') {
|
if (recording.status == 'scheduled') {
|
||||||
final startUtc = recording.startTime.toUtc();
|
final startUtc = recording.startTime.toUtc();
|
||||||
final endUtc = recording.endTime.toUtc();
|
final endUtc = recording.endTime.toUtc();
|
||||||
print('[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}');
|
print(
|
||||||
|
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}');
|
||||||
|
|
||||||
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
|
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini
|
||||||
if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
|
if (now.isAfter(startUtc) && now.isBefore(endUtc)) {
|
||||||
if (_currentRecording != null) {
|
if (_currentRecording != null) {
|
||||||
print('[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.');
|
print(
|
||||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Un autre enregistrement était déjà en cours.');
|
'[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.');
|
||||||
|
_db.updateRecordingStatus(recording.id, 'failed',
|
||||||
|
errorReason: 'Un autre enregistrement était déjà en cours.');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
print('[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***');
|
print(
|
||||||
|
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***');
|
||||||
await _startRecording(recording);
|
await _startRecording(recording);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
|
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé)
|
||||||
if (now.isAfter(endUtc)) {
|
if (now.isAfter(endUtc)) {
|
||||||
print('[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).');
|
print(
|
||||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Heure de fin dépassée avant le démarrage');
|
'[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).');
|
||||||
|
_db.updateRecordingStatus(recording.id, 'failed',
|
||||||
|
errorReason: 'Heure de fin dépassée avant le démarrage');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +125,8 @@ class RecordingScheduler {
|
|||||||
final password = playlistPassword;
|
final password = playlistPassword;
|
||||||
|
|
||||||
if (dns == null || username == null || password == null) {
|
if (dns == null || username == null || password == null) {
|
||||||
print('[SeasonPass] Config playlist non disponible, vérification annulée');
|
print(
|
||||||
|
'[SeasonPass] Config playlist non disponible, vérification annulée');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,29 +143,36 @@ class RecordingScheduler {
|
|||||||
final url = '$dns/player_api.php?username=$username&password=$password'
|
final url = '$dns/player_api.php?username=$username&password=$password'
|
||||||
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
|
'&action=get_simple_data_table&stream_id=$channelId&type=epg&limit=48';
|
||||||
|
|
||||||
final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
final response =
|
||||||
|
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60));
|
||||||
if (response.statusCode != 200) continue;
|
if (response.statusCode != 200) continue;
|
||||||
|
|
||||||
final raw = json.decode(response.body);
|
final raw = json.decode(response.body);
|
||||||
final listings = (raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
|
final listings =
|
||||||
|
(raw is Map ? raw['epg_listings'] : raw) as List<dynamic>? ?? [];
|
||||||
|
|
||||||
for (final item in listings) {
|
for (final item in listings) {
|
||||||
String title = item['title'] as String? ?? '';
|
String title = item['title'] as String? ?? '';
|
||||||
try { title = utf8.decode(base64Decode(title)); } catch (_) {}
|
try {
|
||||||
|
title = utf8.decode(base64Decode(title));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
|
// Vérifier si le titre correspond au Season Pass (insensible casse, recherche partielle)
|
||||||
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
|
if (!title.toLowerCase().contains(showTitle.toLowerCase())) continue;
|
||||||
|
|
||||||
// Parser les heures de début/fin
|
// Parser les heures de début/fin
|
||||||
final startStr = item['start'] as String? ?? '';
|
final startStr = item['start'] as String? ?? '';
|
||||||
final endStr = item['stop'] as String? ?? item['end'] as String? ?? '';
|
final endStr =
|
||||||
|
item['stop'] as String? ?? item['end'] as String? ?? '';
|
||||||
if (startStr.isEmpty || endStr.isEmpty) continue;
|
if (startStr.isEmpty || endStr.isEmpty) continue;
|
||||||
|
|
||||||
DateTime startTime, endTime;
|
DateTime startTime, endTime;
|
||||||
try {
|
try {
|
||||||
startTime = DateTime.parse(startStr).toUtc();
|
startTime = DateTime.parse(startStr).toUtc();
|
||||||
endTime = DateTime.parse(endStr).toUtc();
|
endTime = DateTime.parse(endStr).toUtc();
|
||||||
} catch (_) { continue; }
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Ne pas créer pour les programmes déjà terminés
|
// Ne pas créer pour les programmes déjà terminés
|
||||||
if (endTime.isBefore(DateTime.now().toUtc())) continue;
|
if (endTime.isBefore(DateTime.now().toUtc())) continue;
|
||||||
@@ -170,7 +192,8 @@ class RecordingScheduler {
|
|||||||
startTime: startTime,
|
startTime: startTime,
|
||||||
endTime: endTime,
|
endTime: endTime,
|
||||||
);
|
);
|
||||||
print('[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
|
print(
|
||||||
|
'[SeasonPass] ✓ Planifié automatiquement: "$title" le ${startTime.toLocal()}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
|
print('[SeasonPass] Erreur pour le pass "${pass['show_title']}": $e');
|
||||||
@@ -195,8 +218,13 @@ class RecordingScheduler {
|
|||||||
await _checkDiskSpaceAndRotate(recordingsDir);
|
await _checkDiskSpaceAndRotate(recordingsDir);
|
||||||
|
|
||||||
// Génération d'un nom de fichier unique et sûr
|
// Génération d'un nom de fichier unique et sûr
|
||||||
final safeTitle = recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
final safeTitle =
|
||||||
final dateStr = recording.startTime.toUtc().toIso8601String().replaceAll(':', '').split('.')[0];
|
recording.title.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
|
||||||
|
final dateStr = recording.startTime
|
||||||
|
.toUtc()
|
||||||
|
.toIso8601String()
|
||||||
|
.replaceAll(':', '')
|
||||||
|
.split('.')[0];
|
||||||
final fileName = '${safeTitle}_$dateStr.mkv';
|
final fileName = '${safeTitle}_$dateStr.mkv';
|
||||||
final filePath = p.join(recordingsDir.path, fileName);
|
final filePath = p.join(recordingsDir.path, fileName);
|
||||||
final logFilePath = filePath.replaceAll('.mkv', '.log');
|
final logFilePath = filePath.replaceAll('.mkv', '.log');
|
||||||
@@ -210,10 +238,13 @@ class RecordingScheduler {
|
|||||||
|
|
||||||
final args = [
|
final args = [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', streamUrl,
|
'-i',
|
||||||
'-c', 'copy',
|
streamUrl,
|
||||||
'-t', '${recording.endTime.difference(DateTime.now()).inSeconds}',
|
'-c',
|
||||||
filePath
|
'copy',
|
||||||
|
'-t',
|
||||||
|
'${recording.endTime.difference(DateTime.now()).inSeconds}',
|
||||||
|
filePath,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception)
|
// Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception)
|
||||||
@@ -225,9 +256,11 @@ class RecordingScheduler {
|
|||||||
logSink.writeln('Destination: $filePath');
|
logSink.writeln('Destination: $filePath');
|
||||||
} catch (logError) {
|
} catch (logError) {
|
||||||
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
|
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
|
||||||
print('[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError');
|
print(
|
||||||
|
'[RecordingScheduler] AVERTISSEMENT: Impossible de créer le fichier log ($logFilePath): $logError');
|
||||||
// On note la filePath dans la DB quand même pour pouvoir retourner le statut
|
// On note la filePath dans la DB quand même pour pouvoir retourner le statut
|
||||||
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath);
|
_db.updateRecordingStatus(recording.id, 'recording',
|
||||||
|
filePath: filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trouver ffmpeg
|
// Trouver ffmpeg
|
||||||
@@ -251,15 +284,19 @@ class RecordingScheduler {
|
|||||||
// Écouter la fin du processus FFmpeg de manière asynchrone
|
// Écouter la fin du processus FFmpeg de manière asynchrone
|
||||||
_ffmpegProcess!.exitCode.then((exitCode) async {
|
_ffmpegProcess!.exitCode.then((exitCode) async {
|
||||||
if (_currentRecording?.id == recording.id) {
|
if (_currentRecording?.id == recording.id) {
|
||||||
logSink?.writeln('\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode');
|
logSink?.writeln(
|
||||||
|
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode');
|
||||||
await logSink?.close();
|
await logSink?.close();
|
||||||
|
|
||||||
if (exitCode == 0 || exitCode == 255) {
|
if (exitCode == 0 || exitCode == 255) {
|
||||||
print('[RecordingScheduler] Enregistrement terminé: ${recording.title}');
|
print(
|
||||||
|
'[RecordingScheduler] Enregistrement terminé: ${recording.title}');
|
||||||
_db.updateRecordingStatus(recording.id, 'completed');
|
_db.updateRecordingStatus(recording.id, 'completed');
|
||||||
} else {
|
} else {
|
||||||
print('[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}');
|
print(
|
||||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.');
|
'[RecordingScheduler] Erreur FFmpeg (code: $exitCode) pour ${recording.title}');
|
||||||
|
_db.updateRecordingStatus(recording.id, 'failed',
|
||||||
|
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.');
|
||||||
}
|
}
|
||||||
_currentRecording = null;
|
_currentRecording = null;
|
||||||
_ffmpegProcess = null;
|
_ffmpegProcess = null;
|
||||||
@@ -267,11 +304,11 @@ class RecordingScheduler {
|
|||||||
await logSink?.close();
|
await logSink?.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
|
// Attraper TOUTES les exceptions pour éviter de crasher le serveur
|
||||||
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
|
print('[RecordingScheduler] ERREUR dans _startRecording: $e\n$st');
|
||||||
_db.updateRecordingStatus(recording.id, 'failed', errorReason: 'Erreur au lancement: $e');
|
_db.updateRecordingStatus(recording.id, 'failed',
|
||||||
|
errorReason: 'Erreur au lancement: $e');
|
||||||
_currentRecording = null;
|
_currentRecording = null;
|
||||||
_ffmpegProcess = null;
|
_ffmpegProcess = null;
|
||||||
}
|
}
|
||||||
@@ -280,7 +317,8 @@ class RecordingScheduler {
|
|||||||
/// Arrêter un enregistrement en cours (appelé depuis l'API)
|
/// Arrêter un enregistrement en cours (appelé depuis l'API)
|
||||||
Future<bool> stopRecording(String id) async {
|
Future<bool> stopRecording(String id) async {
|
||||||
if (_ffmpegProcess != null && _currentRecording?.id == id) {
|
if (_ffmpegProcess != null && _currentRecording?.id == id) {
|
||||||
print('[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}');
|
print(
|
||||||
|
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}');
|
||||||
_ffmpegProcess!.kill(ProcessSignal.sigterm);
|
_ffmpegProcess!.kill(ProcessSignal.sigterm);
|
||||||
_db.updateRecordingStatus(id, 'completed');
|
_db.updateRecordingStatus(id, 'completed');
|
||||||
_ffmpegProcess = null;
|
_ffmpegProcess = null;
|
||||||
@@ -305,21 +343,26 @@ class RecordingScheduler {
|
|||||||
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
|
// et supprimer les plus anciens si un quota (ex: max 20 Go) est atteint.
|
||||||
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
|
// Pour l'implémentation initiale, nous pouvons lister et supprimer si plus de X fichiers
|
||||||
try {
|
try {
|
||||||
final maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
|
const maxFiles = 50; // Nombre max d'enregistrements (exemple simpliste)
|
||||||
final files = dir.listSync().whereType<File>().toList();
|
final files = dir.listSync().whereType<File>().toList();
|
||||||
|
|
||||||
if (files.length > maxFiles) {
|
if (files.length > maxFiles) {
|
||||||
print('[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
|
print(
|
||||||
files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
|
'[RecordingScheduler] Rotation de l\'espace disque : suppression des anciens enregistrements');
|
||||||
|
files.sort((a, b) => a
|
||||||
|
.statSync()
|
||||||
|
.modified
|
||||||
|
.compareTo(b.statSync().modified)); // Du plus vieux au plus récent
|
||||||
|
|
||||||
// Supprimer les plus anciens pour revenir sous la limite
|
// Supprimer les plus anciens pour revenir sous la limite
|
||||||
final filesToDelete = files.take(files.length - maxFiles);
|
final filesToDelete = files.take(files.length - maxFiles);
|
||||||
for (var file in filesToDelete) {
|
for (var file in filesToDelete) {
|
||||||
file.deleteSync();
|
file.deleteSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
|
print(
|
||||||
|
'[RecordingScheduler] Erreur lors de la rotation de l\'espace disque : $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
import '../models/recording.dart';
|
|
||||||
|
|
||||||
/// 🎯 SIMPLIFIED Recording System - One job: Record streams
|
/// 🎯 SIMPLIFIED Recording System - One job: Record streams
|
||||||
class SimpleRecorder {
|
class SimpleRecorder {
|
||||||
@@ -56,9 +55,12 @@ class SimpleRecorder {
|
|||||||
try {
|
try {
|
||||||
final ffmpeg = await Process.start('ffmpeg', [
|
final ffmpeg = await Process.start('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', streamUrl,
|
'-i',
|
||||||
'-c', 'copy',
|
streamUrl,
|
||||||
'-t', '${duration.inSeconds}',
|
'-c',
|
||||||
|
'copy',
|
||||||
|
'-t',
|
||||||
|
'${duration.inSeconds}',
|
||||||
filepath,
|
filepath,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -78,7 +80,8 @@ class SimpleRecorder {
|
|||||||
_active.remove(channelId);
|
_active.remove(channelId);
|
||||||
print('✅ Recording done: $title');
|
print('✅ Recording done: $title');
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
db.updateRecordingStatus(recordingId, 'failed', errorReason: 'FFmpeg error');
|
db.updateRecordingStatus(recordingId, 'failed',
|
||||||
|
errorReason: 'FFmpeg error');
|
||||||
_active.remove(channelId);
|
_active.remove(channelId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,22 +170,23 @@ class SimpleRecorder {
|
|||||||
|
|
||||||
/// Get active recordings
|
/// Get active recordings
|
||||||
List<Map<String, dynamic>> getActive() {
|
List<Map<String, dynamic>> getActive() {
|
||||||
return _active.entries.map((e) => {
|
return _active.entries
|
||||||
'id': e.value.id,
|
.map(
|
||||||
'channel': e.key,
|
(e) => {
|
||||||
'filepath': e.value.filepath,
|
'id': e.value.id,
|
||||||
'endsAt': e.value.endTime.toIso8601String(),
|
'channel': e.key,
|
||||||
}).toList();
|
'filepath': e.value.filepath,
|
||||||
|
'endsAt': e.value.endTime.toIso8601String(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cleanup old recordings (keep last 20)
|
/// Cleanup old recordings (keep last 20)
|
||||||
Future<void> cleanupOld({int keepCount = 20}) async {
|
Future<void> cleanupOld({int keepCount = 20}) async {
|
||||||
final dir = Directory(recordingsDir);
|
final dir = Directory(recordingsDir);
|
||||||
final files = dir
|
final files = dir.listSync().whereType<File>().toList()
|
||||||
.listSync()
|
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
|
||||||
.whereType<File>()
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
|
|
||||||
|
|
||||||
if (files.length > keepCount) {
|
if (files.length > keepCount) {
|
||||||
for (final file in files.skip(keepCount)) {
|
for (final file in files.skip(keepCount)) {
|
||||||
@@ -194,7 +198,8 @@ class SimpleRecorder {
|
|||||||
|
|
||||||
String _safeName(String title, String id) {
|
String _safeName(String title, String id) {
|
||||||
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
|
final safe = title.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
|
||||||
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
|
final timestamp =
|
||||||
|
DateTime.now().toIso8601String().replaceAll(':', '').split('.')[0];
|
||||||
return '${safe}_$timestamp.mkv';
|
return '${safe}_$timestamp.mkv';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/// Application optimization configuration
|
/// Application optimization configuration
|
||||||
///
|
///
|
||||||
/// This file contains performance tuning settings for XtremFlow
|
/// This file contains performance tuning settings for XtremFlow
|
||||||
|
library;
|
||||||
|
|
||||||
class OptimizationConfig {
|
class OptimizationConfig {
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -26,7 +27,8 @@ class OptimizationConfig {
|
|||||||
static const bool enableHlsCompression = true;
|
static const bool enableHlsCompression = true;
|
||||||
|
|
||||||
/// FFmpeg preset for live transcoding
|
/// FFmpeg preset for live transcoding
|
||||||
static const String ffmpegLivePreset = 'medium'; // ultrafast, fast, medium, slow
|
static const String ffmpegLivePreset =
|
||||||
|
'medium'; // ultrafast, fast, medium, slow
|
||||||
|
|
||||||
/// FFmpeg preset for VOD transcoding
|
/// FFmpeg preset for VOD transcoding
|
||||||
static const String ffmpegVodPreset = 'medium';
|
static const String ffmpegVodPreset = 'medium';
|
||||||
@@ -175,64 +177,64 @@ class OptimizationConfig {
|
|||||||
|
|
||||||
/// Get optimized image cache settings
|
/// Get optimized image cache settings
|
||||||
static Map<String, dynamic> getImageCacheSettings() => {
|
static Map<String, dynamic> getImageCacheSettings() => {
|
||||||
'maxSize': maxImageCacheMb * 1024 * 1024,
|
'maxSize': maxImageCacheMb * 1024 * 1024,
|
||||||
'maxItems': maxCachedImages,
|
'maxItems': maxCachedImages,
|
||||||
'enableAnimation': enableImageFadeAnimation,
|
'enableAnimation': enableImageFadeAnimation,
|
||||||
'fadeAnimationMs': imageFadeAnimationMs,
|
'fadeAnimationMs': imageFadeAnimationMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Get streaming configuration
|
/// Get streaming configuration
|
||||||
static Map<String, dynamic> getStreamingConfig() => {
|
static Map<String, dynamic> getStreamingConfig() => {
|
||||||
'enableAdaptiveBitrate': enableAdaptiveBitrate,
|
'enableAdaptiveBitrate': enableAdaptiveBitrate,
|
||||||
'defaultQuality': defaultQualityResolution,
|
'defaultQuality': defaultQualityResolution,
|
||||||
'maxBuffer': maxBufferSeconds,
|
'maxBuffer': maxBufferSeconds,
|
||||||
'minBuffer': minBufferSeconds,
|
'minBuffer': minBufferSeconds,
|
||||||
'targetBuffer': targetBufferSeconds,
|
'targetBuffer': targetBufferSeconds,
|
||||||
'enableHlsCompression': enableHlsCompression,
|
'enableHlsCompression': enableHlsCompression,
|
||||||
'ffmpegPreset': ffmpegLivePreset,
|
'ffmpegPreset': ffmpegLivePreset,
|
||||||
'enableGpu': enableNvidiaGpu,
|
'enableGpu': enableNvidiaGpu,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Get network configuration
|
/// Get network configuration
|
||||||
static Map<String, dynamic> getNetworkConfig() => {
|
static Map<String, dynamic> getNetworkConfig() => {
|
||||||
'connectTimeout': connectionTimeoutSeconds,
|
'connectTimeout': connectionTimeoutSeconds,
|
||||||
'receiveTimeout': receiveTimeoutSeconds,
|
'receiveTimeout': receiveTimeoutSeconds,
|
||||||
'enableAutoRetry': enableAutoRetry,
|
'enableAutoRetry': enableAutoRetry,
|
||||||
'maxRetries': maxRetryAttempts,
|
'maxRetries': maxRetryAttempts,
|
||||||
'enableGzip': enableGzipCompression,
|
'enableGzip': enableGzipCompression,
|
||||||
'enableCache': enableNetworkCache,
|
'enableCache': enableNetworkCache,
|
||||||
'cacheRetentionDays': networkCacheRetentionDays,
|
'cacheRetentionDays': networkCacheRetentionDays,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Get UI performance settings
|
/// Get UI performance settings
|
||||||
static Map<String, dynamic> getUiPerformanceSettings() => {
|
static Map<String, dynamic> getUiPerformanceSettings() => {
|
||||||
'itemsPerPage': itemsPerPage,
|
'itemsPerPage': itemsPerPage,
|
||||||
'liveItemsPerPage': liveItemsPerPage,
|
'liveItemsPerPage': liveItemsPerPage,
|
||||||
'enableLazyLoading': enableLazyLoading,
|
'enableLazyLoading': enableLazyLoading,
|
||||||
'enableSmoothScroll': enableSmoothScroll,
|
'enableSmoothScroll': enableSmoothScroll,
|
||||||
'maxTexturePoolSize': videoTexturePoolSize,
|
'maxTexturePoolSize': videoTexturePoolSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Get memory optimization settings
|
/// Get memory optimization settings
|
||||||
static Map<String, dynamic> getMemoryOptimizationSettings() => {
|
static Map<String, dynamic> getMemoryOptimizationSettings() => {
|
||||||
'enableProfiling': enableMemoryProfiling,
|
'enableProfiling': enableMemoryProfiling,
|
||||||
'profilingInterval': memoryProfilingIntervalSeconds,
|
'profilingInterval': memoryProfilingIntervalSeconds,
|
||||||
'aggressiveCleanup': enableMemoryCleanupOnBackground,
|
'aggressiveCleanup': enableMemoryCleanupOnBackground,
|
||||||
'maxImageCacheMb': maxImageCacheMb,
|
'maxImageCacheMb': maxImageCacheMb,
|
||||||
'maxMemoryCacheMb': maxMemoryCacheMb,
|
'maxMemoryCacheMb': maxMemoryCacheMb,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Get all enabled features
|
/// Get all enabled features
|
||||||
static List<String> getEnabledFeatures() => [
|
static List<String> getEnabledFeatures() => [
|
||||||
if (enableEpg) 'EPG',
|
if (enableEpg) 'EPG',
|
||||||
if (enableOfflineDownloads) 'OfflineDownloads',
|
if (enableOfflineDownloads) 'OfflineDownloads',
|
||||||
if (enableContinueWatching) 'ContinueWatching',
|
if (enableContinueWatching) 'ContinueWatching',
|
||||||
if (enableTrending) 'Trending',
|
if (enableTrending) 'Trending',
|
||||||
if (enableQualitySelector) 'QualitySelector',
|
if (enableQualitySelector) 'QualitySelector',
|
||||||
if (enableSubtitles) 'Subtitles',
|
if (enableSubtitles) 'Subtitles',
|
||||||
if (enableWatchHistory) 'WatchHistory',
|
if (enableWatchHistory) 'WatchHistory',
|
||||||
if (enableFavorites) 'Favorites',
|
if (enableFavorites) 'Favorites',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Print configuration summary
|
/// Print configuration summary
|
||||||
static void printSummary() {
|
static void printSummary() {
|
||||||
@@ -285,13 +287,15 @@ class RuntimeOptimizations {
|
|||||||
// Detect low storage
|
// Detect low storage
|
||||||
if (storageFreeMb < 1000) {
|
if (storageFreeMb < 1000) {
|
||||||
print(
|
print(
|
||||||
'[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.');
|
'[WARNING] Low storage available: ${storageFreeMb}MB. Disabling offline downloads.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// High performance device detection
|
// High performance device detection
|
||||||
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
|
hasHighPerformanceDevice = totalMemoryMb > 4000 && storageFreeMb > 5000;
|
||||||
|
|
||||||
print('[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB');
|
print(
|
||||||
|
'[Device Calibration] Memory: ${freeMemoryMb}MB, Storage: ${storageFreeMb}MB');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get dynamic cache size based on available memory
|
/// Get dynamic cache size based on available memory
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
///
|
///
|
||||||
/// Supports multi-bitrate HLS playlists for adaptive streaming
|
/// Supports multi-bitrate HLS playlists for adaptive streaming
|
||||||
/// with fallback options for poor network conditions
|
/// with fallback options for poor network conditions
|
||||||
|
library;
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
/// Represents a video quality level for adaptive bitrate streaming
|
/// Represents a video quality level for adaptive bitrate streaming
|
||||||
class QualityLevel {
|
class QualityLevel {
|
||||||
@@ -108,23 +106,23 @@ class QualityProfiles {
|
|||||||
|
|
||||||
/// Get default profiles for balanced streaming
|
/// Get default profiles for balanced streaming
|
||||||
static List<QualityLevel> getBalancedProfiles() => [
|
static List<QualityLevel> getBalancedProfiles() => [
|
||||||
mobile240p,
|
mobile240p,
|
||||||
mobile360p,
|
mobile360p,
|
||||||
hd480p,
|
hd480p,
|
||||||
hd720p,
|
hd720p,
|
||||||
fhd1080p,
|
fhd1080p,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Get all available profiles
|
/// Get all available profiles
|
||||||
static List<QualityLevel> getAllProfiles() => [
|
static List<QualityLevel> getAllProfiles() => [
|
||||||
mobile240p,
|
mobile240p,
|
||||||
mobile360p,
|
mobile360p,
|
||||||
hd480p,
|
hd480p,
|
||||||
hd720p,
|
hd720p,
|
||||||
fhd1080p,
|
fhd1080p,
|
||||||
uhd2k,
|
uhd2k,
|
||||||
uhd4k,
|
uhd4k,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Get profiles suitable for streaming type
|
/// Get profiles suitable for streaming type
|
||||||
static List<QualityLevel> getProfiles({
|
static List<QualityLevel> getProfiles({
|
||||||
@@ -159,8 +157,8 @@ class QualityProfiles {
|
|||||||
/// Bandwidth detector for adaptive bitrate selection
|
/// Bandwidth detector for adaptive bitrate selection
|
||||||
class BandwidthDetector {
|
class BandwidthDetector {
|
||||||
final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded
|
final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded
|
||||||
int _totalBytesLastPeriod = 0;
|
final int _totalBytesLastPeriod = 0;
|
||||||
Duration _sampleWindow = const Duration(seconds: 10);
|
final Duration _sampleWindow = const Duration(seconds: 10);
|
||||||
|
|
||||||
/// Record bytes downloaded at current time
|
/// Record bytes downloaded at current time
|
||||||
void recordBytes(int bytes) {
|
void recordBytes(int bytes) {
|
||||||
@@ -177,7 +175,9 @@ class BandwidthDetector {
|
|||||||
if (_samples.isEmpty) return 0;
|
if (_samples.isEmpty) return 0;
|
||||||
|
|
||||||
int totalBytes = 0;
|
int totalBytes = 0;
|
||||||
_samples.values.forEach((bytes) => totalBytes += bytes);
|
for (var bytes in _samples.values) {
|
||||||
|
totalBytes += bytes;
|
||||||
|
}
|
||||||
|
|
||||||
final timeSpan = DateTime.now()
|
final timeSpan = DateTime.now()
|
||||||
.difference(
|
.difference(
|
||||||
@@ -236,10 +236,10 @@ class HlsMasterPlaylistGenerator {
|
|||||||
|
|
||||||
// Variant streams (quality levels)
|
// Variant streams (quality levels)
|
||||||
for (final quality in qualityLevels) {
|
for (final quality in qualityLevels) {
|
||||||
|
buffer.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
|
||||||
|
'RESOLUTION=${quality.width}x${quality.height}');
|
||||||
buffer
|
buffer
|
||||||
.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
|
.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
|
||||||
'RESOLUTION=${quality.width}x${quality.height}');
|
|
||||||
buffer.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
@@ -317,7 +317,8 @@ class QualitySelector {
|
|||||||
|
|
||||||
// Only switch if significantly different from current
|
// Only switch if significantly different from current
|
||||||
final overhead = _currentQuality.bitrateBps * 1.2;
|
final overhead = _currentQuality.bitrateBps * 1.2;
|
||||||
if (bandwidthBps > overhead || bandwidthBps < _currentQuality.bitrateBps * 0.5) {
|
if (bandwidthBps > overhead ||
|
||||||
|
bandwidthBps < _currentQuality.bitrateBps * 0.5) {
|
||||||
final bestQuality =
|
final bestQuality =
|
||||||
BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles);
|
BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles);
|
||||||
setQuality(bestQuality);
|
setQuality(bestQuality);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ class CacheEntry<T> {
|
|||||||
required this.data,
|
required this.data,
|
||||||
required this.sizeEstimate,
|
required this.sizeEstimate,
|
||||||
}) : timestamp = DateTime.now(),
|
}) : timestamp = DateTime.now(),
|
||||||
lastAccess = DateTime.now(),
|
lastAccess = DateTime.now(),
|
||||||
accessCount = 0;
|
accessCount = 0;
|
||||||
|
|
||||||
bool isExpired({Duration ttl = const Duration(hours: 24)}) {
|
bool isExpired({Duration ttl = const Duration(hours: 24)}) {
|
||||||
return DateTime.now().difference(timestamp) > ttl;
|
return DateTime.now().difference(timestamp) > ttl;
|
||||||
@@ -52,7 +52,7 @@ class CacheService {
|
|||||||
|
|
||||||
/// Get cached data
|
/// Get cached data
|
||||||
T? get<T>(String key) {
|
T? get<T>(String key) {
|
||||||
final entry = _cache[key] as CacheEntry?;
|
final entry = _cache[key];
|
||||||
if (entry != null && !entry.isExpired()) {
|
if (entry != null && !entry.isExpired()) {
|
||||||
entry.markAccess();
|
entry.markAccess();
|
||||||
return entry.data as T;
|
return entry.data as T;
|
||||||
@@ -68,11 +68,12 @@ class CacheService {
|
|||||||
|
|
||||||
/// Get cache stats
|
/// Get cache stats
|
||||||
Map<String, dynamic> getStats() => {
|
Map<String, dynamic> getStats() => {
|
||||||
'totalSize': _totalSizeBytes,
|
'totalSize': _totalSizeBytes,
|
||||||
'totalSizeMb': _totalSizeBytes / (1024 * 1024),
|
'totalSizeMb': _totalSizeBytes / (1024 * 1024),
|
||||||
'itemCount': _cache.length,
|
'itemCount': _cache.length,
|
||||||
'averageSizePerItem': _cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
|
'averageSizePerItem':
|
||||||
};
|
_cache.isEmpty ? 0 : _totalSizeBytes ~/ _cache.length,
|
||||||
|
};
|
||||||
|
|
||||||
/// Clear all cache
|
/// Clear all cache
|
||||||
void clear() {
|
void clear() {
|
||||||
@@ -96,7 +97,7 @@ class CacheService {
|
|||||||
|
|
||||||
/// Trim cache by removing least recently used entries
|
/// Trim cache by removing least recently used entries
|
||||||
void _trimCacheIfNeeded() {
|
void _trimCacheIfNeeded() {
|
||||||
final maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
|
const maxSize = _maxMemoryCacheSizeMb * 1024 * 1024;
|
||||||
if (_totalSizeBytes > maxSize) {
|
if (_totalSizeBytes > maxSize) {
|
||||||
// Remove least recently accessed entries
|
// Remove least recently accessed entries
|
||||||
final sorted = _cache.entries.toList()
|
final sorted = _cache.entries.toList()
|
||||||
@@ -173,10 +174,10 @@ class ImageCacheManager {
|
|||||||
|
|
||||||
/// Get cache stats
|
/// Get cache stats
|
||||||
Map<String, dynamic> getStats() => {
|
Map<String, dynamic> getStats() => {
|
||||||
'totalMemoryMb': _totalMemoryBytes / (1024 * 1024),
|
'totalMemoryMb': _totalMemoryBytes / (1024 * 1024),
|
||||||
'cachedImages': _imageCache.length,
|
'cachedImages': _imageCache.length,
|
||||||
'maxImages': _maxImages,
|
'maxImages': _maxImages,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Clear all image cache
|
/// Clear all image cache
|
||||||
void clear() {
|
void clear() {
|
||||||
@@ -196,9 +197,8 @@ final imageCacheManagerProvider = Provider<ImageCacheManager>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Provider for cache statistics
|
/// Provider for cache statistics
|
||||||
final cacheStatsProvider = StateNotifierProvider<
|
final cacheStatsProvider =
|
||||||
CacheStatsNotifier,
|
StateNotifierProvider<CacheStatsNotifier, Map<String, dynamic>>((ref) {
|
||||||
Map<String, dynamic>>((ref) {
|
|
||||||
final cacheService = ref.watch(cacheServiceProvider);
|
final cacheService = ref.watch(cacheServiceProvider);
|
||||||
return CacheStatsNotifier(cacheService);
|
return CacheStatsNotifier(cacheService);
|
||||||
});
|
});
|
||||||
@@ -206,8 +206,7 @@ final cacheStatsProvider = StateNotifierProvider<
|
|||||||
class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> {
|
class CacheStatsNotifier extends StateNotifier<Map<String, dynamic>> {
|
||||||
final CacheService _cacheService;
|
final CacheService _cacheService;
|
||||||
|
|
||||||
CacheStatsNotifier(this._cacheService)
|
CacheStatsNotifier(this._cacheService) : super(_cacheService.getStats());
|
||||||
: super(_cacheService.getStats());
|
|
||||||
|
|
||||||
void refreshStats() {
|
void refreshStats() {
|
||||||
state = _cacheService.getStats();
|
state = _cacheService.getStats();
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ class _RetryInterceptor extends Interceptor {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onError(
|
Future<void> onError(
|
||||||
DioException err, ErrorInterceptorHandler handler) async {
|
DioException err,
|
||||||
|
ErrorInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
// Only retry on timeout or connection errors
|
// Only retry on timeout or connection errors
|
||||||
if (err.type == DioExceptionType.connectionTimeout ||
|
if (err.type == DioExceptionType.connectionTimeout ||
|
||||||
err.type == DioExceptionType.receiveTimeout) {
|
err.type == DioExceptionType.receiveTimeout) {
|
||||||
@@ -248,9 +250,11 @@ final networkConfigProvider =
|
|||||||
|
|
||||||
class NetworkConfigNotifier extends StateNotifier<NetworkConfig> {
|
class NetworkConfigNotifier extends StateNotifier<NetworkConfig> {
|
||||||
NetworkConfigNotifier()
|
NetworkConfigNotifier()
|
||||||
: super(const NetworkConfig(
|
: super(
|
||||||
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
|
const NetworkConfig(
|
||||||
));
|
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
void setProxy(String? proxyUrl) {
|
void setProxy(String? proxyUrl) {
|
||||||
state = state.copyWith(proxyUrl: proxyUrl);
|
state = state.copyWith(proxyUrl: proxyUrl);
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ class StreamingMetrics {
|
|||||||
});
|
});
|
||||||
|
|
||||||
double get totalPlaybackSeconds =>
|
double get totalPlaybackSeconds =>
|
||||||
(endTime ?? DateTime.now())
|
(endTime ?? DateTime.now()).difference(startTime).inSeconds.toDouble();
|
||||||
.difference(startTime)
|
|
||||||
.inSeconds
|
|
||||||
.toDouble();
|
|
||||||
|
|
||||||
double get qualityScore {
|
double get qualityScore {
|
||||||
// Score based on metrics
|
// Score based on metrics
|
||||||
@@ -67,7 +64,7 @@ class StreamingOptimizer {
|
|||||||
StreamController.broadcast();
|
StreamController.broadcast();
|
||||||
late Timer _metricsTimer;
|
late Timer _metricsTimer;
|
||||||
|
|
||||||
int _segmentStartTime = 0;
|
final int _segmentStartTime = 0;
|
||||||
int _totalBytesDownloaded = 0;
|
int _totalBytesDownloaded = 0;
|
||||||
int _rebufferCount = 0;
|
int _rebufferCount = 0;
|
||||||
int _lastBufferNotificationTime = 0;
|
int _lastBufferNotificationTime = 0;
|
||||||
@@ -98,8 +95,7 @@ class StreamingOptimizer {
|
|||||||
final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000;
|
final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000;
|
||||||
_currentMetrics = StreamingMetrics(
|
_currentMetrics = StreamingMetrics(
|
||||||
averageNetworkBitrate: _totalBytesDownloaded > 0
|
averageNetworkBitrate: _totalBytesDownloaded > 0
|
||||||
? (_totalBytesDownloaded * 8 /
|
? (_totalBytesDownloaded * 8 / _currentMetrics.totalPlaybackSeconds)
|
||||||
_currentMetrics.totalPlaybackSeconds)
|
|
||||||
: 0,
|
: 0,
|
||||||
currentNetworkBitrate: bandwidthBps,
|
currentNetworkBitrate: bandwidthBps,
|
||||||
bufferDurationMs: _currentMetrics.bufferDurationMs,
|
bufferDurationMs: _currentMetrics.bufferDurationMs,
|
||||||
@@ -235,14 +231,16 @@ class StreamingOptimizerNotifier extends StateNotifier<StreamingMetrics> {
|
|||||||
late StreamingOptimizer _optimizer;
|
late StreamingOptimizer _optimizer;
|
||||||
|
|
||||||
StreamingOptimizerNotifier(this._qualitySelector)
|
StreamingOptimizerNotifier(this._qualitySelector)
|
||||||
: super(StreamingMetrics(
|
: super(
|
||||||
averageNetworkBitrate: 0,
|
StreamingMetrics(
|
||||||
currentNetworkBitrate: 0,
|
averageNetworkBitrate: 0,
|
||||||
bufferDurationMs: 0,
|
currentNetworkBitrate: 0,
|
||||||
segmentDownloadTimeMs: 0,
|
bufferDurationMs: 0,
|
||||||
rebufferCount: 0,
|
segmentDownloadTimeMs: 0,
|
||||||
startTime: DateTime.now(),
|
rebufferCount: 0,
|
||||||
)) {
|
startTime: DateTime.now(),
|
||||||
|
),
|
||||||
|
) {
|
||||||
_optimizer = StreamingOptimizer(qualitySelector: _qualitySelector);
|
_optimizer = StreamingOptimizer(qualitySelector: _qualitySelector);
|
||||||
|
|
||||||
// Listen to metrics updates
|
// Listen to metrics updates
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ class AppTheme {
|
|||||||
static const double spacing24 = 24.0;
|
static const double spacing24 = 24.0;
|
||||||
static const double spacing32 = 32.0;
|
static const double spacing32 = 32.0;
|
||||||
static const double spacing40 = 40.0;
|
static const double spacing40 = 40.0;
|
||||||
static const double spacing48 = 48.0; // TV minimum safe area
|
static const double spacing48 = 48.0; // TV minimum safe area
|
||||||
static const double spacing56 = 56.0;
|
static const double spacing56 = 56.0;
|
||||||
static const double spacing64 = 64.0; // Hero section padding
|
static const double spacing64 = 64.0; // Hero section padding
|
||||||
|
|
||||||
// ============ RADIUS (modern, organic curves) ============
|
// ============ RADIUS (modern, organic curves) ============
|
||||||
static const double radiusXs = 4.0;
|
static const double radiusXs = 4.0;
|
||||||
@@ -114,7 +114,6 @@ class AppTheme {
|
|||||||
final baseTheme = ThemeData.dark(useMaterial3: true);
|
final baseTheme = ThemeData.dark(useMaterial3: true);
|
||||||
|
|
||||||
return baseTheme.copyWith(
|
return baseTheme.copyWith(
|
||||||
useMaterial3: true,
|
|
||||||
colorScheme: AppColors.darkColorScheme,
|
colorScheme: AppColors.darkColorScheme,
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
canvasColor: AppColors.background,
|
canvasColor: AppColors.background,
|
||||||
@@ -387,7 +386,7 @@ class AppTheme {
|
|||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusLg),
|
borderRadius: BorderRadius.circular(radiusLg),
|
||||||
side: BorderSide(
|
side: const BorderSide(
|
||||||
color: AppColors.border,
|
color: AppColors.border,
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
@@ -400,7 +399,7 @@ class AppTheme {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusXl),
|
borderRadius: BorderRadius.circular(radiusXl),
|
||||||
side: BorderSide(
|
side: const BorderSide(
|
||||||
color: AppColors.border,
|
color: AppColors.border,
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
@@ -498,14 +497,14 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ============ NAVIGATION RAIL (Side menu) ============
|
// ============ NAVIGATION RAIL (Side menu) ============
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
navigationRailTheme: const NavigationRailThemeData(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
selectedIconTheme: const IconThemeData(
|
selectedIconTheme: IconThemeData(
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
size: 28,
|
size: 28,
|
||||||
),
|
),
|
||||||
unselectedIconTheme: const IconThemeData(
|
unselectedIconTheme: IconThemeData(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
@@ -538,7 +537,7 @@ class AppTheme {
|
|||||||
|
|
||||||
static const double buttonHeightSm = 36.0;
|
static const double buttonHeightSm = 36.0;
|
||||||
static const double buttonHeightMd = 44.0;
|
static const double buttonHeightMd = 44.0;
|
||||||
static const double buttonHeightLg = 56.0; // TV-comfortable tap target
|
static const double buttonHeightLg = 56.0; // TV-comfortable tap target
|
||||||
|
|
||||||
// ============ ASPECT RATIOS ============
|
// ============ ASPECT RATIOS ============
|
||||||
static const double posterAspectRatio = 2 / 3;
|
static const double posterAspectRatio = 2 / 3;
|
||||||
@@ -564,29 +563,29 @@ class _AppThemeExtension extends ThemeExtension<_AppThemeExtension> {
|
|||||||
final List<BoxShadow>? elevatedShadow;
|
final List<BoxShadow>? elevatedShadow;
|
||||||
|
|
||||||
static _AppThemeExtension get dark => _AppThemeExtension(
|
static _AppThemeExtension get dark => _AppThemeExtension(
|
||||||
glassShadow: [
|
glassShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.15),
|
color: Colors.black.withOpacity(0.15),
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
spreadRadius: -4,
|
spreadRadius: -4,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
focusShadow: [
|
focusShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primary.withOpacity(0.5),
|
color: AppColors.primary.withOpacity(0.5),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
spreadRadius: 4,
|
spreadRadius: 4,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
elevatedShadow: [
|
elevatedShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.25),
|
color: Colors.black.withOpacity(0.25),
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ThemeExtension<_AppThemeExtension> copyWith({
|
ThemeExtension<_AppThemeExtension> copyWith({
|
||||||
|
|||||||
@@ -151,11 +151,11 @@ class _GlassCardState extends State<GlassCard>
|
|||||||
duration: AppTheme.durationMd,
|
duration: AppTheme.durationMd,
|
||||||
);
|
);
|
||||||
|
|
||||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04)
|
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.04).animate(
|
||||||
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
|
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
|
||||||
|
|
||||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8)
|
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
|
||||||
.animate(CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
|
CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -189,12 +189,16 @@ class _GlassCardState extends State<GlassCard>
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
onLongPress: widget.onLongPress,
|
onLongPress: widget.onLongPress,
|
||||||
onTapDown: (_) => _onPointerDown(PointerDownEvent(
|
onTapDown: (_) => _onPointerDown(
|
||||||
position: Offset.zero,
|
const PointerDownEvent(
|
||||||
)),
|
position: Offset.zero,
|
||||||
onTapUp: (_) => _onPointerUp(PointerUpEvent(
|
),
|
||||||
position: Offset.zero,
|
),
|
||||||
)),
|
onTapUp: (_) => _onPointerUp(
|
||||||
|
const PointerUpEvent(
|
||||||
|
position: Offset.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
onTapCancel: () => _controller.reverse(),
|
onTapCancel: () => _controller.reverse(),
|
||||||
child: ScaleTransition(
|
child: ScaleTransition(
|
||||||
scale: _scaleAnimation,
|
scale: _scaleAnimation,
|
||||||
@@ -211,7 +215,7 @@ class _GlassCardState extends State<GlassCard>
|
|||||||
content = Stack(
|
content = Stack(
|
||||||
children: [
|
children: [
|
||||||
Opacity(opacity: 0.5, child: content),
|
Opacity(opacity: 0.5, child: content),
|
||||||
Center(
|
const Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@@ -285,7 +285,8 @@ class _HeroCarouselState extends State<HeroCarousel>
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.play_arrow, color: AppColors.textPrimary),
|
const Icon(Icons.play_arrow,
|
||||||
|
color: AppColors.textPrimary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Play',
|
'Play',
|
||||||
@@ -320,8 +321,10 @@ class _HeroCarouselState extends State<HeroCarousel>
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.info_outline,
|
const Icon(
|
||||||
color: AppColors.textPrimary),
|
Icons.info_outline,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'More Info',
|
'More Info',
|
||||||
@@ -361,9 +364,8 @@ class _HeroCarouselState extends State<HeroCarousel>
|
|||||||
width: _currentIndex == index ? 32 : 8,
|
width: _currentIndex == index ? 32 : 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _currentIndex == index
|
color:
|
||||||
? AppColors.primary
|
_currentIndex == index ? AppColors.primary : AppColors.border,
|
||||||
: AppColors.border,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ class ThemedLoading extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Loading...',
|
'Loading...',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Apple TV Modern Channel Grid
|
/// Apple TV Modern Channel Grid
|
||||||
@@ -57,7 +56,8 @@ class TvChannelGrid extends StatelessWidget {
|
|||||||
// Compute item width, clamped to avoid negative/NaN values.
|
// Compute item width, clamped to avoid negative/NaN values.
|
||||||
final totalSpacing = horizontalSpacing * (columnCount - 1);
|
final totalSpacing = horizontalSpacing * (columnCount - 1);
|
||||||
final availableWidth = screenWidth - horizontalPadding - totalSpacing;
|
final availableWidth = screenWidth - horizontalPadding - totalSpacing;
|
||||||
final itemWidth = (availableWidth / columnCount).clamp(1.0, double.infinity);
|
final itemWidth =
|
||||||
|
(availableWidth / columnCount).clamp(1.0, double.infinity);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
|
|||||||
@@ -149,19 +149,19 @@ class _TvModernCardState extends State<TvModernCard>
|
|||||||
right: 0,
|
right: 0,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 3,
|
height: 3,
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
borderRadius: BorderRadius.vertical(
|
borderRadius: BorderRadius.vertical(
|
||||||
bottom: Radius.circular(AppTheme.radiusLg),
|
bottom: Radius.circular(AppTheme.radiusLg),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.vertical(
|
borderRadius: const BorderRadius.vertical(
|
||||||
bottom: Radius.circular(AppTheme.radiusLg),
|
bottom: Radius.circular(AppTheme.radiusLg),
|
||||||
),
|
),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: widget.progress,
|
value: widget.progress,
|
||||||
backgroundColor: AppColors.surfaceVariant,
|
backgroundColor: AppColors.surfaceVariant,
|
||||||
valueColor: AlwaysStoppedAnimation(
|
valueColor: const AlwaysStoppedAnimation(
|
||||||
AppColors.primary,
|
AppColors.primary,
|
||||||
),
|
),
|
||||||
minHeight: 3,
|
minHeight: 3,
|
||||||
@@ -171,7 +171,8 @@ class _TvModernCardState extends State<TvModernCard>
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Action buttons (on hover)
|
// Action buttons (on hover)
|
||||||
if (_isHovered && (widget.onPlayTap != null || widget.onMoreTap != null))
|
if (_isHovered &&
|
||||||
|
(widget.onPlayTap != null || widget.onMoreTap != null))
|
||||||
Center(
|
Center(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -182,7 +183,8 @@ class _TvModernCardState extends State<TvModernCard>
|
|||||||
label: 'Play',
|
label: 'Play',
|
||||||
onTap: widget.onPlayTap!,
|
onTap: widget.onPlayTap!,
|
||||||
),
|
),
|
||||||
if (widget.onPlayTap != null && widget.onMoreTap != null)
|
if (widget.onPlayTap != null &&
|
||||||
|
widget.onMoreTap != null)
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
if (widget.onMoreTap != null)
|
if (widget.onMoreTap != null)
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
@@ -219,7 +221,7 @@ class _TvModernCardState extends State<TvModernCard>
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (widget.rating != null) ...[
|
if (widget.rating != null) ...[
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.star,
|
Icons.star,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: AppColors.ratingGold,
|
color: AppColors.ratingGold,
|
||||||
@@ -258,7 +260,7 @@ class _TvModernCardState extends State<TvModernCard>
|
|||||||
if (widget.imageUrl == null || widget.imageUrl!.isEmpty) {
|
if (widget.imageUrl == null || widget.imageUrl!.isEmpty) {
|
||||||
return Container(
|
return Container(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
child: Center(
|
child: const Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.image_outlined,
|
Icons.image_outlined,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
@@ -273,7 +275,7 @@ class _TvModernCardState extends State<TvModernCard>
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: AppColors.shimmer,
|
color: AppColors.shimmer,
|
||||||
child: Center(
|
child: const Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -286,7 +288,7 @@ class _TvModernCardState extends State<TvModernCard>
|
|||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
child: Center(
|
child: const Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.broken_image_outlined,
|
Icons.broken_image_outlined,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
|
|||||||
@@ -41,12 +41,15 @@ class RecommendationService {
|
|||||||
|
|
||||||
// Only show if watched more than 5 seconds ago and less than 95% complete
|
// Only show if watched more than 5 seconds ago and less than 95% complete
|
||||||
if (lastPosition > 5 && lastPosition < 95) {
|
if (lastPosition > 5 && lastPosition < 95) {
|
||||||
recommendations.add(Recommendation(
|
recommendations.add(
|
||||||
item: item,
|
Recommendation(
|
||||||
type: RecommendationType.continueWatching,
|
item: item,
|
||||||
score: 100.0 - lastPosition, // Score higher if recently started
|
type: RecommendationType.continueWatching,
|
||||||
reason: 'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
|
score: 100.0 - lastPosition, // Score higher if recently started
|
||||||
));
|
reason:
|
||||||
|
'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,12 +69,14 @@ class RecommendationService {
|
|||||||
for (final item in allContent) {
|
for (final item in allContent) {
|
||||||
final views = viewCounts[item.streamId.toString()] ?? 0;
|
final views = viewCounts[item.streamId.toString()] ?? 0;
|
||||||
if (views > 0) {
|
if (views > 0) {
|
||||||
recommendations.add(Recommendation(
|
recommendations.add(
|
||||||
item: item,
|
Recommendation(
|
||||||
type: RecommendationType.trending,
|
item: item,
|
||||||
score: views.toDouble(),
|
type: RecommendationType.trending,
|
||||||
reason: '$views views - Trending now',
|
score: views.toDouble(),
|
||||||
));
|
reason: '$views views - Trending now',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +101,14 @@ class RecommendationService {
|
|||||||
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
|
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
|
||||||
.inDays;
|
.inDays;
|
||||||
|
|
||||||
recommendations.add(Recommendation(
|
recommendations.add(
|
||||||
item: item,
|
Recommendation(
|
||||||
type: RecommendationType.recentlyAdded,
|
item: item,
|
||||||
score: (20 - daysAgo).toDouble().clamp(0, 100),
|
type: RecommendationType.recentlyAdded,
|
||||||
reason: 'Added $daysAgo days ago',
|
score: (20 - daysAgo).toDouble().clamp(0, 100),
|
||||||
));
|
reason: 'Added $daysAgo days ago',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return recommendations.take(10).toList();
|
return recommendations.take(10).toList();
|
||||||
@@ -125,12 +132,14 @@ class RecommendationService {
|
|||||||
// Recommend similar content
|
// Recommend similar content
|
||||||
for (final item in allContent) {
|
for (final item in allContent) {
|
||||||
if (watchedCategories.contains(item.categoryId)) {
|
if (watchedCategories.contains(item.categoryId)) {
|
||||||
recommendations.add(Recommendation(
|
recommendations.add(
|
||||||
item: item,
|
Recommendation(
|
||||||
type: RecommendationType.forYou,
|
item: item,
|
||||||
score: 75.0,
|
type: RecommendationType.forYou,
|
||||||
reason: 'Based on your interests',
|
score: 75.0,
|
||||||
));
|
reason: 'Based on your interests',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +155,14 @@ class RecommendationService {
|
|||||||
final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
|
final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
|
||||||
return rating >= 7.5;
|
return rating >= 7.5;
|
||||||
}).take(5)) {
|
}).take(5)) {
|
||||||
recommendations.add(Recommendation(
|
recommendations.add(
|
||||||
item: item,
|
Recommendation(
|
||||||
type: RecommendationType.topRated,
|
item: item,
|
||||||
score: double.tryParse(item.rating ?? '0') ?? 0.0,
|
type: RecommendationType.topRated,
|
||||||
reason: 'Highly rated - ${item.rating}/10',
|
score: double.tryParse(item.rating ?? '0') ?? 0.0,
|
||||||
));
|
reason: 'Highly rated - ${item.rating}/10',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove duplicates and limit
|
// Remove duplicates and limit
|
||||||
@@ -169,9 +180,8 @@ class RecommendationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for watch history
|
/// Provider for watch history
|
||||||
final watchHistoryProvider = StateNotifierProvider<
|
final watchHistoryProvider =
|
||||||
WatchHistoryNotifier,
|
StateNotifierProvider<WatchHistoryNotifier, Map<String, dynamic>>((ref) {
|
||||||
Map<String, dynamic>>((ref) {
|
|
||||||
return WatchHistoryNotifier();
|
return WatchHistoryNotifier();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,9 +204,8 @@ class WatchHistoryNotifier extends StateNotifier<Map<String, dynamic>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for view counts/trending
|
/// Provider for view counts/trending
|
||||||
final trendingProvider = StateNotifierProvider<
|
final trendingProvider =
|
||||||
TrendingNotifier,
|
StateNotifierProvider<TrendingNotifier, Map<String, int>>((ref) {
|
||||||
Map<String, int>>((ref) {
|
|
||||||
return TrendingNotifier();
|
return TrendingNotifier();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,8 +222,8 @@ class TrendingNotifier extends StateNotifier<Map<String, int>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for recommendations
|
/// Provider for recommendations
|
||||||
final recommendationsProvider =
|
final recommendationsProvider = FutureProvider.family<List<Recommendation>,
|
||||||
FutureProvider.family<List<Recommendation>, (Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
|
(Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
|
||||||
(ref, params) async {
|
(ref, params) async {
|
||||||
final (playlist, watchHistory, trending, allContent) = params;
|
final (playlist, watchHistory, trending, allContent) = params;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:ui';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
@@ -25,7 +24,13 @@ class DashboardScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
int _selectedIndex = 0;
|
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 = [
|
final List<IconData> _icons = [
|
||||||
Icons.live_tv_rounded,
|
Icons.live_tv_rounded,
|
||||||
Icons.movie_rounded,
|
Icons.movie_rounded,
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ class EpgGridScreen extends ConsumerStatefulWidget {
|
|||||||
final Playlist playlist;
|
final Playlist playlist;
|
||||||
|
|
||||||
const EpgGridScreen({
|
const EpgGridScreen({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.playlist,
|
required this.playlist,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
|
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
|
||||||
@@ -112,7 +112,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<EpgProgram> _parseEpgPrograms(
|
List<EpgProgram> _parseEpgPrograms(
|
||||||
Map<String, dynamic> epgData, String channelId) {
|
Map<String, dynamic> epgData,
|
||||||
|
String channelId,
|
||||||
|
) {
|
||||||
final programs = <EpgProgram>[];
|
final programs = <EpgProgram>[];
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
@@ -130,17 +132,19 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
|||||||
final start = _parseEpgTime(startStr);
|
final start = _parseEpgTime(startStr);
|
||||||
final end = _parseEpgTime(endStr);
|
final end = _parseEpgTime(endStr);
|
||||||
|
|
||||||
programs.add(EpgProgram(
|
programs.add(
|
||||||
id: '${channelId}_${start.millisecondsSinceEpoch}',
|
EpgProgram(
|
||||||
channelId: channelId,
|
id: '${channelId}_${start.millisecondsSinceEpoch}',
|
||||||
title: title,
|
channelId: channelId,
|
||||||
description: desc,
|
title: title,
|
||||||
startTime: start,
|
description: desc,
|
||||||
endTime: end,
|
startTime: start,
|
||||||
isNow: now.isAfter(start) && now.isBefore(end),
|
endTime: end,
|
||||||
isNext: start.isAfter(now) &&
|
isNow: now.isAfter(start) && now.isBefore(end),
|
||||||
start.difference(now).inMinutes < 60,
|
isNext:
|
||||||
));
|
start.isAfter(now) && start.difference(now).inMinutes < 60,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
continue;
|
continue;
|
||||||
@@ -192,7 +196,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(day, style: const TextStyle(fontSize: 11)),
|
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: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'$hour:00',
|
'$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
|
||||||
...programs.map((program) {
|
...programs.map((program) {
|
||||||
final duration = program.duration.inMinutes;
|
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(
|
return GestureDetector(
|
||||||
onTap: () => onProgramTap(program),
|
onTap: () => onProgramTap(program),
|
||||||
@@ -397,8 +405,7 @@ class _EpgChannelRow extends StatelessWidget {
|
|||||||
value: program.progress / 100,
|
value: program.progress / 100,
|
||||||
minHeight: 3,
|
minHeight: 3,
|
||||||
backgroundColor: Colors.white24,
|
backgroundColor: Colors.white24,
|
||||||
valueColor:
|
valueColor: const AlwaysStoppedAnimation(Colors.red),
|
||||||
const AlwaysStoppedAnimation(Colors.red),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -455,7 +462,9 @@ class _ProgramDetailSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
program.description.isEmpty ? 'No description available' : program.description,
|
program.description.isEmpty
|
||||||
|
? 'No description available'
|
||||||
|
: program.description,
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -11,11 +11,8 @@ import '../providers/playback_positions_provider.dart';
|
|||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
import '../../../core/models/playlist_config.dart';
|
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/widgets/glass_container.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../widgets/epg_overlay.dart';
|
|
||||||
|
|
||||||
enum StreamType { live, vod, series }
|
enum StreamType { live, vod, series }
|
||||||
|
|
||||||
@@ -57,7 +54,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
StreamSubscription? _messageSubscription;
|
StreamSubscription? _messageSubscription;
|
||||||
final String _aspectRatio = 'contain';
|
final String _aspectRatio = 'contain';
|
||||||
bool _isSeeking = false;
|
bool _isSeeking = false;
|
||||||
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString();
|
late final String _viewIdPrefix =
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
String _viewId = 'iptv-player';
|
String _viewId = 'iptv-player';
|
||||||
String? _currentStreamUrl;
|
String? _currentStreamUrl;
|
||||||
String _statusMessage = 'Loading...';
|
String _statusMessage = 'Loading...';
|
||||||
@@ -156,11 +154,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
// Cache buster to force reload of updated player.html
|
// Cache buster to force reload of updated player.html
|
||||||
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
|
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod';
|
final streamTypeParam =
|
||||||
final playerFile = isMobile ? 'player_mobile.html' : (isLiveTV ? 'player_lite.html' : 'player.html');
|
widget.streamType == StreamType.live ? 'live' : 'vod';
|
||||||
|
final playerFile = isMobile
|
||||||
|
? 'player_mobile.html'
|
||||||
|
: (isLiveTV ? 'player_lite.html' : 'player.html');
|
||||||
|
|
||||||
// Inject Turbo-Start flag
|
// 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) {
|
if (startTimeOverride != null) {
|
||||||
playerSrc += '&t=$startTimeOverride';
|
playerSrc += '&t=$startTimeOverride';
|
||||||
@@ -301,30 +303,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
try {
|
try {
|
||||||
final doc = html.document;
|
final doc = html.document;
|
||||||
final isFullscreen = doc.fullscreenElement != null ||
|
final isFullscreen = doc.fullscreenElement != null ||
|
||||||
(doc as dynamic).webkitFullscreenElement != null ||
|
(doc as dynamic).webkitFullscreenElement != null ||
|
||||||
(doc as dynamic).mozFullScreenElement != null;
|
(doc as dynamic).mozFullScreenElement != null;
|
||||||
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
if (doc.exitFullscreen != null) {
|
doc.exitFullscreen();
|
||||||
doc.exitFullscreen();
|
|
||||||
} else if ((doc as dynamic).webkitExitFullscreen != null) {
|
|
||||||
(doc as dynamic).webkitExitFullscreen();
|
|
||||||
} else if ((doc as dynamic).mozCancelFullScreen != null) {
|
|
||||||
(doc as dynamic).mozCancelFullScreen();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
final element = doc.documentElement;
|
final element = doc.documentElement;
|
||||||
if (element != null) {
|
if (element != null) {
|
||||||
if (element.requestFullscreen != null) {
|
element.requestFullscreen();
|
||||||
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'});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -442,9 +429,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.channels != null
|
widget.channels != null
|
||||||
? (widget.channels![_currentIndex]
|
? (widget
|
||||||
|
.channels![
|
||||||
|
_currentIndex]
|
||||||
.name ??
|
.name ??
|
||||||
widget.channels![
|
widget
|
||||||
|
.channels![
|
||||||
_currentIndex]
|
_currentIndex]
|
||||||
.title ??
|
.title ??
|
||||||
'Unknown')
|
'Unknown')
|
||||||
@@ -478,12 +468,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
// Unified Bottom Bar - EPG + Controls in one bar
|
// Unified Bottom Bar - EPG + Controls in one bar
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 200),
|
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,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isSmallScreen = constraints.maxWidth < 600;
|
final isSmallScreen =
|
||||||
|
constraints.maxWidth < 600;
|
||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: isSmallScreen ? 16 : 20,
|
horizontal: isSmallScreen ? 16 : 20,
|
||||||
@@ -494,13 +487,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.background.withOpacity(0.3),
|
color: AppColors.background
|
||||||
|
.withOpacity(0.3),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
spreadRadius: 2,
|
spreadRadius: 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.textPrimary.withOpacity(0.15),
|
color: AppColors.textPrimary
|
||||||
|
.withOpacity(0.15),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: isSmallScreen
|
child: isSmallScreen
|
||||||
@@ -510,14 +505,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
// EPG Info (top in mobile)
|
// EPG Info (top in mobile)
|
||||||
_buildInlineEpg(),
|
_buildInlineEpg(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Divider(color: Colors.white10),
|
const Divider(
|
||||||
|
color: Colors.white10),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Control Buttons
|
// Control Buttons
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment:
|
||||||
MainAxisAlignment.spaceEvenly,
|
MainAxisAlignment.spaceEvenly,
|
||||||
children: _buildControlButtons(
|
children: _buildControlButtons(
|
||||||
isSmallScreen),
|
isSmallScreen,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -532,7 +529,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: _buildControlButtons(
|
children: _buildControlButtons(
|
||||||
isSmallScreen),
|
isSmallScreen,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -851,7 +849,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
onTap: _toggleMute,
|
onTap: _toggleMute,
|
||||||
size: buttonSize,
|
size: buttonSize,
|
||||||
),
|
),
|
||||||
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,5 +1056,4 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -37,8 +37,10 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
if (currentUser?.isAdmin ?? false)
|
if (currentUser?.isAdmin ?? false)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.admin_panel_settings,
|
icon: const Icon(
|
||||||
color: AppColors.textSecondary),
|
Icons.admin_panel_settings,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
onPressed: () => context.go('/admin'),
|
onPressed: () => context.go('/admin'),
|
||||||
tooltip: 'Admin Panel',
|
tooltip: 'Admin Panel',
|
||||||
),
|
),
|
||||||
@@ -75,8 +77,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
|||||||
!constraints.maxHeight.isFinite ||
|
!constraints.maxHeight.isFinite ||
|
||||||
constraints.maxHeight <= 0) {
|
constraints.maxHeight <= 0) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child:
|
child: CircularProgressIndicator(color: AppColors.primary),
|
||||||
CircularProgressIndicator(color: AppColors.primary));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return playlistsAsync.when(
|
return playlistsAsync.when(
|
||||||
@@ -87,8 +89,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline,
|
const Icon(
|
||||||
size: 64, color: AppColors.error),
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: AppColors.error,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Error loading playlists',
|
'Error loading playlists',
|
||||||
|
|||||||
@@ -120,12 +120,15 @@ class DownloadService {
|
|||||||
|
|
||||||
/// Get download progress
|
/// Get download progress
|
||||||
double getDownloadProgress(String id) {
|
double getDownloadProgress(String id) {
|
||||||
final task = _downloads.firstWhere((t) => t.id == id, orElse: () => DownloadTask(
|
final task = _downloads.firstWhere(
|
||||||
id: id,
|
(t) => t.id == id,
|
||||||
title: '',
|
orElse: () => DownloadTask(
|
||||||
url: '',
|
id: id,
|
||||||
filePath: '',
|
title: '',
|
||||||
));
|
url: '',
|
||||||
|
filePath: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
return task.progress;
|
return task.progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,13 +154,12 @@ class DownloadService {
|
|||||||
if (available < 1024 * 1024 * 1024) {
|
if (available < 1024 * 1024 * 1024) {
|
||||||
// Less than 1GB free
|
// Less than 1GB free
|
||||||
final dir = Directory(_downloadDir);
|
final dir = Directory(_downloadDir);
|
||||||
final files = dir.listSync()
|
final files = dir.listSync().whereType<File>().toList();
|
||||||
.whereType<File>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Sort by modification time, delete oldest first
|
// Sort by modification time, delete oldest first
|
||||||
files.sort((a, b) =>
|
files.sort(
|
||||||
a.statSync().modified.compareTo(b.statSync().modified));
|
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
|
||||||
|
);
|
||||||
|
|
||||||
for (final file in files.take(files.length ~/ 3)) {
|
for (final file in files.take(files.length ~/ 3)) {
|
||||||
try {
|
try {
|
||||||
@@ -216,7 +218,8 @@ class DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if paused or cancelled
|
// 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 ||
|
if (currentTask.status == DownloadStatus.paused ||
|
||||||
!_activeDownloads.contains(task.id)) {
|
!_activeDownloads.contains(task.id)) {
|
||||||
break;
|
break;
|
||||||
@@ -301,8 +304,6 @@ class DownloadsNotifier extends StateNotifier<List<DownloadTask>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clearCompleted() {
|
void clearCompleted() {
|
||||||
state = state
|
state = state.where((d) => d.status != DownloadStatus.completed).toList();
|
||||||
.where((d) => d.status != DownloadStatus.completed)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,12 +17,12 @@ class SubtitleTrack {
|
|||||||
});
|
});
|
||||||
|
|
||||||
SubtitleTrack copyWith({bool? isEnabled}) => SubtitleTrack(
|
SubtitleTrack copyWith({bool? isEnabled}) => SubtitleTrack(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
language: language,
|
language: language,
|
||||||
url: url,
|
url: url,
|
||||||
isEnabled: isEnabled ?? this.isEnabled,
|
isEnabled: isEnabled ?? this.isEnabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subtitle content with timing
|
/// Subtitle content with timing
|
||||||
@@ -42,7 +42,8 @@ class SubtitleEntry {
|
|||||||
|
|
||||||
/// Service for handling subtitles
|
/// Service for handling subtitles
|
||||||
class SubtitleService {
|
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
|
/// Parse SRT subtitle content
|
||||||
static List<SubtitleEntry> parseSrt(String content) {
|
static List<SubtitleEntry> parseSrt(String content) {
|
||||||
@@ -67,12 +68,14 @@ class SubtitleService {
|
|||||||
final startTime = _parseTime(times[0].trim());
|
final startTime = _parseTime(times[0].trim());
|
||||||
final endTime = _parseTime(times[1].trim());
|
final endTime = _parseTime(times[1].trim());
|
||||||
|
|
||||||
entries.add(SubtitleEntry(
|
entries.add(
|
||||||
index: index,
|
SubtitleEntry(
|
||||||
startTime: startTime,
|
index: index,
|
||||||
endTime: endTime,
|
startTime: startTime,
|
||||||
text: text,
|
endTime: endTime,
|
||||||
));
|
text: text,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -103,12 +106,14 @@ class SubtitleService {
|
|||||||
final startTime = _parseWebVttTime(times[0].trim());
|
final startTime = _parseWebVttTime(times[0].trim());
|
||||||
final endTime = _parseWebVttTime(times[1].trim());
|
final endTime = _parseWebVttTime(times[1].trim());
|
||||||
|
|
||||||
entries.add(SubtitleEntry(
|
entries.add(
|
||||||
index: entries.length,
|
SubtitleEntry(
|
||||||
startTime: startTime,
|
index: entries.length,
|
||||||
endTime: endTime,
|
startTime: startTime,
|
||||||
text: text,
|
endTime: endTime,
|
||||||
));
|
text: text,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -120,7 +125,8 @@ class SubtitleService {
|
|||||||
/// Get subtitle at specific time
|
/// Get subtitle at specific time
|
||||||
static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) {
|
static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) {
|
||||||
for (final entry in entries) {
|
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;
|
return entry.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,11 +183,14 @@ class XtreamService {
|
|||||||
/// Get all live TV channels grouped by category
|
/// Get all live TV channels grouped by category
|
||||||
///
|
///
|
||||||
/// Returns a Map where key is category name and value is list of channels
|
/// 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');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
|
|
||||||
final options = refresh
|
final options = refresh
|
||||||
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
|
? _cacheOptions
|
||||||
|
.copyWith(policy: CachePolicy.refreshForceCache)
|
||||||
|
.toExtra()
|
||||||
: _cacheOptions.toExtra();
|
: _cacheOptions.toExtra();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -226,7 +229,8 @@ class XtreamService {
|
|||||||
return groupedChannels;
|
return groupedChannels;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Try to return from local cache if network fails
|
// 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;
|
if (cached.isNotEmpty) return cached;
|
||||||
throw Exception('Failed to fetch live channels: $e');
|
throw Exception('Failed to fetch live channels: $e');
|
||||||
}
|
}
|
||||||
@@ -236,17 +240,21 @@ class XtreamService {
|
|||||||
if (!kIsWeb) return;
|
if (!kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final jsonStr = jsonEncode(data);
|
final jsonStr = jsonEncode(data);
|
||||||
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] = jsonStr;
|
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
|
||||||
|
jsonStr;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving to local cache ($key): $e');
|
debugPrint('Error saving to local cache ($key): $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, List<T>> _loadFromLocalCache<T>(
|
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 {};
|
if (!kIsWeb) return {};
|
||||||
try {
|
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 {};
|
if (jsonStr == null) return {};
|
||||||
|
|
||||||
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
|
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
@@ -254,9 +262,8 @@ class XtreamService {
|
|||||||
|
|
||||||
data.forEach((cat, items) {
|
data.forEach((cat, items) {
|
||||||
if (items is List) {
|
if (items is List) {
|
||||||
result[cat] = items
|
result[cat] =
|
||||||
.map((i) => fromJson(i as Map<String, dynamic>))
|
items.map((i) => fromJson(i as Map<String, dynamic>)).toList();
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -302,7 +309,9 @@ class XtreamService {
|
|||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
|
|
||||||
final options = refresh
|
final options = refresh
|
||||||
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
|
? _cacheOptions
|
||||||
|
.copyWith(policy: CachePolicy.refreshForceCache)
|
||||||
|
.toExtra()
|
||||||
: _cacheOptions.toExtra();
|
: _cacheOptions.toExtra();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -342,7 +351,8 @@ class XtreamService {
|
|||||||
return groupedVods;
|
return groupedVods;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Try to return from local cache if network fails
|
// 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;
|
if (cached.isNotEmpty) return cached;
|
||||||
throw Exception('Failed to fetch VOD items: $e');
|
throw Exception('Failed to fetch VOD items: $e');
|
||||||
}
|
}
|
||||||
@@ -384,7 +394,9 @@ class XtreamService {
|
|||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
|
|
||||||
final options = refresh
|
final options = refresh
|
||||||
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
|
? _cacheOptions
|
||||||
|
.copyWith(policy: CachePolicy.refreshForceCache)
|
||||||
|
.toExtra()
|
||||||
: _cacheOptions.toExtra();
|
: _cacheOptions.toExtra();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -424,15 +436,18 @@ class XtreamService {
|
|||||||
return groupedSeries;
|
return groupedSeries;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Try to return from local cache if network fails
|
// 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;
|
if (cached.isNotEmpty) return cached;
|
||||||
throw Exception('Failed to fetch series: $e');
|
throw Exception('Failed to fetch series: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get movies with pagination support
|
/// Get movies with pagination support
|
||||||
Future<List<xm.Movie>> getMoviesPaginated(
|
Future<List<xm.Movie>> getMoviesPaginated({
|
||||||
{int offset = 0, int limit = 100}) async {
|
int offset = 0,
|
||||||
|
int limit = 100,
|
||||||
|
}) async {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -511,8 +526,10 @@ class XtreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get series with pagination support (returns flat list)
|
/// Get series with pagination support (returns flat list)
|
||||||
Future<List<xm.Series>> getSeriesPaginated(
|
Future<List<xm.Series>> getSeriesPaginated({
|
||||||
{int offset = 0, int limit = 100}) async {
|
int offset = 0,
|
||||||
|
int limit = 100,
|
||||||
|
}) async {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
child: ScaleTransition(
|
child: ScaleTransition(
|
||||||
scale: widget.isActive ? _pulseAnimation : AlwaysStoppedAnimation(1.0),
|
scale: widget.isActive
|
||||||
|
? _pulseAnimation
|
||||||
|
: const AlwaysStoppedAnimation(1.0),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: AppTheme.durationMd,
|
duration: AppTheme.durationMd,
|
||||||
curve: AppTheme.curveDefault,
|
curve: AppTheme.curveDefault,
|
||||||
@@ -90,7 +92,9 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: widget.isActive
|
color: widget.isActive
|
||||||
? AppColors.primary
|
? 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,
|
width: widget.isActive ? 2.5 : 1.5,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ class ContinueWatchingWidget extends ConsumerStatefulWidget {
|
|||||||
final VoidCallback? onItemTap;
|
final VoidCallback? onItemTap;
|
||||||
|
|
||||||
const ContinueWatchingWidget({
|
const ContinueWatchingWidget({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.playlist,
|
required this.playlist,
|
||||||
required this.content,
|
required this.content,
|
||||||
this.onItemTap,
|
this.onItemTap,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<ContinueWatchingWidget> createState() =>
|
ConsumerState<ContinueWatchingWidget> createState() =>
|
||||||
@@ -68,12 +68,12 @@ class _ContinueWatchingWidgetState
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
...continueWatching.take(5).map((rec) {
|
...continueWatching.take(5).map((rec) {
|
||||||
final item = rec.item;
|
final item = rec.item;
|
||||||
final percentage = watchHistory[item.streamId.toString()]
|
final percentage =
|
||||||
is Map
|
watchHistory[item.streamId.toString()] is Map
|
||||||
? (watchHistory[item.streamId.toString()]
|
? (watchHistory[item.streamId.toString()]
|
||||||
as Map)['percentage'] as double? ??
|
as Map)['percentage'] as double? ??
|
||||||
0.0
|
0.0
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(right: 12),
|
margin: const EdgeInsets.only(right: 12),
|
||||||
@@ -132,9 +132,10 @@ class _ContinueWatchingCard extends StatelessWidget {
|
|||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: Colors.grey[800],
|
color: Colors.grey[800],
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) =>
|
errorWidget: (context, url, error) => Icon(
|
||||||
Icon(Icons.image_not_supported,
|
Icons.image_not_supported,
|
||||||
color: Colors.grey[600]),
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -239,11 +240,11 @@ class TrendingWidget extends ConsumerWidget {
|
|||||||
final VoidCallback? onItemTap;
|
final VoidCallback? onItemTap;
|
||||||
|
|
||||||
const TrendingWidget({
|
const TrendingWidget({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.playlist,
|
required this.playlist,
|
||||||
required this.content,
|
required this.content,
|
||||||
this.onItemTap,
|
this.onItemTap,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -265,12 +266,15 @@ class TrendingWidget extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.local_fire_department,
|
Icon(
|
||||||
color: Colors.red, size: 24),
|
Icons.local_fire_department,
|
||||||
const SizedBox(width: 8),
|
color: Colors.red,
|
||||||
const Text(
|
size: 24,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
'Trending Now',
|
'Trending Now',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@@ -354,9 +358,10 @@ class _TrendingCard extends StatelessWidget {
|
|||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: Colors.grey[800],
|
color: Colors.grey[800],
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) =>
|
errorWidget: (context, url, error) => Icon(
|
||||||
Icon(Icons.image_not_supported,
|
Icons.image_not_supported,
|
||||||
color: Colors.grey[600]),
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -379,8 +384,11 @@ class _TrendingCard extends StatelessWidget {
|
|||||||
if (item.rating != null)
|
if (item.rating != null)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.star,
|
const Icon(
|
||||||
color: Colors.amber, size: 12),
|
Icons.star,
|
||||||
|
color: Colors.amber,
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
item.rating ?? '0',
|
item.rating ?? '0',
|
||||||
@@ -444,11 +452,11 @@ class RecentlyAddedWidget extends ConsumerWidget {
|
|||||||
final VoidCallback? onItemTap;
|
final VoidCallback? onItemTap;
|
||||||
|
|
||||||
const RecentlyAddedWidget({
|
const RecentlyAddedWidget({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.playlist,
|
required this.playlist,
|
||||||
required this.content,
|
required this.content,
|
||||||
this.onItemTap,
|
this.onItemTap,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -463,9 +471,9 @@ class RecentlyAddedWidget extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Recently Added',
|
'Recently Added',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|||||||
@@ -389,9 +389,11 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(
|
color: Colors.white.withOpacity(
|
||||||
0.05), // Subtle transparent background
|
0.05,
|
||||||
|
), // Subtle transparent background
|
||||||
borderRadius: const BorderRadius.vertical(
|
borderRadius: const BorderRadius.vertical(
|
||||||
top: Radius.circular(12)),
|
top: Radius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -418,7 +420,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
// Footer
|
// Footer
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 8),
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -464,8 +468,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
EpgEntry.parseDateTime(entry.start);
|
EpgEntry.parseDateTime(entry.start);
|
||||||
final end =
|
final end =
|
||||||
EpgEntry.parseDateTime(entry.end);
|
EpgEntry.parseDateTime(entry.end);
|
||||||
if (start == null || end == null)
|
if (start == null || end == null) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
return now.isAfter(start) &&
|
return now.isAfter(start) &&
|
||||||
now.isBefore(end);
|
now.isBefore(end);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ class QualitySelectorWidget extends ConsumerStatefulWidget {
|
|||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
|
||||||
const QualitySelectorWidget({
|
const QualitySelectorWidget({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.qualitySelector,
|
required this.qualitySelector,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<QualitySelectorWidget> createState() =>
|
ConsumerState<QualitySelectorWidget> createState() =>
|
||||||
@@ -43,7 +43,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
|
|||||||
// Header
|
// Header
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: Colors.white24),
|
bottom: BorderSide(color: Colors.white24),
|
||||||
),
|
),
|
||||||
@@ -109,13 +109,16 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
|
|||||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
leading: isSelected
|
leading: isSelected
|
||||||
? const Icon(Icons.check_circle, color: Colors.blue)
|
? const Icon(Icons.check_circle, color: Colors.blue)
|
||||||
: const Icon(Icons.radio_button_unchecked,
|
: const Icon(
|
||||||
color: Colors.white30),
|
Icons.radio_button_unchecked,
|
||||||
|
color: Colors.white30,
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
quality.label,
|
quality.label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected ? Colors.blue : Colors.white,
|
color: isSelected ? Colors.blue : Colors.white,
|
||||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
fontWeight:
|
||||||
|
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
@@ -136,7 +139,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(color: Colors.white24),
|
top: BorderSide(color: Colors.white24),
|
||||||
),
|
),
|
||||||
@@ -177,10 +180,10 @@ class QualityIndicator extends ConsumerWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const QualityIndicator({
|
const QualityIndicator({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.qualitySelector,
|
required this.qualitySelector,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -219,9 +222,9 @@ class BandwidthMonitor extends ConsumerWidget {
|
|||||||
final QualitySelector qualitySelector;
|
final QualitySelector qualitySelector;
|
||||||
|
|
||||||
const BandwidthMonitor({
|
const BandwidthMonitor({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.qualitySelector,
|
required this.qualitySelector,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -253,10 +253,12 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
|
|||||||
value: _durationMinutes,
|
value: _durationMinutes,
|
||||||
dropdownColor: Colors.grey[800],
|
dropdownColor: Colors.grey[800],
|
||||||
items: [30, 60, 120, 240, 480]
|
items: [30, 60, 120, 240, 480]
|
||||||
.map((m) => DropdownMenuItem(
|
.map(
|
||||||
value: m,
|
(m) => DropdownMenuItem(
|
||||||
child: Text('$m min'),
|
value: m,
|
||||||
))
|
child: Text('$m min'),
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) {
|
if (v != null) {
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline,
|
const Icon(
|
||||||
size: 48, color: AppColors.error),
|
Icons.error_outline,
|
||||||
|
size: 48,
|
||||||
|
color: AppColors.error,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Error loading playlists', style: GoogleFonts.inter()),
|
Text('Error loading playlists', style: GoogleFonts.inter()),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -82,12 +85,16 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.playlist_remove,
|
const Icon(
|
||||||
size: 48, color: AppColors.textSecondary),
|
Icons.playlist_remove,
|
||||||
|
size: 48,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('No playlists available',
|
Text(
|
||||||
style:
|
'No playlists available',
|
||||||
GoogleFonts.inter(color: AppColors.textSecondary)),
|
style: GoogleFonts.inter(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -158,8 +165,11 @@ class _MobilePlaylistCard extends StatelessWidget {
|
|||||||
color: AppColors.primary.withOpacity(0.2),
|
color: AppColors.primary.withOpacity(0.2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.playlist_play,
|
child: const Icon(
|
||||||
color: AppColors.primary, size: 28),
|
Icons.playlist_play,
|
||||||
|
color: AppColors.primary,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -48,14 +48,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(),
|
const CircularProgressIndicator(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Loading channels...', style: TextStyle(color: Colors.white70)),
|
const Text('Loading channels...',
|
||||||
|
style: TextStyle(color: Colors.white70)),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FutureBuilder(
|
FutureBuilder(
|
||||||
future: Future.delayed(const Duration(seconds: 8)),
|
future: Future.delayed(const Duration(seconds: 8)),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.done) {
|
if (snapshot.connectionState == ConnectionState.done) {
|
||||||
return TextButton(
|
return TextButton(
|
||||||
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
|
onPressed: () => ref.refresh(
|
||||||
|
liveChannelsByPlaylistProvider(widget.playlist)),
|
||||||
child: const Text('Taking too long? Tap to retry'),
|
child: const Text('Taking too long? Tap to retry'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,12 +75,19 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Failed to load channels', style: GoogleFonts.inter(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
Text('Failed to load channels',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('$e', style: const TextStyle(color: Colors.white54), textAlign: TextAlign.center),
|
Text('$e',
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
|
onPressed: () => ref
|
||||||
|
.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
|
||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -285,7 +294,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
channel: channel,
|
channel: channel,
|
||||||
playlist: widget.playlist,
|
playlist: widget.playlist,
|
||||||
onTap: () => _playChannel(
|
onTap: () => _playChannel(
|
||||||
context, channel, displayedChannels),
|
context,
|
||||||
|
channel,
|
||||||
|
displayedChannels,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -299,7 +311,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _playChannel(
|
void _playChannel(
|
||||||
BuildContext context, Channel channel, List<Channel> channels) {
|
BuildContext context,
|
||||||
|
Channel channel,
|
||||||
|
List<Channel> channels,
|
||||||
|
) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => MobilePlayerScreen(
|
builder: (context) => MobilePlayerScreen(
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class MobileTheme {
|
|||||||
final baseTheme = ThemeData.dark(useMaterial3: true);
|
final baseTheme = ThemeData.dark(useMaterial3: true);
|
||||||
|
|
||||||
return baseTheme.copyWith(
|
return baseTheme.copyWith(
|
||||||
useMaterial3: true,
|
|
||||||
colorScheme: AppColors.darkColorScheme,
|
colorScheme: AppColors.darkColorScheme,
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
canvasColor: AppColors.background,
|
canvasColor: AppColors.background,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class MobileScaffold extends ConsumerWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surface.withOpacity(0.9),
|
color: AppColors.surface.withOpacity(0.9),
|
||||||
border: Border(
|
border: const Border(
|
||||||
top: BorderSide(
|
top: BorderSide(
|
||||||
color: AppColors.border,
|
color: AppColors.border,
|
||||||
width: 0.5,
|
width: 0.5,
|
||||||
|
|||||||
@@ -228,7 +228,6 @@
|
|||||||
// Android / Chrome - HLS.js
|
// Android / Chrome - HLS.js
|
||||||
if (isHLS && Hls.isSupported()) {
|
if (isHLS && Hls.isSupported()) {
|
||||||
log('Using Hls.js optimized for mobile');
|
log('Using Hls.js optimized for mobile');
|
||||||
const isTurbo = urlParams.get('turbo') === 'true';
|
|
||||||
|
|
||||||
const hls = new Hls({
|
const hls = new Hls({
|
||||||
maxBufferLength: 60,
|
maxBufferLength: 60,
|
||||||
|
|||||||
Reference in new issue
Block a user