mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 15:08:07 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0541e1b0e3 | ||
|
|
8ba57a7367 | ||
|
|
8bed6cf7b8 | ||
|
|
f6a4fc41da | ||
|
|
46a1710491 | ||
|
|
aacfa54604 | ||
|
|
ea63314ba7 |
No files matched your search
@@ -2,6 +2,14 @@
|
||||
|
||||
## Non publié
|
||||
|
||||
### ✨ Fonctionnalités
|
||||
- **Guide TV et Season Passes de retour** : l'onglet Enregistrements retrouve ses 3 vues (Guide TV pour programmer depuis l'EPG, liste des enregistrements, Season Passes) — le code existait mais n'était plus branché depuis une refonte
|
||||
- **Favoris enfin utilisables** : bouton cœur sur les tuiles chaînes (desktop et mobile) ; le filtre « Favoris » affichait toujours vide faute de moyen d'en ajouter
|
||||
- **Reprise de lecture** : films et épisodes reprennent où on s'était arrêté (les positions étaient sauvegardées mais jamais relues) ; le live ne pollue plus le stockage de positions
|
||||
- **Enregistrements sur mobile** : nouvel onglet REC dans la barre de navigation ; les onglets mobiles conservent leur état (IndexedStack) au lieu d'être reconstruits à chaque bascule
|
||||
- **Menu profil** sur l'avatar de la sidebar : nom d'utilisateur + déconnexion (le bouton était mort, aucune déconnexion possible depuis le dashboard)
|
||||
- **Confirmation avant suppression** d'un enregistrement, et messages d'erreur avec bouton « Réessayer » (chaînes, enregistrements) au lieu d'exceptions brutes
|
||||
|
||||
### 🔧 Fiabilité des enregistrements
|
||||
- **Fuseaux horaires unifiés** : le backend exige des dates ISO-8601 avec fuseau (400 sinon) et stocke tout en UTC ; le frontend passe par un helper unique `postRecording()` — fini les enregistrements décalés de 1-2 h selon l'écran utilisé
|
||||
- **Contrôle de propriété** : stop/suppression/logs d'un enregistrement et suppression d'un season pass ne sont plus possibles que par leur propriétaire (ou un admin)
|
||||
|
||||
+11
-2
@@ -3,6 +3,7 @@ import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/playlist_config.dart';
|
||||
import '../services/xmltv_epg_service.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// API EPG — proxy vers Xtream avec cache 30 minutes
|
||||
/// GET /api/epg/<channel_id>?days=1
|
||||
@@ -112,8 +113,11 @@ class EpgApi {
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Détail redacté en log uniquement : une ClientException Dart contient
|
||||
// l'URI amont, credentials Xtream inclus.
|
||||
print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}');
|
||||
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'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
@@ -258,7 +262,12 @@ class EpgApi {
|
||||
|
||||
return {'channel_id': channelId, 'programmes': programmes};
|
||||
} catch (e) {
|
||||
return {'channel_id': channelId, 'programmes': [], 'error': e.toString()};
|
||||
print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}');
|
||||
return {
|
||||
'channel_id': channelId,
|
||||
'programmes': [],
|
||||
'error': 'EPG indisponible',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+74
-15
@@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../database/database.dart';
|
||||
import '../middleware/auth_middleware.dart';
|
||||
import '../models/playlist_config.dart';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
@@ -31,6 +33,7 @@ bool isForbiddenProxyHost(String host) {
|
||||
/// Handler for the Xtream Proxy
|
||||
class ProxyHandler {
|
||||
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
|
||||
final AppDatabase _db;
|
||||
final http.Client _client = http.Client();
|
||||
|
||||
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
|
||||
@@ -70,7 +73,7 @@ class ProxyHandler {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
ProxyHandler(this._getPlaylist);
|
||||
ProxyHandler(this._getPlaylist, this._db);
|
||||
|
||||
/// Create Xtream proxy handler with M3U8 URL rewriting support
|
||||
Handler get handler {
|
||||
@@ -84,8 +87,16 @@ class ProxyHandler {
|
||||
return Response.notFound(null);
|
||||
}
|
||||
|
||||
// NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.)
|
||||
// SSRF protection is still active via domain validation below.
|
||||
// Authentification par session. Les requêtes initiées par le navigateur
|
||||
// (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient
|
||||
// le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au
|
||||
// login : extractAuthToken accepte les deux. Un proxy ouvert offrait un
|
||||
// rebond SSRF non authentifié vers n'importe quel hôte public via les
|
||||
// extensions d'image.
|
||||
final token = extractAuthToken(request);
|
||||
if (token == null || _db.findSessionByToken(token) == null) {
|
||||
return Response(401, body: 'Unauthorized');
|
||||
}
|
||||
|
||||
Uri? targetUrl;
|
||||
|
||||
@@ -139,6 +150,7 @@ class ProxyHandler {
|
||||
targetUrl.path.contains('/picons/') ||
|
||||
targetUrl.path.contains('/logos/');
|
||||
|
||||
String? allowedHost;
|
||||
if (!isStaticAsset) {
|
||||
// For API calls, enforce domain allowlist
|
||||
final playlist = await _getCachedPlaylist(request);
|
||||
@@ -149,7 +161,7 @@ class ProxyHandler {
|
||||
}
|
||||
|
||||
final targetHost = targetUrl.host.toLowerCase();
|
||||
final allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
|
||||
allowedHost = Uri.parse(playlist.dns).host.toLowerCase();
|
||||
|
||||
if (targetHost != allowedHost) {
|
||||
print(
|
||||
@@ -175,7 +187,6 @@ class ProxyHandler {
|
||||
|
||||
try {
|
||||
print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
|
||||
final proxyRequest = http.Request(request.method, targetUrl);
|
||||
|
||||
// Forward safe request headers
|
||||
for (final header in _allowedRequestHeaders) {
|
||||
@@ -184,18 +195,61 @@ class ProxyHandler {
|
||||
}
|
||||
}
|
||||
|
||||
proxyRequest.headers.addAll(proxyHeaders);
|
||||
proxyRequest.followRedirects = true;
|
||||
|
||||
List<int>? postBody;
|
||||
if (request.method == 'POST') {
|
||||
final bodyBytes = await request.read().toList();
|
||||
proxyRequest.bodyBytes = bodyBytes.expand((i) => i).toList();
|
||||
postBody = bodyBytes.expand((i) => i).toList();
|
||||
}
|
||||
|
||||
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
||||
final response = await _client
|
||||
.send(proxyRequest)
|
||||
.timeout(const Duration(seconds: 90));
|
||||
// Redirections suivies MANUELLEMENT : chaque destination est
|
||||
// revalidée (hôte privé, allowlist de domaine). Avec
|
||||
// followRedirects, la validation ne portait que sur l'URL
|
||||
// initiale — une 302 du serveur amont suffisait pour atteindre
|
||||
// un hôte interne malgré l'anti-SSRF.
|
||||
http.StreamedResponse response;
|
||||
var currentUrl = targetUrl;
|
||||
var redirects = 0;
|
||||
while (true) {
|
||||
final proxyRequest = http.Request(request.method, currentUrl);
|
||||
proxyRequest.headers.addAll(proxyHeaders);
|
||||
proxyRequest.followRedirects = false;
|
||||
if (postBody != null) proxyRequest.bodyBytes = postBody;
|
||||
|
||||
// Added 90s timeout to allow frontend (60s) to time out gracefully first
|
||||
response = await _client
|
||||
.send(proxyRequest)
|
||||
.timeout(const Duration(seconds: 90));
|
||||
|
||||
final location = response.headers['location'];
|
||||
final isRedirect = response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
location != null;
|
||||
if (!isRedirect) break;
|
||||
|
||||
if (++redirects > 3) {
|
||||
return Response.forbidden('Too many redirects');
|
||||
}
|
||||
final next = Uri.parse(location);
|
||||
currentUrl = next.isAbsolute ? next : currentUrl.resolve(location);
|
||||
if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') {
|
||||
return Response.forbidden('Unsupported redirect scheme');
|
||||
}
|
||||
if (isForbiddenProxyHost(currentUrl.host)) {
|
||||
print(
|
||||
'[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}',
|
||||
);
|
||||
return Response.forbidden('Access to this host is forbidden');
|
||||
}
|
||||
if (allowedHost != null &&
|
||||
currentUrl.host.toLowerCase() != allowedHost) {
|
||||
print(
|
||||
'[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)',
|
||||
);
|
||||
return Response.forbidden(
|
||||
'Access to this domain is forbidden by policy',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build response headers from source response
|
||||
final responseHeaders = <String, String>{
|
||||
@@ -221,7 +275,12 @@ class ProxyHandler {
|
||||
rethrow;
|
||||
}
|
||||
} catch (e) {
|
||||
print('[ProxyHandler] error on $path: $e');
|
||||
// Redaction : une ClientException porte l'URL amont, credentials
|
||||
// Xtream inclus. Jamais de détail d'exception vers le client.
|
||||
print(
|
||||
'[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
|
||||
// Return transparent 1x1 pixel image fallback for images
|
||||
if (targetUrl?.path.endsWith('.png') == true ||
|
||||
@@ -235,7 +294,7 @@ class ProxyHandler {
|
||||
}
|
||||
|
||||
return Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Proxy error', 'message': e.toString()}),
|
||||
body: jsonEncode({'error': 'Proxy error'}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ class UsersHandler {
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (db.findUserByUsername(username) != null) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
@@ -103,6 +110,13 @@ class UsersHandler {
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return Response.badRequest(body: jsonEncode({
|
||||
'success': false,
|
||||
'error': 'Le mot de passe doit faire au moins 8 caractères',
|
||||
}), headers: {'Content-Type': 'application/json'},);
|
||||
}
|
||||
|
||||
db.updateUserPassword(id, password);
|
||||
return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/user.dart';
|
||||
@@ -195,14 +196,21 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed default admin user if no users exist
|
||||
/// Seed default admin user if no users exist.
|
||||
///
|
||||
/// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré
|
||||
/// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien
|
||||
/// couple admin/admin restait souvent en place sur les instances exposées.
|
||||
Future<void> seedAdmin() async {
|
||||
final result = _db.select('SELECT COUNT(*) as count FROM users');
|
||||
final count = result.first['count'] as int;
|
||||
|
||||
if (count == 0) {
|
||||
final adminId = _uuid.v4();
|
||||
final passwordHash = PasswordHasher.hash('admin');
|
||||
final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD'];
|
||||
final generated = envPassword == null || envPassword.isEmpty;
|
||||
final password = generated ? _generatePassword() : envPassword;
|
||||
final passwordHash = PasswordHasher.hash(password);
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
@@ -212,10 +220,34 @@ class AppDatabase {
|
||||
[adminId, 'admin', passwordHash],
|
||||
);
|
||||
|
||||
print('Default admin user created (username: admin, password: admin)');
|
||||
if (generated) {
|
||||
print('╔══════════════════════════════════════════════════════════╗');
|
||||
print(' Compte admin créé — mot de passe initial (affiché une');
|
||||
print(' seule fois, changez-le après la première connexion) :');
|
||||
print(' utilisateur: admin');
|
||||
print(' mot de passe: $password');
|
||||
print('╚══════════════════════════════════════════════════════════╝');
|
||||
} else {
|
||||
print(
|
||||
'Default admin user created (username: admin, '
|
||||
'password: ADMIN_INITIAL_PASSWORD)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _generatePassword({int length = 16}) {
|
||||
// Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié
|
||||
// depuis les logs du conteneur.
|
||||
const chars =
|
||||
'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
final random = Random.secure();
|
||||
return List.generate(
|
||||
length,
|
||||
(_) => chars[random.nextInt(chars.length)],
|
||||
).join();
|
||||
}
|
||||
|
||||
// ==================== Users ====================
|
||||
|
||||
/// Find user by username
|
||||
|
||||
@@ -77,7 +77,12 @@ Middleware streamAuthMiddleware(AppDatabase db) {
|
||||
};
|
||||
}
|
||||
|
||||
/// Extract token from Authorization header or cookie
|
||||
/// Extract token from Authorization header or cookie.
|
||||
/// Public : le proxy /api/xtream fait sa propre vérification de session
|
||||
/// (le contrôle doit rester DANS le handler, après le test de chemin,
|
||||
/// pour que les requêtes non-proxy tombent sur le handler statique).
|
||||
String? extractAuthToken(Request request) => _extractToken(request);
|
||||
|
||||
String? _extractToken(Request request) {
|
||||
// Try Authorization header first
|
||||
final authHeader = request.headers['authorization'];
|
||||
|
||||
@@ -1,26 +1,94 @@
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import '../utils/log_redactor.dart';
|
||||
|
||||
/// Security Middleware Collection
|
||||
///
|
||||
/// Includes:
|
||||
/// - Redacted request logging
|
||||
/// - Honeypot Routes (Trap for bots)
|
||||
/// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
|
||||
/// - Rate Limiting (Basic DoS protection)
|
||||
/// - Login-specific rate limiting (brute-force protection)
|
||||
|
||||
/// Resolve the real client IP.
|
||||
/// Honors the first hop of X-Forwarded-For when behind nginx, otherwise
|
||||
/// falls back to the socket connection info.
|
||||
String clientIpOf(Request request) {
|
||||
final forwarded = request.headers['x-forwarded-for'];
|
||||
if (forwarded != null && forwarded.isNotEmpty) {
|
||||
return forwarded.split(',').first.trim();
|
||||
/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré.
|
||||
/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du
|
||||
/// docker-compose parle depuis le réseau Docker). Surcharger avec
|
||||
/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre.
|
||||
final List<String> _trustedProxies =
|
||||
(Platform.environment['TRUSTED_PROXIES'] ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
bool _isTrustedProxy(String address) {
|
||||
if (_trustedProxies.isNotEmpty) return _trustedProxies.contains(address);
|
||||
final ip = InternetAddress.tryParse(address);
|
||||
if (ip == null) return false;
|
||||
if (ip.isLoopback) return true;
|
||||
if (ip.type == InternetAddressType.IPv4) {
|
||||
final parts = ip.address.split('.').map(int.parse).toList();
|
||||
if (parts[0] == 10) return true;
|
||||
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] == 192 && parts[1] == 168) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Resolve the real client IP.
|
||||
///
|
||||
/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un
|
||||
/// proxy de confiance : sinon un client direct peut forger l'en-tête et
|
||||
/// contourner le rate limit global comme la limite de tentatives de login.
|
||||
String clientIpOf(Request request) {
|
||||
final connectionInfo =
|
||||
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
|
||||
return connectionInfo?.remoteAddress.address ?? 'unknown';
|
||||
final socketAddress = connectionInfo?.remoteAddress.address;
|
||||
|
||||
final forwarded = request.headers['x-forwarded-for'];
|
||||
if (forwarded != null &&
|
||||
forwarded.isNotEmpty &&
|
||||
socketAddress != null &&
|
||||
_isTrustedProxy(socketAddress)) {
|
||||
return forwarded.split(',').first.trim();
|
||||
}
|
||||
return socketAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
/// 0. Redacted request logging.
|
||||
///
|
||||
/// Remplace `logRequests()` de shelf : le chemin `/api/xtream/<url>` embarque
|
||||
/// `username`/`password` Xtream en clair dans l'URI, que le logger standard
|
||||
/// écrivait tels quels — annulant l'effort de LogRedactor partout ailleurs.
|
||||
Middleware redactedLogRequests() {
|
||||
return (Handler handler) {
|
||||
return (Request request) async {
|
||||
final watch = Stopwatch()..start();
|
||||
try {
|
||||
final response = await handler(request);
|
||||
watch.stop();
|
||||
final query =
|
||||
request.requestedUri.hasQuery ? '?${request.requestedUri.query}' : '';
|
||||
print(
|
||||
'${DateTime.now().toIso8601String()} ${response.statusCode} '
|
||||
'${request.method} '
|
||||
'${LogRedactor.redactUrl('${request.requestedUri.path}$query')} '
|
||||
'(${watch.elapsedMilliseconds}ms)',
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
watch.stop();
|
||||
print(
|
||||
'${DateTime.now().toIso8601String()} ERR ${request.method} '
|
||||
'${LogRedactor.redactUrl(request.requestedUri.path)}: '
|
||||
'${LogRedactor.redactUrl('$e')}',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// 1. Security Headers Middleware
|
||||
@@ -71,11 +139,14 @@ Middleware honeypotMiddleware() {
|
||||
|
||||
return (Handler handler) {
|
||||
return (Request request) {
|
||||
final path = request.url.path;
|
||||
// Comparaison sur le chemin exact ou un préfixe de segment. L'ancienne
|
||||
// comparaison `contains(trap.replaceAll('/', ''))` bloquait toute URL
|
||||
// contenant « console », « env » ou « wpadmin » n'importe où — y
|
||||
// compris des URLs proxifiées parfaitement légitimes.
|
||||
final path = '/${request.url.path}';
|
||||
|
||||
// Check if path contains any honeypot target
|
||||
for (final trap in honeypotPaths) {
|
||||
if (path.contains(trap.replaceAll('/', ''))) { // Simple check
|
||||
if (path == trap || path.startsWith('$trap/')) {
|
||||
print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
|
||||
return Response.forbidden('Access Denied');
|
||||
}
|
||||
|
||||
+7
-3
@@ -120,7 +120,7 @@ void main(List<String> args) async {
|
||||
final playlistsHandler = PlaylistsHandler(db);
|
||||
final usersHandler = UsersHandler(db);
|
||||
final settingsHandler = SettingsHandler(db);
|
||||
final proxyHandler = ProxyHandler(getPlaylist);
|
||||
final proxyHandler = ProxyHandler(getPlaylist, db);
|
||||
final recordingsApi = RecordingsApi(db, recordingScheduler);
|
||||
// Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant :
|
||||
// l'EPG se limite alors au panneau de l'abonné.
|
||||
@@ -222,8 +222,10 @@ void main(List<String> args) async {
|
||||
// Do NOT mount here as it would intercept and block the actual proxy
|
||||
|
||||
// Initialize Cleanup Service
|
||||
// Ne JAMAIS cibler Directory.systemTemp en récursif : il contient les
|
||||
// temporaires de la VM Dart et le dossier des sessions HLS — les fichiers
|
||||
// de plus de 24 h y étaient supprimés aveuglément.
|
||||
final cleanupService = CleanupService();
|
||||
cleanupService.addTarget(Directory.systemTemp);
|
||||
cleanupService.addTarget(Directory('/app/data/logs'));
|
||||
cleanupService.addTarget(Directory('/app/data/tmp'));
|
||||
|
||||
@@ -338,8 +340,10 @@ void main(List<String> args) async {
|
||||
.handler;
|
||||
|
||||
// Add middleware
|
||||
// redactedLogRequests remplace logRequests() : l'URI de /api/xtream/<url>
|
||||
// contient username/password Xtream en clair.
|
||||
final pipeline = const Pipeline()
|
||||
.addMiddleware(logRequests())
|
||||
.addMiddleware(redactedLogRequests())
|
||||
.addMiddleware(securityHeadersMiddleware())
|
||||
.addMiddleware(honeypotMiddleware())
|
||||
.addMiddleware(rateLimitMiddleware())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
@@ -200,13 +201,61 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Settings / Profile
|
||||
// Profil : nom d'utilisateur + déconnexion. L'avatar était
|
||||
// un bouton mort (onTap vide) — aucune déconnexion possible
|
||||
// depuis le dashboard desktop.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: TvFocusableCard(
|
||||
onTap: () {},
|
||||
borderRadius: 50,
|
||||
scaleFactor: 1.1,
|
||||
child: PopupMenuButton<String>(
|
||||
tooltip: 'Profil',
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
offset: const Offset(56, -12),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.person,
|
||||
size: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
ref.read(authProvider).currentUser?.username ??
|
||||
'Utilisateur',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'logout',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.logout,
|
||||
size: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Déconnexion',
|
||||
style: TextStyle(color: AppColors.onSurface),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'logout') {
|
||||
ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
},
|
||||
child: const CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: AppColors.surfaceContainerHigh,
|
||||
|
||||
@@ -271,8 +271,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
// Update watch history if relevant
|
||||
if (currentTime > 0) {
|
||||
// Update watch history if relevant. Jamais en live : la « position »
|
||||
// d'un flux continu n'a pas de sens et polluait le stockage avec une
|
||||
// entrée bidon par chaîne zappée.
|
||||
if (currentTime > 0 && widget.streamType != StreamType.live) {
|
||||
ref.read(playbackPositionsProvider.notifier).savePosition(
|
||||
widget.streamId,
|
||||
currentTime,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../models/xtream_models.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
import '../providers/playback_positions_provider.dart';
|
||||
import '../providers/watch_history_provider.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'player_screen.dart';
|
||||
@@ -287,7 +288,14 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
|
||||
// Reprise de lecture : le player sauvegarde la position sous
|
||||
// episode.id, on la relit ici pour reprendre où on s'était arrêté.
|
||||
final positions = ref.read(playbackPositionsProvider);
|
||||
final resumeAt = positions.hasPosition(episode.id)
|
||||
? positions.getPosition(episode.id)
|
||||
: null;
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -298,6 +306,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
||||
streamType: StreamType.series,
|
||||
containerExtension: episode.containerExtension ?? 'mkv',
|
||||
duration: episodeDuration,
|
||||
startTime: resumeAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -63,8 +63,23 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
body: channelsAsync.when(
|
||||
loading: () => const ThemedLoading(),
|
||||
error: (e, s) => Center(
|
||||
child: Text('Error: $e',
|
||||
style: const TextStyle(color: AppColors.onSurface)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Impossible de charger les chaînes',
|
||||
style: TextStyle(color: AppColors.onSurface),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Réessayer'),
|
||||
onPressed: () => ref.invalidate(
|
||||
liveChannelsByPlaylistProvider(widget.playlist),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (groupedChannels) {
|
||||
var categories = groupedChannels.keys.toList();
|
||||
@@ -515,6 +530,43 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
),
|
||||
],
|
||||
),
|
||||
// Favori (Positioned top left) — toggleFavorite n'était appelé
|
||||
// nulle part : le filtre « Favoris » affichait toujours vide.
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final isFav = ref
|
||||
.watch(favoritesProvider)
|
||||
.contains(channel.streamId);
|
||||
return TvFocusableCard(
|
||||
onTap: () => ref
|
||||
.read(favoritesProvider.notifier)
|
||||
.toggleFavorite(channel.streamId),
|
||||
borderRadius: 20,
|
||||
scaleFactor: 1.2,
|
||||
semanticLabel: isFav
|
||||
? 'Retirer ${channel.name} des favoris'
|
||||
: 'Ajouter ${channel.name} aux favoris',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFav
|
||||
? AppColors.primary
|
||||
: AppColors.onSurface54,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Record Button Icon Overlay (Positioned top right)
|
||||
Positioned(
|
||||
top: 8,
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../core/utils/responsive_layout.dart';
|
||||
import '../../../core/widgets/hero_carousel.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
import '../providers/playback_positions_provider.dart';
|
||||
import '../providers/watch_history_provider.dart';
|
||||
import '../models/xtream_models.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
@@ -163,6 +164,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Reprise de lecture : les positions étaient sauvegardées par le player
|
||||
// mais jamais relues — le film repartait systématiquement de zéro.
|
||||
final positions = ref.read(playbackPositionsProvider);
|
||||
final resumeAt = positions.hasPosition(movie.streamId)
|
||||
? positions.getPosition(movie.streamId)
|
||||
: null;
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -173,6 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
streamType: StreamType.vod,
|
||||
containerExtension: movie.containerExtension ?? 'mp4',
|
||||
duration: movieDuration,
|
||||
startTime: resumeAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,10 @@ import '../screens/player_screen.dart';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ENTRÉE — Onglet "Enregistrements"
|
||||
// Trois vues : Guide TV (programmer depuis l'EPG), Enregistrements (liste),
|
||||
// Season Passes (enregistrements récurrents). _EpgGuideView et
|
||||
// _SeasonPassesView existaient déjà mais n'étaient plus instanciés depuis
|
||||
// une refonte : les fonctions étaient codées mais inaccessibles.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class RecordingsTab extends StatefulWidget {
|
||||
@@ -25,7 +29,24 @@ class RecordingsTab extends StatefulWidget {
|
||||
State<RecordingsTab> createState() => _RecordingsTabState();
|
||||
}
|
||||
|
||||
class _RecordingsTabState extends State<RecordingsTab> {
|
||||
class _RecordingsTabState extends State<RecordingsTab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Ouvrir sur la liste des enregistrements (onglet du milieu), l'usage le
|
||||
// plus fréquent ; le guide sert à en programmer de nouveaux.
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -33,25 +54,59 @@ class _RecordingsTabState extends State<RecordingsTab> {
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
color: Colors.grey[900],
|
||||
child: const Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Enregistrements',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Enregistrements',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
indicatorColor: AppColors.primary,
|
||||
labelColor: AppColors.onSurface,
|
||||
unselectedLabelColor: AppColors.onSurface54,
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.calendar_month, size: 18),
|
||||
text: 'Guide TV',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.fiber_manual_record, size: 18),
|
||||
text: 'Enregistrements',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.repeat, size: 18),
|
||||
text: 'Season Passes',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _RecordingsListView(playlist: widget.playlist),
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_EpgGuideView(playlist: widget.playlist),
|
||||
_RecordingsListView(playlist: widget.playlist),
|
||||
const _SeasonPassesView(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -825,7 +880,39 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording(String id) async {
|
||||
/// Demande confirmation avant suppression : l'ancienne corbeille supprimait
|
||||
/// immédiatement, sans retour ni possibilité d'annuler.
|
||||
Future<void> _deleteRecording(String id, String title) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceContainer,
|
||||
title: Text(
|
||||
'Supprimer l\'enregistrement ?',
|
||||
style: GoogleFonts.fraunces(color: AppColors.onSurface, fontSize: 18),
|
||||
),
|
||||
content: Text(
|
||||
'« $title » et son fichier seront définitivement supprimés.',
|
||||
style: const TextStyle(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.errorContainer,
|
||||
foregroundColor: AppColors.onErrorContainer,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
|
||||
_fetchRecordings();
|
||||
}
|
||||
@@ -986,9 +1073,20 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error ?? 'Erreur',
|
||||
style: const TextStyle(color: AppColors.live),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Impossible de charger les enregistrements',
|
||||
style: TextStyle(color: AppColors.live),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Réessayer'),
|
||||
onPressed: _fetchRecordings,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _recordings.isEmpty
|
||||
@@ -1157,8 +1255,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () =>
|
||||
_deleteRecording(rec['id']),
|
||||
onPressed: () => _deleteRecording(
|
||||
rec['id'],
|
||||
rec['title'] ?? '',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/models/playlist_config.dart';
|
||||
import '../../../widgets/mobile_scaffold.dart';
|
||||
import '../../../theme/mobile_theme.dart';
|
||||
import '../../../../features/iptv/widgets/recordings_tab.dart';
|
||||
import '../widgets/mobile_live_tv_tab.dart';
|
||||
import '../widgets/mobile_movies_tab.dart';
|
||||
import '../widgets/mobile_series_tab.dart';
|
||||
@@ -34,23 +35,20 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
child: _buildActiveTab(),
|
||||
// IndexedStack conserve l'état des onglets (position de scroll,
|
||||
// catalogues chargés) : l'ancien switch reconstruisait tout à chaque
|
||||
// changement d'onglet. Même approche que le dashboard desktop.
|
||||
child: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: [
|
||||
MobileLiveTVTab(playlist: widget.playlist),
|
||||
MobileMoviesTab(playlist: widget.playlist),
|
||||
MobileSeriesTab(playlist: widget.playlist),
|
||||
RecordingsTab(playlist: widget.playlist),
|
||||
const MobileSettingsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActiveTab() {
|
||||
switch (_currentIndex) {
|
||||
case 0:
|
||||
return MobileLiveTVTab(playlist: widget.playlist);
|
||||
case 1:
|
||||
return MobileMoviesTab(playlist: widget.playlist);
|
||||
case 2:
|
||||
return MobileSeriesTab(playlist: widget.playlist);
|
||||
case 3:
|
||||
return const MobileSettingsTab();
|
||||
default:
|
||||
return MobileLiveTVTab(playlist: widget.playlist);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +335,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileChannelTile extends StatelessWidget {
|
||||
class _MobileChannelTile extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@@ -345,7 +345,10 @@ class _MobileChannelTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Sans ce bouton, aucun moyen d'ajouter un favori : le filtre « Favoris »
|
||||
// de l'en-tête affichait toujours une liste vide.
|
||||
final isFav = ref.watch(favoritesProvider).contains(channel.streamId);
|
||||
final iconUrl =
|
||||
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
|
||||
? '/api/xtream/${channel.streamIcon}'
|
||||
@@ -393,6 +396,18 @@ class _MobileChannelTile extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: isFav ? 'Retirer des favoris' : 'Ajouter aux favoris',
|
||||
icon: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFav ? AppColors.primary : AppColors.onSurface54,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => ref
|
||||
.read(favoritesProvider.notifier)
|
||||
.toggleFavorite(channel.streamId),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -78,6 +78,11 @@ class MobileScaffold extends ConsumerWidget {
|
||||
activeIcon: Icon(Icons.video_library_rounded),
|
||||
label: 'Series',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.videocam_outlined),
|
||||
activeIcon: Icon(Icons.videocam_rounded),
|
||||
label: 'REC',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
activeIcon: Icon(Icons.settings_rounded),
|
||||
|
||||
@@ -131,7 +131,10 @@
|
||||
};
|
||||
|
||||
XFPlayer.prototype.send = function (msg) {
|
||||
try { global.parent.postMessage(msg, '*'); } catch (e) {}
|
||||
// Cible restreinte à notre origine : l'iframe est toujours même-origine
|
||||
// que le parent Flutter, un wildcard '*' livrerait l'état du player à
|
||||
// n'importe quelle page qui embarquerait player.html.
|
||||
try { global.parent.postMessage(msg, global.location.origin); } catch (e) {}
|
||||
};
|
||||
|
||||
// ---------------- Démarrage ----------------
|
||||
@@ -491,6 +494,9 @@
|
||||
XFPlayer.prototype._wireParentMessages = function () {
|
||||
var self = this;
|
||||
global.addEventListener('message', function (event) {
|
||||
// N'accepter que les commandes émises par notre propre origine
|
||||
// (le côté Flutter filtre déjà les messages entrants de la même façon).
|
||||
if (event.origin !== global.location.origin) return;
|
||||
var d = event.data;
|
||||
if (!d || !d.type) return;
|
||||
var v = self.video;
|
||||
|
||||
Reference in new issue
Block a user