feat: Implement a web-based IPTV player with HLS/MPEG-TS support and Flutter communication.

This commit is contained in:
Michael committed 2025-12-17 23:45:56 +01:00
1 parent 2ca92d8425
commit 55acbbc135
28 files changed
+2075 -1448

No files matched your search

+57 -35
View File
@@ -46,22 +46,25 @@ void main(List<String> args) async {
..mount('/api/auth', authHandler.router) ..mount('/api/auth', authHandler.router)
// Playlists endpoints (with auth middleware) // Playlists endpoints (with auth middleware)
..mount( ..mount(
'/api/playlists', '/api/playlists',
Pipeline() const Pipeline()
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler(playlistsHandler.router.call)) .addHandler(playlistsHandler.router.call),
)
// Users endpoints (with auth middleware) // Users endpoints (with auth middleware)
..mount( ..mount(
'/api/users', '/api/users',
Pipeline() const Pipeline()
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler(usersHandler.router.call)) .addHandler(usersHandler.router.call),
)
// Settings endpoints (with auth middleware) // Settings endpoints (with auth middleware)
..mount( ..mount(
'/api/settings', '/api/settings',
Pipeline() const Pipeline()
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler(settingsHandler.router.call)); .addHandler(settingsHandler.router.call),
);
// Initialize Cleanup Service // Initialize Cleanup Service
final cleanupService = CleanupService(); final cleanupService = CleanupService();
@@ -76,7 +79,9 @@ void main(List<String> args) async {
// Admin Routes (protected) // Admin Routes (protected)
apiRouter.mount( apiRouter.mount(
'/api/admin', '/api/admin',
Pipeline().addMiddleware(authMiddleware(db)).addHandler((Request request) { const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler((Request request) {
final router = Router(); final router = Router();
// POST /api/admin/purge // POST /api/admin/purge
@@ -84,7 +89,8 @@ void main(List<String> args) async {
final user = req.context['user'] as User?; final user = req.context['user'] as User?;
if (user == null || !user.isAdmin) { if (user == null || !user.isAdmin) {
return Response.forbidden( return Response.forbidden(
jsonEncode({'error': 'Admin access required'})); jsonEncode({'error': 'Admin access required'}),
);
} }
final result = await cleanupService.runCleanup(); final result = await cleanupService.runCleanup();
@@ -107,7 +113,7 @@ void main(List<String> args) async {
); );
// Wrap static handler to enforce cache policies // Wrap static handler to enforce cache policies
Handler staticHandler = (Request request) async { FutureOr<Response> staticHandler(Request request) async {
final response = await baseStaticHandler(request); final response = await baseStaticHandler(request);
// Disable cache for entry points to ensure updates are seen immediately // Disable cache for entry points to ensure updates are seen immediately
@@ -115,22 +121,27 @@ void main(List<String> args) async {
if (path.isEmpty || if (path.isEmpty ||
path == 'index.html' || path == 'index.html' ||
path.endsWith( path.endsWith(
'.js') || // Disable cache for ALL JS files (main.dart.js, bootstrap, etc) '.js',
) || // Disable cache for ALL JS files (main.dart.js, bootstrap, etc)
path.endsWith('.json')) { path.endsWith('.json')) {
// version.json etc // version.json etc
return response.change(headers: { return response.change(
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', headers: {
'Pragma': 'no-cache', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Expires': '0', 'Pragma': 'no-cache',
}); 'Expires': '0',
},
);
} }
// Allow aggressive caching for hashed assets (images, compiled JS) // Allow aggressive caching for hashed assets (images, compiled JS)
// Flutter web builds usually hash main.dart.js, but let's be safe with 1 day // Flutter web builds usually hash main.dart.js, but let's be safe with 1 day
return response.change(headers: { return response.change(
'Cache-Control': 'public, max-age=86400', headers: {
}); 'Cache-Control': 'public, max-age=86400',
}; },
);
}
// Main handler with API proxy and FFmpeg streaming // Main handler with API proxy and FFmpeg streaming
final handler = Cascade() final handler = Cascade()
@@ -142,7 +153,7 @@ void main(List<String> args) async {
.handler; .handler;
// Add middleware // Add middleware
final pipeline = Pipeline() final pipeline = const Pipeline()
.addMiddleware(logRequests()) .addMiddleware(logRequests())
.addMiddleware(securityHeadersMiddleware()) // Basic Headers .addMiddleware(securityHeadersMiddleware()) // Basic Headers
.addMiddleware(honeypotMiddleware()) // Trap Bots .addMiddleware(honeypotMiddleware()) // Trap Bots
@@ -354,7 +365,7 @@ Handler _createXtreamProxyHandler() {
/// Stream video file using HttpClient with Range support for seeking /// Stream video file using HttpClient with Range support for seeking
Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async { Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
final maxRedirects = 5; const maxRedirects = 5;
var currentUrl = targetUrl; var currentUrl = targetUrl;
final cookies = <String, String>{}; // Cookie Jar for redirects final cookies = <String, String>{}; // Cookie Jar for redirects
@@ -397,9 +408,9 @@ Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
print('Upstream Response: ${response.statusCode} (Redirect $i)'); print('Upstream Response: ${response.statusCode} (Redirect $i)');
// Collect Cookies from response // Collect Cookies from response
response.cookies.forEach((cookie) { for (var cookie in response.cookies) {
cookies[cookie.name] = cookie.value; cookies[cookie.name] = cookie.value;
}); }
// Handle Redirects (301, 302, 303, 307, 308) // Handle Redirects (301, 302, 303, 307, 308)
if (response.statusCode >= 300 && response.statusCode < 400) { if (response.statusCode >= 300 && response.statusCode < 400) {
@@ -427,7 +438,8 @@ Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
// Checks for 206 vs 200 // Checks for 206 vs 200
if (response.statusCode == 200 && rangeHeader != null) { if (response.statusCode == 200 && rangeHeader != null) {
print( print(
'WARNING: Upstream ignored Range header and returned 200. Seeking might fail.'); 'WARNING: Upstream ignored Range header and returned 200. Seeking might fail.',
);
} else if (response.statusCode == 206) { } else if (response.statusCode == 206) {
print('SUCCESS: Upstream returned 206 Partial Content.'); print('SUCCESS: Upstream returned 206 Partial Content.');
} }
@@ -458,7 +470,8 @@ Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
if (i == maxRedirects - 1) { if (i == maxRedirects - 1) {
return Response.internalServerError( return Response.internalServerError(
body: jsonEncode( body: jsonEncode(
{'error': 'Video streaming error', 'message': e.toString()}), {'error': 'Video streaming error', 'message': e.toString()},
),
headers: {'content-type': 'application/json'}, headers: {'content-type': 'application/json'},
); );
} }
@@ -471,8 +484,12 @@ Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
/// Rewrite URLs in M3U8 playlist to go through the proxy /// Rewrite URLs in M3U8 playlist to go through the proxy
/// ///
/// Handles both relative and absolute URLs in HLS playlists /// Handles both relative and absolute URLs in HLS playlists
String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath, String _rewriteM3u8Urls(
String proxyOrigin) { String m3u8Content,
String baseUrl,
String originalPath,
String proxyOrigin,
) {
final lines = m3u8Content.split('\n'); final lines = m3u8Content.split('\n');
final rewrittenLines = <String>[]; final rewrittenLines = <String>[];
@@ -517,7 +534,11 @@ String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath,
/// Rewrite a single URL to go through the proxy /// Rewrite a single URL to go through the proxy
String _rewriteUrl( String _rewriteUrl(
String url, String baseUrl, String basePath, String proxyOrigin) { String url,
String baseUrl,
String basePath,
String proxyOrigin,
) {
String fullUrl; String fullUrl;
if (url.startsWith('http://') || url.startsWith('https://')) { if (url.startsWith('http://') || url.startsWith('https://')) {
@@ -731,7 +752,8 @@ Handler _createStreamHandler() {
print(stackTrace); print(stackTrace);
return Response.internalServerError( return Response.internalServerError(
body: jsonEncode( body: jsonEncode(
{'error': 'Stream setup error', 'message': e.toString()}), {'error': 'Stream setup error', 'message': e.toString()},
),
headers: {'content-type': 'application/json'}, headers: {'content-type': 'application/json'},
); );
} }
+34 -26
View File
@@ -7,14 +7,15 @@ import 'package:dio/io.dart';
/// Useful for emulators or restricted networks /// Useful for emulators or restricted networks
class DnsFallbackInterceptor extends Interceptor { class DnsFallbackInterceptor extends Interceptor {
final Dio _dio; final Dio _dio;
// Cache for resolved IPs // Cache for resolved IPs
final Map<String, String> _dnsCache = {}; final Map<String, String> _dnsCache = {};
DnsFallbackInterceptor(this._dio); DnsFallbackInterceptor(this._dio);
@override @override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async { Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
// Basic check for connection/host lookup errors // Basic check for connection/host lookup errors
if (_isDnsError(err)) { if (_isDnsError(err)) {
final uri = err.requestOptions.uri; final uri = err.requestOptions.uri;
@@ -30,12 +31,12 @@ class DnsFallbackInterceptor extends Interceptor {
// Attempt resolution attempts // Attempt resolution attempts
print('DnsFallback: Attempting DoH resolution...'); print('DnsFallback: Attempting DoH resolution...');
ip = await _resolveWithDoH(host); ip = await _resolveWithDoH(host);
if (ip != null) { if (ip != null) {
print('DnsFallback: Resolved $host to $ip'); print('DnsFallback: Resolved $host to $ip');
_dnsCache[host] = ip; _dnsCache[host] = ip;
} else { } else {
print('DnsFallback: Failed to resolve $host'); print('DnsFallback: Failed to resolve $host');
} }
} catch (e) { } catch (e) {
print('DnsFallback: Resolution error: $e'); print('DnsFallback: Resolution error: $e');
@@ -48,30 +49,32 @@ class DnsFallbackInterceptor extends Interceptor {
// Clone options but replace host with IP // Clone options but replace host with IP
final newUri = uri.replace(host: ip); final newUri = uri.replace(host: ip);
final options = err.requestOptions.copyWith(path: newUri.toString()); final options = err.requestOptions.copyWith(path: newUri.toString());
// IMPORTANT: Set Host header so the server handles it correctly // IMPORTANT: Set Host header so the server handles it correctly
options.headers['Host'] = host; options.headers['Host'] = host;
// Retry request // Retry request
print('DnsFallback: Retrying request to $ip'); print('DnsFallback: Retrying request to $ip');
final response = await _dio.fetch(options); final response = await _dio.fetch(options);
return handler.resolve(response); return handler.resolve(response);
} catch (e) { } catch (e) {
print('DnsFallback: Retry failed: $e'); print('DnsFallback: Retry failed: $e');
return handler.next(err); return handler.next(err);
} }
} }
} }
handler.next(err); handler.next(err);
} }
bool _isDnsError(DioException err) { bool _isDnsError(DioException err) {
if (err.type == DioExceptionType.connectionError || err.type == DioExceptionType.unknown) { if (err.type == DioExceptionType.connectionError ||
if (err.error is SocketException) { err.type == DioExceptionType.unknown) {
final msg = (err.error as SocketException).message; if (err.error is SocketException) {
return msg.contains('Failed host lookup') || msg.contains('No address associated with hostname'); final msg = (err.error as SocketException).message;
} return msg.contains('Failed host lookup') ||
msg.contains('No address associated with hostname');
}
} }
return false; return false;
} }
@@ -79,14 +82,17 @@ class DnsFallbackInterceptor extends Interceptor {
/// Resolve hostname using multiple DoH providers /// Resolve hostname using multiple DoH providers
Future<String?> _resolveWithDoH(String hostname) async { Future<String?> _resolveWithDoH(String hostname) async {
// 1. Google DNS (8.8.8.8) // 1. Google DNS (8.8.8.8)
String? ip = await _queryDoH('https://8.8.8.8/resolve?name=$hostname&type=A'); String? ip =
await _queryDoH('https://8.8.8.8/resolve?name=$hostname&type=A');
if (ip != null) return ip; if (ip != null) return ip;
// 2. Cloudflare DNS (1.1.1.1) // 2. Cloudflare DNS (1.1.1.1)
ip = await _queryDoH('https://1.1.1.1/dns-query?name=$hostname&type=A', ip = await _queryDoH(
headers: {'accept': 'application/dns-json'}); 'https://1.1.1.1/dns-query?name=$hostname&type=A',
headers: {'accept': 'application/dns-json'},
);
if (ip != null) return ip; if (ip != null) return ip;
return null; return null;
} }
@@ -94,26 +100,28 @@ class DnsFallbackInterceptor extends Interceptor {
try { try {
// Create a raw HttpClient to avoid loop and control SSL // Create a raw HttpClient to avoid loop and control SSL
final client = HttpClient() final client = HttpClient()
..badCertificateCallback = (cert, host, port) => true; // IGNORE SSL ERRORS just for DoH IP connectivity ..badCertificateCallback = (cert, host, port) =>
true; // IGNORE SSL ERRORS just for DoH IP connectivity
final request = await client.getUrl(Uri.parse(url)); final request = await client.getUrl(Uri.parse(url));
if (headers != null) { if (headers != null) {
headers.forEach((k, v) => request.headers.add(k, v)); headers.forEach((k, v) => request.headers.add(k, v));
} }
final response = await request.close(); final response = await request.close();
if (response.statusCode == 200) { if (response.statusCode == 200) {
final body = await response.transform(utf8.decoder).join(); final body = await response.transform(utf8.decoder).join();
final json = jsonDecode(body); final json = jsonDecode(body);
// Google/Cloudflare JSON format // Google/Cloudflare JSON format
if (json['Status'] == 0 && json['Answer'] != null) { if (json['Status'] == 0 && json['Answer'] != null) {
final answers = json['Answer'] as List; final answers = json['Answer'] as List;
for (final ans in answers) { for (final ans in answers) {
if (ans['type'] == 1) { // Type A record if (ans['type'] == 1) {
return ans['data']; // Type A record
} return ans['data'];
}
} }
} }
} }
+6 -7
View File
@@ -23,12 +23,10 @@ class DnsResolver {
); );
// Fallback to Cloudflare via IP (1.1.1.1) // Fallback to Cloudflare via IP (1.1.1.1)
if (ip == null) { ip ??= await _queryDoH(
ip = await _queryDoH( 'https://1.1.1.1/dns-query?name=$hostname&type=A',
'https://1.1.1.1/dns-query?name=$hostname&type=A', headers: {'accept': 'application/dns-json'},
headers: {'accept': 'application/dns-json'}, );
);
}
if (ip != null) { if (ip != null) {
print('DnsResolver: Resolved $hostname -> $ip'); print('DnsResolver: Resolved $hostname -> $ip');
@@ -40,7 +38,8 @@ class DnsResolver {
return ip; return ip;
} }
static Future<String?> _queryDoH(String url, {Map<String, String>? headers}) async { static Future<String?> _queryDoH(String url,
{Map<String, String>? headers}) async {
try { try {
final client = HttpClient() final client = HttpClient()
..connectionTimeout = const Duration(seconds: 10) ..connectionTimeout = const Duration(seconds: 10)
+9 -10
View File
@@ -8,7 +8,7 @@ import '../models/playlist_config.dart';
class HiveService { class HiveService {
static const String _usersBoxName = 'users'; static const String _usersBoxName = 'users';
static const String _playlistsBoxName = 'playlists'; static const String _playlistsBoxName = 'playlists';
static bool _initialized = false; static bool _initialized = false;
static List<int>? _encryptionKey; static List<int>? _encryptionKey;
@@ -45,27 +45,27 @@ class HiveService {
// Generate new 256-bit key // Generate new 256-bit key
_encryptionKey = Hive.generateSecureKey(); _encryptionKey = Hive.generateSecureKey();
return _encryptionKey!; return _encryptionKey!;
} }
/// Seed default admin user (admin/admin) /// Seed default admin user (admin/admin)
static Future<void> _seedDefaultAdmin() async { static Future<void> _seedDefaultAdmin() async {
final usersBox = Hive.box<AppUser>(_usersBoxName); final usersBox = Hive.box<AppUser>(_usersBoxName);
if (usersBox.isEmpty) { if (usersBox.isEmpty) {
final adminId = const Uuid().v4(); final adminId = const Uuid().v4();
final passwordHash = _hashPassword('admin'); final passwordHash = _hashPassword('admin');
final admin = AppUser( final admin = AppUser(
id: adminId, id: adminId,
username: 'admin', username: 'admin',
passwordHash: passwordHash, passwordHash: passwordHash,
isAdmin: true, isAdmin: true,
assignedPlaylistIds: [], assignedPlaylistIds: const [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
await usersBox.put(adminId, admin); await usersBox.put(adminId, admin);
} }
} }
@@ -88,12 +88,12 @@ class HiveService {
final legacyHash = sha256.convert(utf8.encode(password)).toString(); final legacyHash = sha256.convert(utf8.encode(password)).toString();
return legacyHash == storedHash; return legacyHash == storedHash;
} }
final salt = parts[0]; final salt = parts[0];
final expectedHash = parts[1]; final expectedHash = parts[1];
final bytes = utf8.encode(password + salt); final bytes = utf8.encode(password + salt);
final actualHash = sha256.convert(bytes).toString(); final actualHash = sha256.convert(bytes).toString();
return actualHash == expectedHash; return actualHash == expectedHash;
} catch (e) { } catch (e) {
return false; return false;
@@ -103,12 +103,11 @@ class HiveService {
/// Public method to hash passwords (used by auth) /// Public method to hash passwords (used by auth)
static String hashPassword(String password) => _hashPassword(password); static String hashPassword(String password) => _hashPassword(password);
/// Get users box /// Get users box
static Box<AppUser> get usersBox => Hive.box<AppUser>(_usersBoxName); static Box<AppUser> get usersBox => Hive.box<AppUser>(_usersBoxName);
/// Get playlists box /// Get playlists box
static Box<PlaylistConfig> get playlistsBox => static Box<PlaylistConfig> get playlistsBox =>
Hive.box<PlaylistConfig>(_playlistsBoxName); Hive.box<PlaylistConfig>(_playlistsBoxName);
/// Close all boxes (cleanup) /// Close all boxes (cleanup)
+41 -68
View File
@@ -1,30 +1,18 @@
import '../api/api_client.dart'; import 'package:uuid/uuid.dart';
import '../database/hive_service.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
/// Service for managing playlists via API /// Service for managing playlists via local storage (Hive)
class PlaylistApiService { class PlaylistApiService {
final ApiClient _api = ApiClient();
/// Get all playlists for current user /// Get all playlists for current user
Future<List<PlaylistConfig>> getPlaylists() async { Future<List<PlaylistConfig>> getPlaylists() async {
try { try {
final response = await _api.get('/api/playlists'); if (!HiveService.playlistsBox.isOpen) {
final data = response.data as Map<String, dynamic>; await HiveService.init();
final playlistsData = data['playlists'] as List<dynamic>; }
return HiveService.playlistsBox.values.toList();
return playlistsData.map((p) {
final playlist = p as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}).toList();
} catch (e) { } catch (e) {
print('Error fetching playlists: $e'); print('Error fetching playlists from Hive: $e');
return []; return [];
} }
} }
@@ -37,30 +25,20 @@ class PlaylistApiService {
required String password, required String password,
}) async { }) async {
try { try {
final response = await _api.post('/api/playlists', data: { final id = const Uuid().v4();
'name': name, final playlist = PlaylistConfig(
'serverUrl': dns, id: id,
'username': username, name: name,
'password': password, dns: dns,
'dns': dns, username: username,
},); password: password,
createdAt: DateTime.now(),
);
final data = response.data as Map<String, dynamic>; await HiveService.playlistsBox.put(id, playlist);
return playlist;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}
return null;
} catch (e) { } catch (e) {
print('Error creating playlist: $e'); print('Error creating playlist in Hive: $e');
return null; return null;
} }
} }
@@ -74,30 +52,26 @@ class PlaylistApiService {
required String password, required String password,
}) async { }) async {
try { try {
final response = await _api.put('/api/playlists/$id', data: { if (!HiveService.playlistsBox.containsKey(id)) {
'name': name, return null;
'serverUrl': dns,
'username': username,
'password': password,
'dns': dns,
},);
final data = response.data as Map<String, dynamic>;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
} }
return null;
final existing = HiveService.playlistsBox.get(id);
if (existing == null) return null;
final updated = PlaylistConfig(
id: id,
name: name,
dns: dns,
username: username,
password: password,
createdAt: existing.createdAt,
);
await HiveService.playlistsBox.put(id, updated);
return updated;
} catch (e) { } catch (e) {
print('Error updating playlist: $e'); print('Error updating playlist in Hive: $e');
return null; return null;
} }
} }
@@ -105,11 +79,10 @@ class PlaylistApiService {
/// Delete a playlist /// Delete a playlist
Future<bool> deletePlaylist(String id) async { Future<bool> deletePlaylist(String id) async {
try { try {
final response = await _api.delete('/api/playlists/$id'); await HiveService.playlistsBox.delete(id);
final data = response.data as Map<String, dynamic>; return true;
return data['success'] == true;
} catch (e) { } catch (e) {
print('Error deleting playlist: $e'); print('Error deleting playlist from Hive: $e');
return false; return false;
} }
} }
+27 -28
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// XtremFlow Apple TV Style Palette /// XtremFlow Apple TV Style Palette
/// ///
/// Ultra minimalist, focus-driven palette. /// Ultra minimalist, focus-driven palette.
/// - Background: Pure Black (#000000) /// - Background: Pure Black (#000000)
/// - Focus/Active: Pure White (#FFFFFF) /// - Focus/Active: Pure White (#FFFFFF)
@@ -13,20 +13,20 @@ class AppColors {
// ============ BACKGROUNDS ============ // ============ BACKGROUNDS ============
/// Pure black for that infinite depth look (OLED friendly) /// Pure black for that infinite depth look (OLED friendly)
static const Color background = Color(0xFF000000); static const Color background = Color(0xFF000000);
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey) /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
static const Color surface = Color(0xFF1C1C1E); static const Color surface = Color(0xFF1C1C1E);
/// Slightly lighter grey for secondary surfaces or hover states /// Slightly lighter grey for secondary surfaces or hover states
static const Color surfaceVariant = Color(0xFF3A3A3C); static const Color surfaceVariant = Color(0xFF3A3A3C);
/// Focused element background (often white in tvOS for text, or bright accent) /// Focused element background (often white in tvOS for text, or bright accent)
static const Color focusColor = Color(0xFFFFFFFF); static const Color focusColor = Color(0xFFFFFFFF);
// ============ ACCENTS ============ // ============ ACCENTS ============
/// Minimal white accent. In Apple TV, color is used sparingly. /// Minimal white accent. In Apple TV, color is used sparingly.
/// We keep a subtle blue only for specific indicators if needed. /// We keep a subtle blue only for specific indicators if needed.
static const Color primary = Color(0xFFFFFFFF); static const Color primary = Color(0xFFFFFFFF);
static const Color accent = Color(0xFFE5E5E5); static const Color accent = Color(0xFFE5E5E5);
/// Primary Gradient (Subtle silver/white glow) /// Primary Gradient (Subtle silver/white glow)
@@ -60,7 +60,7 @@ class AppColors {
// ============ BORDERS ============ // ============ BORDERS ============
// Borders are usually hidden until focused // Borders are usually hidden until focused
static const Color border = Color(0xFF1F1F1F); static const Color border = Color(0xFF1F1F1F);
static const Color focusBorder = Color(0xFFFFFFFF); static const Color focusBorder = Color(0xFFFFFFFF);
// ============ GLASSMORPHISM ============ // ============ GLASSMORPHISM ============
@@ -70,28 +70,27 @@ class AppColors {
// ============ THEME SCHEMES ============ // ============ THEME SCHEMES ============
static ColorScheme get darkColorScheme => const ColorScheme.dark( static ColorScheme get darkColorScheme => const ColorScheme.dark(
primary: primary, primary: primary,
secondary: accent, secondary: accent,
surface: surface, surface: surface,
background: background, error: error,
error: error, onPrimary: Colors
onPrimary: Colors.black, // White text on buttons -> Black text on White buttons .black, // White text on buttons -> Black text on White buttons
onSecondary: Colors.black, onSecondary: Colors.black,
onSurface: textPrimary, onSurface: textPrimary,
onError: Colors.white, onError: Colors.white,
brightness: Brightness.dark, brightness: Brightness.dark,
); );
static ColorScheme get lightColorScheme => const ColorScheme.light( static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: Colors.black, primary: Colors.black,
secondary: Colors.grey, secondary: Colors.grey,
surface: Color(0xFFF2F2F7), surface: Color(0xFFF2F2F7),
background: Colors.white, error: error,
error: error, onPrimary: Colors.white,
onPrimary: Colors.white, onSecondary: Colors.white,
onSecondary: Colors.white, onSurface: Colors.black,
onSurface: Colors.black, onError: Colors.white,
onError: Colors.white, brightness: Brightness.light,
brightness: Brightness.light, );
);
} }
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
class ThemedLoading extends StatelessWidget { class ThemedLoading extends StatelessWidget {
const ThemedLoading({super.key}); const ThemedLoading({super.key});
+269 -97
View File
@@ -87,9 +87,9 @@ class _AdminContentState extends ConsumerState<AdminContent>
Text( Text(
'Administration', 'Administration',
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(), const Spacer(),
// Custom Tab Bar Indicator // Custom Tab Bar Indicator
@@ -111,12 +111,13 @@ class _AdminContentState extends ConsumerState<AdminContent>
], ],
), ),
), ),
// Content // Content
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref physics:
const NeverScrollableScrollPhysics(), // Custom tabs handle ref
children: const [ children: const [
_PlaylistsTab(), _PlaylistsTab(),
_UsersTab(), _UsersTab(),
@@ -182,7 +183,11 @@ class _PlaylistsTab extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), Text('${playlistsAsync.asData?.value.length ?? 0} Playlists',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70)),
GradientButton( GradientButton(
label: 'Add Playlist', label: 'Add Playlist',
icon: Icons.add, icon: Icons.add,
@@ -194,16 +199,21 @@ class _PlaylistsTab extends ConsumerWidget {
Expanded( Expanded(
child: playlistsAsync.when( child: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), error: (error, stack) => Center(
child: Text('Error: $error',
style: const TextStyle(color: AppColors.error))),
data: (playlists) { data: (playlists) {
if (playlists.isEmpty) { if (playlists.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), Icon(Icons.playlist_remove,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium), Text('No playlists configured',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: () => _showPlaylistDialog(context, ref), onPressed: () => _showPlaylistDialog(context, ref),
@@ -215,7 +225,8 @@ class _PlaylistsTab extends ConsumerWidget {
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: playlists.length, itemCount: playlists.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -231,16 +242,25 @@ class _PlaylistsTab extends ConsumerWidget {
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Icon(Icons.playlist_play, color: AppColors.textPrimary), child: const Icon(Icons.playlist_play,
color: AppColors.textPrimary),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(playlist.name, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), Text(playlist.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), Text(playlist.dns,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60)),
], ],
), ),
), ),
@@ -249,13 +269,15 @@ class _PlaylistsTab extends ConsumerWidget {
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined, size: 20), icon: const Icon(Icons.edit_outlined, size: 20),
color: Colors.white70, color: Colors.white70,
onPressed: () => _showPlaylistDialog(context, ref, playlist), onPressed: () =>
_showPlaylistDialog(context, ref, playlist),
tooltip: 'Edit', tooltip: 'Edit',
), ),
IconButton( IconButton(
icon: const Icon(Icons.delete_outline, size: 20), icon: const Icon(Icons.delete_outline, size: 20),
color: AppColors.error, color: AppColors.error,
onPressed: () => _deletePlaylist(context, ref, playlist), onPressed: () =>
_deletePlaylist(context, ref, playlist),
tooltip: 'Delete', tooltip: 'Delete',
), ),
], ],
@@ -272,7 +294,8 @@ class _PlaylistsTab extends ConsumerWidget {
); );
} }
void _showPlaylistDialog(BuildContext context, WidgetRef ref, [PlaylistConfig? playlist]) { void _showPlaylistDialog(BuildContext context, WidgetRef ref,
[PlaylistConfig? playlist]) {
final nameController = TextEditingController(text: playlist?.name); final nameController = TextEditingController(text: playlist?.name);
final dnsController = TextEditingController(text: playlist?.dns); final dnsController = TextEditingController(text: playlist?.dns);
final usernameController = TextEditingController(text: playlist?.username); final usernameController = TextEditingController(text: playlist?.username);
@@ -284,40 +307,79 @@ class _PlaylistsTab extends ConsumerWidget {
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog( builder: (context, setState) => AlertDialog(
title: Text(playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist'), title: Text(playlist == null
? 'Ajouter une playlist'
: 'Modifier la playlist'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: 'Nom')), TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom')),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: dnsController, decoration: const InputDecoration(labelText: 'URL du serveur', hintText: 'http://server.com:8080')), TextField(
controller: dnsController,
decoration: const InputDecoration(
labelText: 'URL du serveur',
hintText: 'http://server.com:8080')),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Utilisateur')), TextField(
controller: usernameController,
decoration:
const InputDecoration(labelText: 'Utilisateur')),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true), TextField(
controller: passwordController,
decoration:
const InputDecoration(labelText: 'Mot de passe'),
obscureText: true),
], ],
), ),
), ),
actions: [ actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: isLoading ? null : () async { onPressed: isLoading
if (nameController.text.isEmpty || dnsController.text.isEmpty) { ? null
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); : () async {
return; if (nameController.text.isEmpty ||
} dnsController.text.isEmpty) {
setState(() => isLoading = true); ScaffoldMessenger.of(context).showSnackBar(
final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); const SnackBar(
if (playlist == null) { content: Text('Tous les champs sont requis')));
await service.createPlaylist(name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); return;
} else { }
await service.updatePlaylist(id: playlist.id, name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); setState(() => isLoading = true);
} final dns = dnsController.text
if (dialogContext.mounted) Navigator.pop(dialogContext); .trim()
ref.invalidate(playlistsProvider); .replaceAll(RegExp(r'/$'), '');
}, if (playlist == null) {
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sauvegarder'), await service.createPlaylist(
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text);
} else {
await service.updatePlaylist(
id: playlist.id,
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text);
}
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(playlistsProvider);
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Sauvegarder'),
), ),
], ],
), ),
@@ -325,14 +387,17 @@ class _PlaylistsTab extends ConsumerWidget {
); );
} }
void _deletePlaylist(BuildContext context, WidgetRef ref, PlaylistConfig playlist) { void _deletePlaylist(
BuildContext context, WidgetRef ref, PlaylistConfig playlist) {
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer la playlist'), title: const Text('Supprimer la playlist'),
content: Text('Supprimer "${playlist.name}" ?'), content: Text('Supprimer "${playlist.name}" ?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: () async { onPressed: () async {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
@@ -363,7 +428,11 @@ class _UsersTab extends ConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), Text('${usersAsync.asData?.value.length ?? 0} Users',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70)),
GradientButton( GradientButton(
label: 'Create User', label: 'Create User',
icon: Icons.person_add, icon: Icons.person_add,
@@ -375,23 +444,29 @@ class _UsersTab extends ConsumerWidget {
Expanded( Expanded(
child: usersAsync.when( child: usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), error: (error, stack) => Center(
child: Text('Error: $error',
style: const TextStyle(color: AppColors.error))),
data: (users) { data: (users) {
if (users.isEmpty) { if (users.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), Icon(Icons.people_outline,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No users found', style: Theme.of(context).textTheme.titleMedium), Text('No users found',
style: Theme.of(context).textTheme.titleMedium),
], ],
), ),
); );
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: users.length, itemCount: users.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -404,12 +479,18 @@ class _UsersTab extends ConsumerWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: user.isAdmin ? AppColors.focusColor : AppColors.surface, color: user.isAdmin
? AppColors.focusColor
: AppColors.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
user.isAdmin ? Icons.admin_panel_settings : Icons.person, user.isAdmin
color: user.isAdmin ? Colors.black : AppColors.textPrimary, ? Icons.admin_panel_settings
: Icons.person,
color: user.isAdmin
? Colors.black
: AppColors.textPrimary,
size: 24, size: 24,
), ),
), ),
@@ -420,38 +501,72 @@ class _UsersTab extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text(user.username, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), Text(user.username,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white)),
if (user.isAdmin) ...[ if (user.isAdmin) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.focusColor.withOpacity(0.2), color: AppColors.focusColor
.withOpacity(0.2),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
), ),
child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)), child: const Text('ADMIN',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: AppColors.focusColor)),
), ),
], ],
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), Text('ID: ${user.id}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60)),
], ],
), ),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white70), icon: const Icon(Icons.more_vert,
color: Colors.white70),
color: AppColors.surface, color: AppColors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
onSelected: (value) { onSelected: (value) {
switch (value) { switch (value) {
case 'password': _showChangePasswordDialog(context, ref, user); break; case 'password':
case 'delete': _deleteUser(context, ref, user); break; _showChangePasswordDialog(context, ref, user);
break;
case 'delete':
_deleteUser(context, ref, user);
break;
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])), const PopupMenuItem(
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])), value: 'password',
child: Row(children: [
Icon(Icons.lock_reset, size: 18),
SizedBox(width: 8),
Text('Change Password')
])),
const PopupMenuItem(
value: 'delete',
child: Row(children: [
Icon(Icons.delete,
color: AppColors.error, size: 18),
SizedBox(width: 8),
Text('Delete',
style: TextStyle(color: AppColors.error))
])),
], ],
), ),
], ],
@@ -480,9 +595,18 @@ class _UsersTab extends ConsumerWidget {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Nom d\'utilisateur', prefixIcon: Icon(Icons.person_outline))), TextField(
controller: usernameController,
decoration: const InputDecoration(
labelText: 'Nom d\'utilisateur',
prefixIcon: Icon(Icons.person_outline))),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline)),
obscureText: true),
const SizedBox(height: 16), const SizedBox(height: 16),
SwitchListTile( SwitchListTile(
title: const Text('Administrateur'), title: const Text('Administrateur'),
@@ -493,22 +617,40 @@ class _UsersTab extends ConsumerWidget {
], ],
), ),
actions: [ actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: isLoading ? null : () async { onPressed: isLoading
if (usernameController.text.isEmpty || passwordController.text.isEmpty) { ? null
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); : () async {
return; if (usernameController.text.isEmpty ||
} passwordController.text.isEmpty) {
setState(() => isLoading = true); ScaffoldMessenger.of(context).showSnackBar(
final result = await UserApiService().createUser(username: usernameController.text.trim(), password: passwordController.text, isAdmin: isAdmin); const SnackBar(
if (dialogContext.mounted) Navigator.pop(dialogContext); content: Text('Tous les champs sont requis')));
ref.invalidate(usersProvider); return;
if (!result.success && context.mounted) { }
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); setState(() => isLoading = true);
} final result = await UserApiService().createUser(
}, username: usernameController.text.trim(),
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Créer'), password: passwordController.text,
isAdmin: isAdmin);
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(usersProvider);
if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
}
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Créer'),
), ),
], ],
), ),
@@ -516,7 +658,8 @@ class _UsersTab extends ConsumerWidget {
); );
} }
void _showChangePasswordDialog(BuildContext context, WidgetRef ref, AppUser user) { void _showChangePasswordDialog(
BuildContext context, WidgetRef ref, AppUser user) {
final passwordController = TextEditingController(); final passwordController = TextEditingController();
bool isLoading = false; bool isLoading = false;
@@ -525,25 +668,49 @@ class _UsersTab extends ConsumerWidget {
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog( builder: (context, setState) => AlertDialog(
title: Text('Changer le mot de passe de ${user.username}'), title: Text('Changer le mot de passe de ${user.username}'),
content: TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Nouveau mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), content: TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Nouveau mot de passe',
prefixIcon: Icon(Icons.lock_outline)),
obscureText: true),
actions: [ actions: [
TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed:
isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: isLoading ? null : () async { onPressed: isLoading
if (passwordController.text.isEmpty) { ? null
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mot de passe requis'))); : () async {
return; if (passwordController.text.isEmpty) {
} ScaffoldMessenger.of(context).showSnackBar(
setState(() => isLoading = true); const SnackBar(
final result = await UserApiService().updatePassword(userId: user.id, newPassword: passwordController.text); content: Text('Mot de passe requis')));
if (dialogContext.mounted) Navigator.pop(dialogContext); return;
if (result.success && context.mounted) { }
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Mot de passe modifié'), backgroundColor: AppColors.success)); setState(() => isLoading = true);
} else if (context.mounted) { final result = await UserApiService().updatePassword(
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); userId: user.id,
} newPassword: passwordController.text);
}, if (dialogContext.mounted) Navigator.pop(dialogContext);
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Changer'), if (result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe modifié'),
backgroundColor: AppColors.success));
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
}
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Changer'),
), ),
], ],
), ),
@@ -556,16 +723,21 @@ class _UsersTab extends ConsumerWidget {
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer l\'utilisateur'), title: const Text('Supprimer l\'utilisateur'),
content: Text('Supprimer "${user.username}" ? Cette action est irréversible.'), content: Text(
'Supprimer "${user.username}" ? Cette action est irréversible.'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler')),
FilledButton( FilledButton(
onPressed: () async { onPressed: () async {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
final result = await UserApiService().deleteUser(user.id); final result = await UserApiService().deleteUser(user.id);
ref.invalidate(usersProvider); ref.invalidate(usersProvider);
if (!result.success && context.mounted) { if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error));
} }
}, },
style: FilledButton.styleFrom(backgroundColor: AppColors.error), style: FilledButton.styleFrom(backgroundColor: AppColors.error),
+28 -16
View File
@@ -67,7 +67,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
), ),
), ),
), ),
// Main content // Main content
Center( Center(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -132,9 +132,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
children: [ children: [
Text( Text(
'Welcome Back', 'Welcome Back',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context)
fontWeight: FontWeight.bold, .textTheme
), .headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -143,21 +146,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Username', labelText: 'Username',
labelStyle: const TextStyle(color: Colors.white70), labelStyle:
prefixIcon: const Icon(Icons.person_outline, color: Colors.white70), const TextStyle(color: Colors.white70),
prefixIcon: const Icon(Icons.person_outline,
color: Colors.white70),
filled: true, filled: true,
fillColor: Colors.white.withOpacity(0.05), fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary), borderSide: const BorderSide(
color: AppColors.primary),
), ),
), ),
validator: (value) { validator: (value) {
@@ -176,21 +184,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Password', labelText: 'Password',
labelStyle: const TextStyle(color: Colors.white70), labelStyle:
prefixIcon: const Icon(Icons.lock_outline, color: Colors.white70), const TextStyle(color: Colors.white70),
prefixIcon: const Icon(Icons.lock_outline,
color: Colors.white70),
filled: true, filled: true,
fillColor: Colors.white.withOpacity(0.05), fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), borderSide: BorderSide(
color: Colors.white.withOpacity(0.1)),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary), borderSide: const BorderSide(
color: AppColors.primary),
), ),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
@@ -228,7 +241,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.error_outline, Icons.error_outline,
color: AppColors.error, color: AppColors.error,
size: 20, size: 20,
@@ -277,6 +290,5 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
], ],
), ),
); );
} }
} }
+45 -23
View File
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/digital_clock.dart'; import '../../../core/widgets/digital_clock.dart';
import '../widgets/live_tv_tab.dart'; import '../widgets/live_tv_tab.dart';
import '../widgets/movies_tab.dart'; import '../widgets/movies_tab.dart';
@@ -11,7 +9,7 @@ import '../widgets/series_tab.dart';
import '../widgets/settings_tab.dart'; import '../widgets/settings_tab.dart';
/// Apple TV Style Dashboard /// Apple TV Style Dashboard
/// ///
/// Uses a floating Top Bar for navigation instead of a sidebar. /// Uses a floating Top Bar for navigation instead of a sidebar.
/// Content flows underneath the top bar. /// Content flows underneath the top bar.
class DashboardScreen extends ConsumerStatefulWidget { class DashboardScreen extends ConsumerStatefulWidget {
@@ -26,11 +24,17 @@ class DashboardScreen extends ConsumerStatefulWidget {
ConsumerState<DashboardScreen> createState() => _DashboardScreenState(); ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
} }
class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTickerProviderStateMixin { class _DashboardScreenState extends ConsumerState<DashboardScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
final List<String> _tabs = ['Live TV', 'Films', 'Séries', 'Réglages']; final List<String> _tabs = ['Live TV', 'Films', 'Séries', 'Réglages'];
final List<IconData> _icons = [Icons.live_tv, Icons.movie, Icons.tv, Icons.settings]; final List<IconData> _icons = [
Icons.live_tv,
Icons.movie,
Icons.tv,
Icons.settings
];
@override @override
void initState() { void initState() {
@@ -74,13 +78,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
child: _buildLogo(), child: _buildLogo(),
), ),
), ),
// 2. Centered Navigation Tabs (Expanded to fill space, then Centered) // 2. Centered Navigation Tabs (Expanded to fill space, then Centered)
Expanded( Expanded(
child: Center( child: Center(
child: Container( child: Container(
height: 50, height: 50,
constraints: const BoxConstraints(maxWidth: 600), // Prevent taking too much space constraints: const BoxConstraints(
maxWidth: 600), // Prevent taking too much space
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(30),
@@ -97,8 +102,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.all(4), indicatorPadding: const EdgeInsets.all(4),
labelColor: Colors.black, labelColor: Colors.black,
unselectedLabelColor: Colors.white70, // Fixed: Improved readability unselectedLabelColor:
labelStyle: GoogleFonts.inter(fontWeight: FontWeight.w700, fontSize: 13), Colors.white70, // Fixed: Improved readability
labelStyle: GoogleFonts.inter(
fontWeight: FontWeight.w700, fontSize: 13),
overlayColor: WidgetStateProperty.all(Colors.transparent), overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: List.generate(_tabs.length, (index) { tabs: List.generate(_tabs.length, (index) {
return Tab( return Tab(
@@ -118,7 +125,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
), ),
), ),
), ),
// 3. Search / Profile (Fixed Width for centering balance) // 3. Search / Profile (Fixed Width for centering balance)
SizedBox( SizedBox(
width: 200, width: 200,
@@ -130,7 +137,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
IconButton( IconButton(
icon: const Icon(Icons.search, color: Colors.white), icon: const Icon(Icons.search, color: Colors.white),
onPressed: () { onPressed: () {
// Global search trigger // Global search trigger
}, },
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -139,7 +146,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
const CircleAvatar( const CircleAvatar(
radius: 16, radius: 16,
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
child: Icon(Icons.person, size: 18, color: Colors.white), child:
Icon(Icons.person, size: 18, color: Colors.white),
), ),
], ],
), ),
@@ -165,17 +173,25 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
), ),
), ),
), ),
// Content // Content
TabBarView( TabBarView(
controller: _tabController, controller: _tabController,
physics: const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts physics:
const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts
children: [ children: [
// Padding top to account for the transparent AppBar // Padding top to account for the transparent AppBar
Padding(padding: const EdgeInsets.only(top: 80), child: LiveTVTab(playlist: widget.playlist)), Padding(
Padding(padding: const EdgeInsets.only(top: 80), child: MoviesTab(playlist: widget.playlist)), padding: const EdgeInsets.only(top: 80),
Padding(padding: const EdgeInsets.only(top: 80), child: SeriesTab(playlist: widget.playlist)), child: LiveTVTab(playlist: widget.playlist)),
const Padding(padding: EdgeInsets.only(top: 80), child: SettingsTab()), Padding(
padding: const EdgeInsets.only(top: 80),
child: MoviesTab(playlist: widget.playlist)),
Padding(
padding: const EdgeInsets.only(top: 80),
child: SeriesTab(playlist: widget.playlist)),
const Padding(
padding: EdgeInsets.only(top: 80), child: SettingsTab()),
], ],
), ),
], ],
@@ -190,7 +206,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
width: 36, width: 36,
height: 36, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF43cea2), Color(0xFF185a9d)]), // Green to Blue gradient: const LinearGradient(colors: [
Color(0xFF43cea2),
Color(0xFF185a9d)
]), // Green to Blue
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white), child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
@@ -202,13 +221,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
children: [ children: [
Text( Text(
'Xtrem', 'Xtrem',
style: GoogleFonts.inter(fontWeight: FontWeight.w800, fontSize: 16, color: Colors.white), style: GoogleFonts.inter(
fontWeight: FontWeight.w800,
fontSize: 16,
color: Colors.white),
), ),
Text( Text(
'Flow', 'Flow',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
fontSize: 16, fontSize: 16,
color: Colors.white.withOpacity(0.8), color: Colors.white.withOpacity(0.8),
height: 0.8, height: 0.8,
), ),
File diff suppressed because it is too large. Load diff
@@ -26,14 +26,16 @@ class PlaylistSelectionScreen extends ConsumerWidget {
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
'Select Playlist', 'Select Playlist',
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white),
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
actions: [ actions: [
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false)
IconButton( IconButton(
icon: const Icon(Icons.admin_panel_settings, color: Colors.white70), icon:
const Icon(Icons.admin_panel_settings, color: Colors.white70),
onPressed: () => context.go('/admin'), onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel', tooltip: 'Admin Panel',
), ),
@@ -69,7 +71,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
children: [ children: [
Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), Icon(Icons.error_outline, size: 64, color: Colors.red.shade400),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.roboto(fontSize: 18, color: Colors.white70)), Text('Error loading playlists',
style: GoogleFonts.roboto(
fontSize: 18, color: Colors.white70)),
const SizedBox(height: 8), const SizedBox(height: 8),
ElevatedButton( ElevatedButton(
onPressed: () => ref.refresh(playlistsProvider), onPressed: () => ref.refresh(playlistsProvider),
@@ -84,7 +88,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.playlist_remove, Icons.playlist_remove,
size: 64, size: 64,
color: Colors.white24, color: Colors.white24,
@@ -113,7 +117,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
} }
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.fromLTRB(24, 100, 24, 24), // Top padding for transparent AppBar padding: const EdgeInsets.fromLTRB(
24, 100, 24, 24), // Top padding for transparent AppBar
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 3,
crossAxisSpacing: 24, crossAxisSpacing: 24,
@@ -165,23 +170,25 @@ class _PlaylistCardState extends State<_PlaylistCard> {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isHovered color: _isHovered
? Colors.white.withOpacity(0.15) ? Colors.white.withOpacity(0.15)
: Colors.white.withOpacity(0.05), // Glass effect : Colors.white.withOpacity(0.05), // Glass effect
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: _isHovered color: _isHovered
? Colors.white.withOpacity(0.5) ? Colors.white.withOpacity(0.5)
: Colors.white.withOpacity(0.1), : Colors.white.withOpacity(0.1),
width: 1.5, width: 1.5,
), ),
boxShadow: _isHovered ? [ boxShadow: _isHovered
BoxShadow( ? [
color: Colors.black.withOpacity(0.3), BoxShadow(
blurRadius: 20, color: Colors.black.withOpacity(0.3),
offset: const Offset(0, 10), blurRadius: 20,
), offset: const Offset(0, 10),
] : [], ),
]
: [],
), ),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@@ -204,7 +211,8 @@ class _PlaylistCardState extends State<_PlaylistCard> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
widget.playlist.name, widget.playlist.name,
style: GoogleFonts.outfit( // Using Outfit for modern look style: GoogleFonts.outfit(
// Using Outfit for modern look
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,
@@ -229,4 +237,3 @@ class _PlaylistCardState extends State<_PlaylistCard> {
); );
} }
} }
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
/// Horizontal carousel for displaying channels by category /// Horizontal carousel for displaying channels by category
/// ///
/// Netflix-style horizontal scrolling list with: /// Netflix-style horizontal scrolling list with:
/// - Category title with count /// - Category title with count
/// - Smooth horizontal scrolling /// - Smooth horizontal scrolling
@@ -36,7 +36,7 @@ class CategoryCarousel<T> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
if (items.isEmpty) { if (items.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -110,15 +110,15 @@ class CategoryCarousel<T> extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Horizontal scrolling list with fade edges // Horizontal scrolling list with fade edges
SizedBox( SizedBox(
height: itemHeight, height: itemHeight,
child: ShaderMask( child: ShaderMask(
shaderCallback: (Rect bounds) { shaderCallback: (Rect bounds) {
return LinearGradient( return const LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.centerLeft,
end: Alignment.centerRight, end: Alignment.centerRight,
colors: [ colors: [
@@ -127,7 +127,7 @@ class CategoryCarousel<T> extends StatelessWidget {
Colors.white, Colors.white,
Colors.transparent, Colors.transparent,
], ],
stops: const [0.0, 0.02, 0.98, 1.0], stops: [0.0, 0.02, 0.98, 1.0],
).createShader(bounds); ).createShader(bounds);
}, },
blendMode: BlendMode.dstIn, blendMode: BlendMode.dstIn,
@@ -173,7 +173,7 @@ class CategoryChipButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
@@ -181,14 +181,10 @@ class CategoryChipButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: isSelected ? AppColors.primaryGradient : null, gradient: isSelected ? AppColors.primaryGradient : null,
color: isSelected color: isSelected ? null : colorScheme.surfaceContainerHighest,
? null
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: isSelected color: isSelected ? Colors.transparent : colorScheme.outlineVariant,
? Colors.transparent
: colorScheme.outlineVariant,
), ),
), ),
child: Row( child: Row(
@@ -209,9 +205,8 @@ class CategoryChipButton extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isSelected color:
? colorScheme.onPrimary isSelected ? colorScheme.onPrimary : colorScheme.onSurface,
: colorScheme.onSurface,
), ),
), ),
], ],
+58 -56
View File
@@ -1,10 +1,8 @@
import 'dart:async';
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';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
/// Minimal EPG overlay - shows program title and info /// Minimal EPG overlay - shows program title and info
class EpgOverlay extends ConsumerWidget { class EpgOverlay extends ConsumerWidget {
final String streamId; final String streamId;
@@ -18,9 +16,11 @@ class EpgOverlay extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final epgAsync = ref.watch(epgByPlaylistProvider( final epgAsync = ref.watch(
EpgRequestKey(playlist: playlist, streamId: streamId), epgByPlaylistProvider(
),); EpgRequestKey(playlist: playlist, streamId: streamId),
),
);
return epgAsync.when( return epgAsync.when(
data: (epgEntries) { data: (epgEntries) {
@@ -41,67 +41,69 @@ class EpgOverlay extends ConsumerWidget {
// Enhanced info display // Enhanced info display
return Align( return Align(
alignment: Alignment.bottomLeft, // Align to bottom left near controls alignment: Alignment.bottomLeft, // Align to bottom left near controls
child: Container( child: Container(
margin: const EdgeInsets.fromLTRB(24, 0, 24, 24), margin: const EdgeInsets.fromLTRB(24, 0, 24, 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), // Semi-transparent backing for readability color: Colors.black
borderRadius: BorderRadius.circular(12), .withOpacity(0.5), // Semi-transparent backing for readability
border: Border.all(color: Colors.white.withOpacity(0.1)), borderRadius: BorderRadius.circular(12),
), border: Border.all(color: Colors.white.withOpacity(0.1)),
child: Row( ),
mainAxisSize: MainAxisSize.min, child: Row(
children: [ mainAxisSize: MainAxisSize.min,
Container( children: [
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), Container(
decoration: BoxDecoration( padding:
color: Colors.red, const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
borderRadius: BorderRadius.circular(4), decoration: BoxDecoration(
), color: Colors.red,
child: const Text( borderRadius: BorderRadius.circular(4),
'LIVE', ),
style: TextStyle( child: const Text(
color: Colors.white, 'LIVE',
fontSize: 12, // Larger style: TextStyle(
fontWeight: FontWeight.bold, color: Colors.white,
), fontSize: 12, // Larger
fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(width: 12), ),
ConstrainedBox( const SizedBox(width: 12),
constraints: const BoxConstraints(maxWidth: 500), // Wider ConstrainedBox(
child: Column( constraints: const BoxConstraints(maxWidth: 500), // Wider
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentProgram.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18, // Much Larger
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (currentProgram.description.isNotEmpty)
Text( Text(
currentProgram.title, currentProgram.description,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white70,
fontSize: 18, // Much Larger fontSize: 13, // Larger desc
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (currentProgram.description.isNotEmpty) ],
Text(
currentProgram.description,
style: const TextStyle(
color: Colors.white70,
fontSize: 13, // Larger desc
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
), ),
], ),
), ],
), ),
),
); );
}, },
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
+95 -68
View File
@@ -1,6 +1,5 @@
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 'package:google_fonts/google_fonts.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/favorites_provider.dart'; import '../providers/favorites_provider.dart';
@@ -10,7 +9,6 @@ import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart'; import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../../../core/widgets/themed_loading_screen.dart'; import '../../../core/widgets/themed_loading_screen.dart';
import 'channel_card.dart'; import 'channel_card.dart';
@@ -26,16 +24,15 @@ class LiveTVTab extends ConsumerStatefulWidget {
class _LiveTVTabState extends ConsumerState<LiveTVTab> class _LiveTVTabState extends ConsumerState<LiveTVTab>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
// State // State
String? _selectedCategory; String? _selectedCategory;
// UI State // UI State
bool _isGridView = true; bool _isGridView = true;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _showFavoritesOnly = false; bool _showFavoritesOnly = false;
String _searchQuery = ''; String _searchQuery = '';
// Scroll Controllers // Scroll Controllers
final ScrollController _mainScrollController = ScrollController(); final ScrollController _mainScrollController = ScrollController();
@@ -63,7 +60,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final channelsAsync =
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider); final favorites = ref.watch(favoritesProvider);
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
@@ -73,34 +71,38 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
loading: () => const ThemedLoading(), loading: () => const ThemedLoading(),
error: (e, s) => Center(child: Text('Error: $e')), error: (e, s) => Center(child: Text('Error: $e')),
data: (groupedChannels) { data: (groupedChannels) {
// Groups // Groups
var categories = groupedChannels.keys.toList(); var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) { if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); categories = categories
} .where((cat) => settings.matchesLiveTvFilter(cat))
categories.sort(); .toList();
}
// Filter Logic categories.sort();
List<Channel> displayedChannels = [];
bool showingCategoryGrid = false; // Filter Logic
List<Channel> displayedChannels = [];
if (_searchQuery.isNotEmpty) { bool showingCategoryGrid = false;
// Global Search
displayedChannels = groupedChannels.values.expand((l) => l) if (_searchQuery.isNotEmpty) {
.where((c) => c.name.toLowerCase().contains(_searchQuery)) // Global Search
.toList(); displayedChannels = groupedChannels.values
} else if (_showFavoritesOnly) { .expand((l) => l)
// Favorites .where((c) => c.name.toLowerCase().contains(_searchQuery))
displayedChannels = groupedChannels.values.expand((l) => l) .toList();
.where((c) => favorites.contains(c.streamId)) } else if (_showFavoritesOnly) {
.toList(); // Favorites
} else if (_selectedCategory != null) { displayedChannels = groupedChannels.values
// Selected Category View .expand((l) => l)
displayedChannels = groupedChannels[_selectedCategory] ?? []; .where((c) => favorites.contains(c.streamId))
} else { .toList();
// Category Grid View (Default) } else if (_selectedCategory != null) {
showingCategoryGrid = true; // Selected Category View
} displayedChannels = groupedChannels[_selectedCategory] ?? [];
} else {
// Category Grid View (Default)
showingCategoryGrid = true;
}
return Column( return Column(
children: [ children: [
@@ -110,9 +112,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Row( child: Row(
children: [ children: [
// Back Button (if in category view and not searching) // Back Button (if in category view and not searching)
if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[ if (_selectedCategory != null &&
_searchQuery.isEmpty &&
!_showFavoritesOnly) ...[
IconButton( IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), icon: const Icon(Icons.arrow_back,
color: AppColors.textPrimary),
onPressed: () { onPressed: () {
setState(() { setState(() {
_selectedCategory = null; _selectedCategory = null;
@@ -125,34 +130,40 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
], ],
Text( Text(
_searchQuery.isNotEmpty ? 'Search Results' : _searchQuery.isNotEmpty
_showFavoritesOnly ? 'Favorites' : ? 'Search Results'
_selectedCategory ?? 'Live TV', : _showFavoritesOnly
style: Theme.of(context).textTheme.headlineMedium?.copyWith( ? 'Favorites'
color: Colors.white, : _selectedCategory ?? 'Live TV',
fontWeight: FontWeight.bold, style:
), Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
), ),
const Spacer(), const Spacer(),
// Search Pill // Search Pill
Container( Container(
width: 300, width: 300,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull), borderRadius:
BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border), border: Border.all(color: AppColors.border),
), ),
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary), const Icon(Icons.search,
size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: const TextStyle(fontSize: 14, color: Colors.white), style: const TextStyle(
fontSize: 14, color: Colors.white),
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Rechercher...', hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54), hintStyle: TextStyle(color: Colors.white54),
@@ -163,31 +174,37 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
), ),
), ),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () => _searchController.clear(), onTap: () => _searchController.clear(),
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), child: const Icon(Icons.close,
), size: 16, color: AppColors.textSecondary),
),
], ],
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
// Favorites Toggle // Favorites Toggle
IconButton( IconButton(
icon: Icon( icon: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border, _showFavoritesOnly
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary, ? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.error
: AppColors.textSecondary,
), ),
onPressed: () { onPressed: () {
setState(() { setState(() {
_showFavoritesOnly = !_showFavoritesOnly; _showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) _searchController.clear(); if (_showFavoritesOnly) _searchController.clear();
_selectedCategory = null; // Reset selection when toggling favorites _selectedCategory =
}); null; // Reset selection when toggling favorites
});
}, },
tooltip: 'Favorites Only', tooltip: 'Favorites Only',
), ),
// View Toggle (Only show when not in category grid) // View Toggle (Only show when not in category grid)
if (!showingCategoryGrid) if (!showingCategoryGrid)
IconButton( IconButton(
@@ -195,7 +212,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
_isGridView ? Icons.view_list : Icons.grid_view, _isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
onPressed: () => setState(() => _isGridView = !_isGridView), onPressed: () =>
setState(() => _isGridView = !_isGridView),
tooltip: 'Toggle View', tooltip: 'Toggle View',
), ),
], ],
@@ -209,9 +227,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
: displayedChannels.isEmpty : displayedChannels.isEmpty
? Center( ? Center(
child: Text( child: Text(
_searchQuery.isNotEmpty ? 'No channels found' : _searchQuery.isNotEmpty
_showFavoritesOnly ? 'No favorites' : 'No channels in this group', ? 'No channels found'
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary), : _showFavoritesOnly
? 'No favorites'
: 'No channels in this group',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: AppColors.textSecondary),
), ),
) )
: _isGridView : _isGridView
@@ -225,7 +249,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
); );
} }
Widget _buildCategoryGrid(List<String> categories, Map<String, List<Channel>> groupedChannels) { Widget _buildCategoryGrid(
List<String> categories, Map<String, List<Channel>> groupedChannels) {
final columns = ResponsiveLayout.value( final columns = ResponsiveLayout.value(
context, context,
mobile: 2, mobile: 2,
@@ -246,11 +271,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
itemBuilder: (context, index) { itemBuilder: (context, index) {
final category = categories[index]; final category = categories[index];
final count = groupedChannels[category]?.length ?? 0; final count = groupedChannels[category]?.length ?? 0;
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
setState(() { setState(() {
@@ -281,7 +307,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
color: AppColors.primary.withOpacity(0.1), color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.folder_open, color: AppColors.primary, size: 32), child: const Icon(Icons.folder_open,
color: AppColors.primary, size: 32),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
+52 -35
View File
@@ -61,7 +61,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
setState(() { setState(() {
_searchQuery = query; _searchQuery = query;
if (query.isEmpty) { if (query.isEmpty) {
@@ -69,7 +69,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
_isSearching = false; _isSearching = false;
} }
}); });
if (query.length >= 2) { if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () { _searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query); _performSearch(query);
@@ -79,13 +79,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Future<void> _performSearch(String query) async { Future<void> _performSearch(String query) async {
if (query.isEmpty) return; if (query.isEmpty) return;
setState(() => _isSearching = true); setState(() => _isSearching = true);
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchMovies(query); final results = await service.searchMovies(query);
if (mounted && _searchQuery == query) { if (mounted && _searchQuery == query) {
setState(() { setState(() {
_searchResults = results; _searchResults = results;
@@ -151,7 +151,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
void _playMovie(Movie movie) { void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -170,32 +170,39 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
final watchHistory = ref.watch(watchHistoryProvider); final watchHistory = ref.watch(watchHistoryProvider);
List<Movie> displayMovies; List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
displayMovies = _searchResults!; displayMovies = _searchResults!;
} else { } else {
displayMovies = settings.moviesKeywords.isEmpty displayMovies = settings.moviesKeywords.isEmpty
? _movies ? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); : _movies
.where((m) => settings.matchesMoviesFilter(m.categoryName))
.toList();
} }
// Hero Items (Take 5 random or first 5 from filtered list) // Hero Items (Take 5 random or first 5 from filtered list)
final heroItems = displayMovies.take(5).map((m) => HeroItem( final heroItems = displayMovies
id: m.streamId, .take(5)
title: m.name, .map(
imageUrl: _getProxiedImageUrl(m.streamIcon), (m) => HeroItem(
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, id: m.streamId,
onMoreInfo: () { title: m.name,
_playMovie(m); imageUrl: _getProxiedImageUrl(m.streamIcon),
}, subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
)).toList(); onMoreInfo: () {
_playMovie(m);
},
),
)
.toList();
if (_movies.isEmpty && !_isLoading) { if (_movies.isEmpty && !_isLoading) {
return const Center(child: Text('No movies available')); return const Center(child: Text('No movies available'));
} }
final double gridItemRatio = 0.65; const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value( final int crossAxisCount = ResponsiveLayout.value(
context, context,
mobile: 3, mobile: 3,
@@ -215,9 +222,9 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Text( Text(
'Movies', 'Movies',
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(), const Spacer(),
Container( Container(
@@ -231,12 +238,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary), const Icon(Icons.search,
size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: const TextStyle(fontSize: 14, color: Colors.white), style: const TextStyle(
fontSize: 14, color: Colors.white),
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Rechercher...', hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54), hintStyle: TextStyle(color: Colors.white54),
@@ -250,14 +259,18 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
), ),
), ),
if (_isSearching) if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
), ),
], ],
), ),
@@ -275,13 +288,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
final movie = _movies.firstWhere((element) => element.streamId == item.id); final movie = _movies
_playMovie(movie); .firstWhere((element) => element.streamId == item.id);
_playMovie(movie);
}, },
), ),
), ),
), ),
// Grid // Grid
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -297,17 +311,20 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
if (index >= displayMovies.length) return null; if (index >= displayMovies.length) return null;
final movie = displayMovies[index]; final movie = displayMovies[index];
final isWatched = watchHistory.isMovieWatched(movie.streamId); final isWatched = watchHistory.isMovieWatched(movie.streamId);
return MediaCard( return MediaCard(
title: movie.name, title: movie.name,
imageUrl: _getProxiedImageUrl(movie.streamIcon), imageUrl: _getProxiedImageUrl(movie.streamIcon),
subtitle: movie.rating != null
subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null, ? '${_formatRating(movie.rating)} ★'
: null,
rating: _formatRating(movie.rating), rating: _formatRating(movie.rating),
isWatched: isWatched, isWatched: isWatched,
onTap: () => _playMovie(movie), onTap: () => _playMovie(movie),
onLongPress: () { onLongPress: () {
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); ref
.read(watchHistoryProvider.notifier)
.toggleMovieWatched(movie.streamId);
}, },
); );
}, },
@@ -315,7 +332,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
), ),
), ),
), ),
// Loader // Loader
if (_isLoading) if (_isLoading)
const SliverToBoxAdapter( const SliverToBoxAdapter(
+45 -30
View File
@@ -61,7 +61,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
setState(() { setState(() {
_searchQuery = query; _searchQuery = query;
if (query.isEmpty) { if (query.isEmpty) {
@@ -69,7 +69,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
_isSearching = false; _isSearching = false;
} }
}); });
if (query.length >= 2) { if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () { _searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query); _performSearch(query);
@@ -79,13 +79,13 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
Future<void> _performSearch(String query) async { Future<void> _performSearch(String query) async {
if (query.isEmpty) return; if (query.isEmpty) return;
setState(() => _isSearching = true); setState(() => _isSearching = true);
try { try {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchSeries(query); final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) { if (mounted && _searchQuery == query) {
setState(() { setState(() {
_searchResults = results; _searchResults = results;
@@ -164,14 +164,16 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
List<Series> displaySeries; List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!; displaySeries = _searchResults!;
} else { } else {
displaySeries = settings.seriesKeywords.isEmpty displaySeries = settings.seriesKeywords.isEmpty
? _series ? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); : _series
.where((s) => settings.matchesSeriesFilter(s.categoryName))
.toList();
} }
if (_series.isEmpty && !_isLoading) { if (_series.isEmpty && !_isLoading) {
@@ -179,15 +181,20 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
} }
// Hero Items // Hero Items
final heroItems = displaySeries.take(5).map((s) => HeroItem( final heroItems = displaySeries
id: s.seriesId.toString(), // ID logic might differ for Series .take(5)
title: s.name, .map(
imageUrl: _getProxiedImageUrl(s.cover), (s) => HeroItem(
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, id: s.seriesId.toString(), // ID logic might differ for Series
onMoreInfo: () => _openSeries(s), title: s.name,
)).toList(); imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s),
),
)
.toList();
final double gridItemRatio = 0.65; const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value( final int crossAxisCount = ResponsiveLayout.value(
context, context,
mobile: 3, mobile: 3,
@@ -207,9 +214,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
Text( Text(
'TV Series', 'TV Series',
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(), const Spacer(),
Container( Container(
@@ -223,12 +230,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary), const Icon(Icons.search,
size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: const TextStyle(fontSize: 14, color: Colors.white), style: const TextStyle(
fontSize: 14, color: Colors.white),
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Rechercher...', hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54), hintStyle: TextStyle(color: Colors.white54),
@@ -242,14 +251,18 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
), ),
), ),
if (_isSearching) if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
), ),
], ],
), ),
@@ -267,13 +280,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
final series = _series.firstWhere((s) => s.name == item.title); // Fallback by title if ID mismatch final series = _series.firstWhere((s) =>
_openSeries(series); s.name == item.title); // Fallback by title if ID mismatch
_openSeries(series);
}, },
), ),
), ),
), ),
// Grid // Grid
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -291,8 +305,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
return MediaCard( return MediaCard(
title: serie.name, title: serie.name,
imageUrl: _getProxiedImageUrl(serie.cover), imageUrl: _getProxiedImageUrl(serie.cover),
subtitle: serie.rating != null
subtitle: serie.rating != null ? '${_formatRating(serie.rating)} ★' : null, ? '${_formatRating(serie.rating)} ★'
: null,
rating: _formatRating(serie.rating), rating: _formatRating(serie.rating),
placeholderIcon: Icons.tv, placeholderIcon: Icons.tv,
onTap: () => _openSeries(serie), onTap: () => _openSeries(serie),
@@ -302,7 +317,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
), ),
), ),
), ),
// Loader // Loader
if (_isLoading) if (_isLoading)
const SliverToBoxAdapter( const SliverToBoxAdapter(
+100 -83
View File
@@ -18,7 +18,8 @@ class SettingsTab extends ConsumerStatefulWidget {
ConsumerState<SettingsTab> createState() => _SettingsTabState(); ConsumerState<SettingsTab> createState() => _SettingsTabState();
} }
class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProviderStateMixin { class _SettingsTabState extends ConsumerState<SettingsTab>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late TextEditingController _liveTvController; late TextEditingController _liveTvController;
late TextEditingController _moviesController; late TextEditingController _moviesController;
@@ -45,13 +46,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
} }
void _syncControllers(IptvSettings settings) { void _syncControllers(IptvSettings settings) {
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) { if (!_initialized ||
_liveTvController.text != settings.liveTvCategoryFilter) {
_liveTvController.text = settings.liveTvCategoryFilter; _liveTvController.text = settings.liveTvCategoryFilter;
} }
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) { if (!_initialized ||
_moviesController.text != settings.moviesCategoryFilter) {
_moviesController.text = settings.moviesCategoryFilter; _moviesController.text = settings.moviesCategoryFilter;
} }
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) { if (!_initialized ||
_seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter; _seriesController.text = settings.seriesCategoryFilter;
} }
_initialized = true; _initialized = true;
@@ -61,7 +65,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser; final currentUser = ref.watch(authProvider).currentUser;
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
// Sync controllers with persisted state (only on first load) // Sync controllers with persisted state (only on first load)
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings); if (!_initialized) _syncControllers(settings);
@@ -85,17 +89,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
labelColor: Colors.white, labelColor: Colors.white,
unselectedLabelColor: Colors.white54, unselectedLabelColor: Colors.white54,
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
overlayColor: MaterialStateProperty.all(Colors.transparent), overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: [ tabs: [
const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'), const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'),
const Tab(icon: Icon(Icons.stream), text: 'Streaming'), const Tab(icon: Icon(Icons.stream), text: 'Streaming'),
const Tab(icon: Icon(Icons.palette), text: 'Apparence'), const Tab(icon: Icon(Icons.palette), text: 'Apparence'),
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false)
const Tab(icon: Icon(Icons.admin_panel_settings), text: 'Administration'), const Tab(
icon: Icon(Icons.admin_panel_settings),
text: 'Administration'),
], ],
), ),
), ),
// TabBarView // TabBarView
Expanded( Expanded(
child: TabBarView( child: TabBarView(
@@ -103,16 +109,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
children: [ children: [
// Filters Tab // Filters Tab
_buildFiltersTab(context, currentUser, settings), _buildFiltersTab(context, currentUser, settings),
// Streaming Tab // Streaming Tab
const StreamingSettingsTab(), const StreamingSettingsTab(),
// Appearance Tab // Appearance Tab
_buildAppearanceTab(context), _buildAppearanceTab(context),
// Admin Tab // Admin Tab
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false) const AdminContent(),
const AdminContent(),
], ],
), ),
), ),
@@ -125,7 +130,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
final themeState = ref.watch(themeProvider); final themeState = ref.watch(themeProvider);
final themeNotifier = ref.read(themeProvider.notifier); final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -142,7 +147,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
children: [ children: [
Row( Row(
children: [ children: [
Icon( const Icon(
Icons.dark_mode, Icons.dark_mode,
color: Colors.white, color: Colors.white,
), ),
@@ -158,7 +163,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Theme options // Theme options
_buildThemeOption( _buildThemeOption(
context: context, context: context,
@@ -189,9 +194,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Quick toggle card // Quick toggle card
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -206,20 +211,21 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
title: Text( title: Text(
'Mode actuel', 'Mode actuel',
style: GoogleFonts.inter(fontWeight: FontWeight.w500, color: Colors.white), style: GoogleFonts.inter(
fontWeight: FontWeight.w500, color: Colors.white),
), ),
subtitle: Text( subtitle: Text(
isDark ? 'Thème sombre actif' : 'Thème clair actif', isDark ? 'Thème sombre actif' : 'Thème clair actif',
style: TextStyle(color: Colors.white70), style: const TextStyle(color: Colors.white70),
), ),
trailing: Switch( trailing: Switch(
value: isDark, value: isDark,
onChanged: (_) => themeNotifier.toggleTheme(), onChanged: (_) => themeNotifier.toggleTheme(),
activeColor: AppColors.primary, activeThumbColor: AppColors.primary,
trackColor: MaterialStateProperty.resolveWith( trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected) (states) => states.contains(WidgetState.selected)
? AppColors.primary.withOpacity(0.5) ? AppColors.primary.withOpacity(0.5)
: Colors.grey.shade800, : Colors.grey.shade800,
), ),
), ),
), ),
@@ -238,7 +244,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -298,8 +304,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
); );
} }
Widget _buildFiltersTab(
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) { BuildContext context, dynamic currentUser, IptvSettings settings) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -319,18 +325,21 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
subtitle: Text( subtitle: Text(
currentUser?.username ?? 'Unknown', currentUser?.username ?? 'Unknown',
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), style: GoogleFonts.roboto(
fontWeight: FontWeight.w600, color: Colors.white),
), ),
trailing: currentUser?.isAdmin ?? false trailing: currentUser?.isAdmin ?? false
? Chip( ? Chip(
label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11, color: Colors.black)), label: Text('Admin',
style: GoogleFonts.roboto(
fontSize: 11, color: Colors.black)),
backgroundColor: Colors.white, backgroundColor: Colors.white,
) )
: null, : null,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Category filters section // Category filters section
Text( Text(
'Filtres de Catégories', 'Filtres de Catégories',
@@ -349,7 +358,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Live TV Filter // Live TV Filter
_buildFilterCard( _buildFilterCard(
title: 'Filtre TV Live', title: 'Filtre TV Live',
@@ -365,7 +374,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Movies Filter // Movies Filter
_buildFilterCard( _buildFilterCard(
title: 'Filtre Films', title: 'Filtre Films',
@@ -381,7 +390,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Series Filter // Series Filter
_buildFilterCard( _buildFilterCard(
title: 'Filtre Séries', title: 'Filtre Séries',
@@ -397,9 +406,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Change playlist // Change playlist
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -409,13 +416,14 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
child: ListTile( child: ListTile(
leading: const Icon(Icons.playlist_play, color: Colors.white), leading: const Icon(Icons.playlist_play, color: Colors.white),
title: Text('Changer de Playlist', style: GoogleFonts.roboto(color: Colors.white)), title: Text('Changer de Playlist',
style: GoogleFonts.roboto(color: Colors.white)),
trailing: const Icon(Icons.chevron_right, color: Colors.white54), trailing: const Icon(Icons.chevron_right, color: Colors.white54),
onTap: () => context.go('/playlists'), onTap: () => context.go('/playlists'),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// About // About
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -425,7 +433,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
child: ListTile( child: ListTile(
leading: const Icon(Icons.info_outline, color: Colors.white), leading: const Icon(Icons.info_outline, color: Colors.white),
title: Text('À propos', style: GoogleFonts.roboto(color: Colors.white)), title: Text('À propos',
style: GoogleFonts.roboto(color: Colors.white)),
subtitle: Text( subtitle: Text(
'XtremFlow IPTV v1.0.0', 'XtremFlow IPTV v1.0.0',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
@@ -433,7 +442,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Maintenance Section // Maintenance Section
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -443,50 +452,53 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
child: ListTile( child: ListTile(
leading: const Icon(Icons.cleaning_services, color: Colors.orange), leading: const Icon(Icons.cleaning_services, color: Colors.orange),
title: Text('Maintenance', style: GoogleFonts.roboto(color: Colors.white)), title: Text('Maintenance',
style: GoogleFonts.roboto(color: Colors.white)),
subtitle: Text( subtitle: Text(
'En cas de problèmes d\'affichage ou de mise à jour', 'En cas de problèmes d\'affichage ou de mise à jour',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
), ),
trailing: FilledButton.icon( trailing: FilledButton.icon(
onPressed: () { onPressed: () {
// Show confirmation dialog // Show confirmation dialog
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
title: const Text('Vider le cache ?', style: TextStyle(color: Colors.white)), title: const Text('Vider le cache ?',
content: const Text( style: TextStyle(color: Colors.white)),
'Cette action va recharger l\'application et effacer les données temporaires.\n' content: const Text(
'Vos réglages et favoris sont sauvegardés sur le serveur.', 'Cette action va recharger l\'application et effacer les données temporaires.\n'
style: TextStyle(color: Colors.white70), 'Vos réglages et favoris sont sauvegardés sur le serveur.',
), style: TextStyle(color: Colors.white70),
actions: [ ),
TextButton( actions: [
onPressed: () => Navigator.pop(context), TextButton(
child: const Text('Annuler'), onPressed: () => Navigator.pop(context),
), child: const Text('Annuler'),
FilledButton( ),
style: FilledButton.styleFrom(backgroundColor: Colors.orange), FilledButton(
onPressed: () { style: FilledButton.styleFrom(
Navigator.pop(context); backgroundColor: Colors.orange),
onPressed: () {
// Clear Flutter Image Cache Navigator.pop(context);
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages(); // Clear Flutter Image Cache
PaintingBinding.instance.imageCache.clear();
// Clear Browser Local Storage (SharedPreferences backend for Web) PaintingBinding.instance.imageCache.clearLiveImages();
html.window.localStorage.clear();
html.window.sessionStorage.clear(); // Clear Browser Local Storage (SharedPreferences backend for Web)
html.window.localStorage.clear();
// Reload application html.window.sessionStorage.clear();
html.window.location.reload();
}, // Reload application
child: const Text('Vider et Recharger'), html.window.location.reload();
), },
], child: const Text('Vider et Recharger'),
) ),
); ],
),
);
}, },
icon: const Icon(Icons.refresh, size: 16), icon: const Icon(Icons.refresh, size: 16),
label: const Text('Vider le Cache'), label: const Text('Vider le Cache'),
@@ -498,7 +510,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Logout button // Logout button
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
@@ -553,7 +565,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Ex: FR,FRANCE,HD,SPORT', hintText: 'Ex: FR,FRANCE,HD,SPORT',
hintStyle: GoogleFonts.roboto(fontSize: 12, color: Colors.white30), hintStyle:
GoogleFonts.roboto(fontSize: 12, color: Colors.white30),
filled: true, filled: true,
fillColor: Colors.black26, fillColor: Colors.black26,
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -567,7 +580,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
), ),
suffixIcon: controller.text.isNotEmpty suffixIcon: controller.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear, size: 16, color: Colors.white54), icon: const Icon(Icons.clear,
size: 16, color: Colors.white54),
onPressed: onClear, onPressed: onClear,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
@@ -584,15 +598,18 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
runSpacing: 6, runSpacing: 6,
children: keywords.map((keyword) { children: keywords.map((keyword) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.2), color: Colors.blueAccent.withOpacity(0.2),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
border: Border.all(color: Colors.blueAccent.withOpacity(0.5)), border:
Border.all(color: Colors.blueAccent.withOpacity(0.5)),
), ),
child: Text( child: Text(
keyword, keyword,
style: GoogleFonts.roboto(fontSize: 11, color: Colors.white), style:
GoogleFonts.roboto(fontSize: 11, color: Colors.white),
), ),
); );
}).toList(), }).toList(),
@@ -107,7 +107,7 @@ class StreamingSettingsTab extends ConsumerWidget {
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
), ),
value: settings.autoReconnect, value: settings.autoReconnect,
activeColor: Colors.blueAccent, activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setAutoReconnect(v), onChanged: (v) => notifier.setAutoReconnect(v),
), ),
], ],
@@ -143,7 +143,7 @@ class StreamingSettingsTab extends ConsumerWidget {
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
), ),
value: settings.preferDirectPlay, value: settings.preferDirectPlay,
activeColor: Colors.blueAccent, activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setPreferDirectPlay(v), onChanged: (v) => notifier.setPreferDirectPlay(v),
), ),
], ],
@@ -248,8 +248,10 @@ class StreamingSettingsTab extends ConsumerWidget {
required String Function(T) labelBuilder, required String Function(T) labelBuilder,
}) { }) {
return ListTile( return ListTile(
title: Text(title, style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), title: Text(title,
subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)),
subtitle: Text(subtitle,
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)),
trailing: DropdownButton<T>( trailing: DropdownButton<T>(
value: value, value: value,
underline: const SizedBox(), underline: const SizedBox(),
+7 -10
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -13,25 +12,24 @@ import 'core/models/playlist_config.dart';
/// No authentication - direct access to playlist selection /// No authentication - direct access to playlist selection
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize MediaKit (required for video playback) // Initialize MediaKit (required for video playback)
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
// Initialize Hive for local storage // Initialize Hive for local storage
await Hive.initFlutter(); await Hive.initFlutter();
// CLEAR CACHE ON STARTUP (Requested by User) // CLEAR CACHE ON STARTUP (Requested by User)
try { try {
// Clear API/EPG Cache (using default box name 'dio_cache') // Clear API/EPG Cache (using default box name 'dio_cache')
await Hive.deleteBoxFromDisk('dio_cache'); await Hive.deleteBoxFromDisk('dio_cache');
debugPrint('XtremFlow: API Cache cleared'); debugPrint('XtremFlow: API Cache cleared');
// Clear Image Cache (if using cached_network_image) // Clear Image Cache (if using cached_network_image)
// Note: flutter_cache_manager needs to be imported if used explicitly, // Note: flutter_cache_manager needs to be imported if used explicitly,
// but typically it manages itself. If we want to force clear: // but typically it manages itself. If we want to force clear:
// await DefaultCacheManager().emptyCache(); // await DefaultCacheManager().emptyCache();
// Since we didn't add the import yet, we'll stick to Hive for now as that handles EPG/API. // Since we didn't add the import yet, we'll stick to Hive for now as that handles EPG/API.
} catch (e) { } catch (e) {
debugPrint('XtremFlow: Failed to clear cache: $e'); debugPrint('XtremFlow: Failed to clear cache: $e');
} }
@@ -40,7 +38,7 @@ void main() async {
if (!Hive.isAdapterRegistered(1)) { if (!Hive.isAdapterRegistered(1)) {
Hive.registerAdapter(PlaylistConfigAdapter()); Hive.registerAdapter(PlaylistConfigAdapter());
} }
runApp(const ProviderScope(child: MobileApp())); runApp(const ProviderScope(child: MobileApp()));
} }
@@ -74,4 +72,3 @@ class MobileApp extends ConsumerWidget {
); );
} }
} }
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import '../../../../features/auth/providers/auth_provider.dart'; import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart'; import '../../../theme/mobile_theme.dart';
import '../../../../core/widgets/components/ui_components.dart';
class MobileLoginScreen extends ConsumerStatefulWidget { class MobileLoginScreen extends ConsumerStatefulWidget {
const MobileLoginScreen({super.key}); const MobileLoginScreen({super.key});
@@ -103,8 +102,10 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
labelText: 'Username', labelText: 'Username',
prefixIcon: Icon(Icons.person_outline), prefixIcon: Icon(Icons.person_outline),
), ),
validator: (value) => validator: (value) =>
(value == null || value.trim().isEmpty) ? 'Required' : null, (value == null || value.trim().isEmpty)
? 'Required'
: null,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -121,18 +122,21 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
? Icons.visibility_outlined ? Icons.visibility_outlined
: Icons.visibility_off_outlined, : Icons.visibility_off_outlined,
), ),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword), onPressed: () => setState(
() => _obscurePassword = !_obscurePassword),
), ),
), ),
validator: (value) => validator: (value) => (value == null || value.isEmpty)
(value == null || value.isEmpty) ? 'Required' : null, ? 'Required'
: null,
onFieldSubmitted: (_) => _handleLogin(), onFieldSubmitted: (_) => _handleLogin(),
), ),
if (authState.errorMessage != null) ...[ if (authState.errorMessage != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
authState.errorMessage!, authState.errorMessage!,
style: const TextStyle(color: AppColors.error, fontSize: 12), style: const TextStyle(
color: AppColors.error, fontSize: 12),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -145,7 +149,8 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.black),
) )
: const Text('Sign In'), : const Text('Sign In'),
), ),
@@ -8,7 +8,8 @@ import '../../../services/local_playlist_service.dart';
import 'mobile_dashboard_screen.dart'; import 'mobile_dashboard_screen.dart';
/// Provider for local playlists /// Provider for local playlists
final localPlaylistsProvider = FutureProvider<List<PlaylistConfig>>((ref) async { final localPlaylistsProvider =
FutureProvider<List<PlaylistConfig>>((ref) async {
final service = LocalPlaylistService(); final service = LocalPlaylistService();
return service.getPlaylists(); return service.getPlaylists();
}); });
@@ -18,7 +19,8 @@ class MobilePlaylistScreen extends ConsumerStatefulWidget {
const MobilePlaylistScreen({super.key}); const MobilePlaylistScreen({super.key});
@override @override
ConsumerState<MobilePlaylistScreen> createState() => _MobilePlaylistScreenState(); ConsumerState<MobilePlaylistScreen> createState() =>
_MobilePlaylistScreenState();
} }
class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> { class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
@@ -28,7 +30,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_playlistService.init(); _playlistService.init();
// Auto-login: if playlists exist, navigate to first one automatically // Auto-login: if playlists exist, navigate to first one automatically
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAutoLogin(); _checkAutoLogin();
@@ -64,8 +66,10 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
final isEditing = playlist != null; final isEditing = playlist != null;
final nameController = TextEditingController(text: playlist?.name ?? ''); final nameController = TextEditingController(text: playlist?.name ?? '');
final dnsController = TextEditingController(text: playlist?.dns ?? ''); final dnsController = TextEditingController(text: playlist?.dns ?? '');
final usernameController = TextEditingController(text: playlist?.username ?? ''); final usernameController =
final passwordController = TextEditingController(text: playlist?.password ?? ''); TextEditingController(text: playlist?.username ?? '');
final passwordController =
TextEditingController(text: playlist?.password ?? '');
// Focus nodes for TV navigation // Focus nodes for TV navigation
final nameFocus = FocusNode(); final nameFocus = FocusNode();
@@ -90,7 +94,8 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
policy: OrderedTraversalPolicy(), // Ensure logical order policy: OrderedTraversalPolicy(), // Ensure logical order
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: SingleChildScrollView( // Ensure scrolling when keyboard/focus moves child: SingleChildScrollView(
// Ensure scrolling when keyboard/focus moves
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -107,7 +112,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Title // Title
Text( Text(
isEditing ? 'Modifier la playlist' : 'Ajouter une playlist', isEditing ? 'Modifier la playlist' : 'Ajouter une playlist',
@@ -118,7 +123,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Form fields // Form fields
_buildTextField( _buildTextField(
controller: nameController, controller: nameController,
@@ -126,10 +131,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
hint: 'Ma Playlist IPTV', hint: 'Ma Playlist IPTV',
icon: Icons.label_outline, icon: Icons.label_outline,
textAction: TextInputAction.next, textAction: TextInputAction.next,
onSubmitted: () => FocusScope.of(context).requestFocus(dnsFocus), onSubmitted: () =>
FocusScope.of(context).requestFocus(dnsFocus),
focusNode: nameFocus, focusNode: nameFocus,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
controller: dnsController, controller: dnsController,
@@ -138,10 +144,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
icon: Icons.dns_outlined, icon: Icons.dns_outlined,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
textAction: TextInputAction.next, textAction: TextInputAction.next,
onSubmitted: () => FocusScope.of(context).requestFocus(usernameFocus), onSubmitted: () =>
FocusScope.of(context).requestFocus(usernameFocus),
focusNode: dnsFocus, focusNode: dnsFocus,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
controller: usernameController, controller: usernameController,
@@ -149,10 +156,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
hint: 'username', hint: 'username',
icon: Icons.person_outline, icon: Icons.person_outline,
textAction: TextInputAction.next, textAction: TextInputAction.next,
onSubmitted: () => FocusScope.of(context).requestFocus(passwordFocus), onSubmitted: () =>
FocusScope.of(context).requestFocus(passwordFocus),
focusNode: usernameFocus, focusNode: usernameFocus,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
controller: passwordController, controller: passwordController,
@@ -161,12 +169,13 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
icon: Icons.lock_outline, icon: Icons.lock_outline,
obscureText: true, obscureText: true,
textAction: TextInputAction.done, textAction: TextInputAction.done,
onSubmitted: () => FocusScope.of(context).requestFocus(buttonFocus), onSubmitted: () =>
FocusScope.of(context).requestFocus(buttonFocus),
focusNode: passwordFocus, focusNode: passwordFocus,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// Save button // Save button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -186,55 +195,58 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
return; return;
} }
// Auto-fix URL scheme // Auto-fix URL scheme
String dns = dnsController.text.trim(); String dns = dnsController.text.trim();
if (!dns.startsWith('http://') && !dns.startsWith('https://')) { if (!dns.startsWith('http://') &&
dns = 'http://$dns'; !dns.startsWith('https://')) {
} dns = 'http://$dns';
}
if (isEditing) { if (isEditing) {
await _playlistService.updatePlaylist( await _playlistService.updatePlaylist(
id: playlist!.id, id: playlist.id,
name: nameController.text.trim(), name: nameController.text.trim(),
dns: dns, dns: dns,
username: usernameController.text.trim(), username: usernameController.text.trim(),
password: passwordController.text.trim(), password: passwordController.text.trim(),
); );
} else { } else {
await _playlistService.addPlaylist( await _playlistService.addPlaylist(
name: nameController.text.trim(), name: nameController.text.trim(),
dns: dns, dns: dns,
username: usernameController.text.trim(), username: usernameController.text.trim(),
password: passwordController.text.trim(), password: passwordController.text.trim(),
); );
} }
if (mounted) { if (mounted) {
Navigator.pop(context); Navigator.pop(context);
_refreshPlaylists(); _refreshPlaylists();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(isEditing ? 'Playlist modifiée' : 'Playlist ajoutée'), content: Text(isEditing
backgroundColor: Colors.green, ? 'Playlist modifiée'
), : 'Playlist ajoutée'),
); backgroundColor: Colors.green,
} ),
}, );
icon: Icon(isEditing ? Icons.save : Icons.add), }
label: Text(isEditing ? 'Enregistrer' : 'Ajouter'), },
style: FilledButton.styleFrom( icon: Icon(isEditing ? Icons.save : Icons.add),
backgroundColor: AppColors.primary, label: Text(isEditing ? 'Enregistrer' : 'Ajouter'),
padding: const EdgeInsets.symmetric(vertical: 16), style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
), ),
), const SizedBox(height: 16),
],
), ),
const SizedBox(height: 16), ),
],
), ),
), ),
), ),
),
),
); );
} }
@@ -251,11 +263,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
}) { }) {
// Create a dedicated internal focus node for the actual TextField // Create a dedicated internal focus node for the actual TextField
// We don't expose this, allowing us to control when the keyboard opens // We don't expose this, allowing us to control when the keyboard opens
// Since we are inside a stateless method but need state for focus nodes if we want to create them on the fly... // Since we are inside a stateless method but need state for focus nodes if we want to create them on the fly...
// Actually, to avoid State complexity, we will trust the passed 'focusNode' is for NAVIGATION. // Actually, to avoid State complexity, we will trust the passed 'focusNode' is for NAVIGATION.
// And we will use a LayoutBuilder + Stateful wrapper or just a customized Focus widget. // And we will use a LayoutBuilder + Stateful wrapper or just a customized Focus widget.
return _TVTextField( return _TVTextField(
controller: controller, controller: controller,
label: label, label: label,
@@ -269,7 +281,6 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
); );
} }
void _showOptionsSheet(PlaylistConfig playlist) { void _showOptionsSheet(PlaylistConfig playlist) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -297,7 +308,8 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
leading: const Icon(Icons.edit, color: AppColors.textPrimary), leading: const Icon(Icons.edit, color: AppColors.textPrimary),
title: const Text('Modifier', style: TextStyle(color: AppColors.textPrimary)), title: const Text('Modifier',
style: TextStyle(color: AppColors.textPrimary)),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showPlaylistDialog(playlist: playlist); _showPlaylistDialog(playlist: playlist);
@@ -305,7 +317,8 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete, color: AppColors.error), leading: const Icon(Icons.delete, color: AppColors.error),
title: const Text('Supprimer', style: TextStyle(color: AppColors.error)), title: const Text('Supprimer',
style: TextStyle(color: AppColors.error)),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showDeleteConfirmation(playlist); _showDeleteConfirmation(playlist);
@@ -324,10 +337,14 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
title: const Text('Supprimer la playlist ?', title: const Text(
style: TextStyle(color: AppColors.textPrimary)), 'Supprimer la playlist ?',
content: Text('Voulez-vous supprimer "${playlist.name}" ?', style: TextStyle(color: AppColors.textPrimary),
style: const TextStyle(color: AppColors.textSecondary)), ),
content: Text(
'Voulez-vous supprimer "${playlist.name}" ?',
style: const TextStyle(color: AppColors.textSecondary),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -371,13 +388,19 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error), const Icon(Icons.error_outline,
size: 48, color: AppColors.error),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Erreur de chargement', const Text(
style: TextStyle(color: AppColors.textPrimary)), 'Erreur de chargement',
style: TextStyle(color: AppColors.textPrimary),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(error.toString(), Text(
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), error.toString(),
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12),
),
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton.icon( TextButton.icon(
onPressed: _refreshPlaylists, onPressed: _refreshPlaylists,
@@ -540,7 +563,8 @@ class _PlaylistCard extends StatelessWidget {
color: AppColors.textTertiary, color: AppColors.textTertiary,
), ),
IconButton( IconButton(
icon: const Icon(Icons.more_vert, color: AppColors.textSecondary), icon: const Icon(Icons.more_vert,
color: AppColors.textSecondary),
onPressed: onLongPress, // Re-use the options sheet logic onPressed: onLongPress, // Re-use the options sheet logic
), ),
], ],
@@ -589,7 +613,7 @@ class _TVTextFieldState extends State<_TVTextField> {
super.initState(); super.initState();
_inputFocus = FocusNode(); _inputFocus = FocusNode();
_navFocus = widget.navigationFocus ?? FocusNode(); _navFocus = widget.navigationFocus ?? FocusNode();
// When input loses focus (keyboard closed), return to nav focus // When input loses focus (keyboard closed), return to nav focus
_inputFocus.addListener(() { _inputFocus.addListener(() {
if (!_inputFocus.hasFocus && _isEditing) { if (!_inputFocus.hasFocus && _isEditing) {
@@ -622,8 +646,8 @@ class _TVTextFieldState extends State<_TVTextField> {
onKeyEvent: (node, event) { onKeyEvent: (node, event) {
if (event is KeyDownEvent) { if (event is KeyDownEvent) {
final key = event.logicalKey; final key = event.logicalKey;
if (key == LogicalKeyboardKey.select || if (key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.space || key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.gameButtonA || key == LogicalKeyboardKey.gameButtonA ||
key == LogicalKeyboardKey.numpadEnter) { key == LogicalKeyboardKey.numpadEnter) {
@@ -640,7 +664,9 @@ class _TVTextFieldState extends State<_TVTextField> {
onTap: _activateInput, onTap: _activateInput,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: isFocused ? Border.all(color: AppColors.primary, width: 2) : null, border: isFocused
? Border.all(color: AppColors.primary, width: 2)
: null,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
// Ignore standard focus traversal to child, only allow programatic focus // Ignore standard focus traversal to child, only allow programatic focus
@@ -650,37 +676,44 @@ class _TVTextFieldState extends State<_TVTextField> {
controller: widget.controller, controller: widget.controller,
focusNode: _inputFocus, focusNode: _inputFocus,
// Prevent keyboard if somehow focused while not editing // Prevent keyboard if somehow focused while not editing
readOnly: !_isEditing, readOnly: !_isEditing,
showCursor: _isEditing, showCursor: _isEditing,
obscureText: widget.obscureText, obscureText: widget.obscureText,
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
textInputAction: widget.textAction, textInputAction: widget.textAction,
onSubmitted: (_) { onSubmitted: (_) {
setState(() => _isEditing = false); setState(() => _isEditing = false);
// Give focus back to nav node first so we can move to next // Give focus back to nav node first so we can move to next
_navFocus.requestFocus(); _navFocus.requestFocus();
widget.onSubmitted?.call(); widget.onSubmitted?.call();
}, },
style: const TextStyle(color: AppColors.textPrimary), style: const TextStyle(color: AppColors.textPrimary),
decoration: InputDecoration( decoration: InputDecoration(
labelText: widget.label, labelText: widget.label,
hintText: widget.hint, hintText: widget.hint,
prefixIcon: Icon(widget.icon, color: isFocused ? AppColors.primary : AppColors.textSecondary), prefixIcon: Icon(widget.icon,
labelStyle: TextStyle(color: isFocused ? AppColors.primary : AppColors.textSecondary), color: isFocused
hintStyle: const TextStyle(color: AppColors.textTertiary), ? AppColors.primary
filled: true, : AppColors.textSecondary),
fillColor: AppColors.surface, labelStyle: TextStyle(
border: OutlineInputBorder( color: isFocused
borderRadius: BorderRadius.circular(8), ? AppColors.primary
borderSide: BorderSide.none, : AppColors.textSecondary),
hintStyle: const TextStyle(color: AppColors.textTertiary),
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 14),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
), ),
), ),
), ),
),
); );
} },
), ),
); );
} }
@@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../../core/services/playlist_api_service.dart'; import '../../../../core/services/playlist_api_service.dart';
import '../../../../core/models/playlist_config.dart'; import '../../../../core/models/playlist_config.dart';
import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart'; import '../../../theme/mobile_theme.dart';
@@ -18,10 +18,12 @@ class MobilePlaylistSelectionScreen extends ConsumerStatefulWidget {
const MobilePlaylistSelectionScreen({super.key, this.manageMode = false}); const MobilePlaylistSelectionScreen({super.key, this.manageMode = false});
@override @override
ConsumerState<MobilePlaylistSelectionScreen> createState() => _MobilePlaylistSelectionScreenState(); ConsumerState<MobilePlaylistSelectionScreen> createState() =>
_MobilePlaylistSelectionScreenState();
} }
class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSelectionScreen> { class _MobilePlaylistSelectionScreenState
extends ConsumerState<MobilePlaylistSelectionScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -42,9 +44,155 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
} }
} }
Future<void> _showPlaylistDialog({PlaylistConfig? playlist}) async {
final isEditing = playlist != null;
final nameController = TextEditingController(text: playlist?.name ?? '');
final dnsController = TextEditingController(text: playlist?.dns ?? '');
final userController =
TextEditingController(text: playlist?.username ?? '');
final passController =
TextEditingController(text: playlist?.password ?? '');
await showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(
isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Nom',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24)),
),
),
const SizedBox(height: 8),
TextField(
controller: dnsController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'URL (DNS)',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24)),
),
),
const SizedBox(height: 8),
TextField(
controller: userController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Utilisateur',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24)),
),
),
const SizedBox(height: 8),
TextField(
controller: passController,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Mot de passe',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24)),
),
),
],
),
),
actions: [
if (isEditing)
TextButton(
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('Confirmer',
style: TextStyle(color: Colors.white)),
content: const Text('Supprimer cette playlist ?',
style: TextStyle(color: Colors.white70)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style:
TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
await PlaylistApiService().deletePlaylist(playlist.id);
if (context.mounted) {
Navigator.pop(context);
ref.invalidate(playlistsProvider);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
if (nameController.text.isEmpty || dnsController.text.isEmpty) {
return;
}
final service = PlaylistApiService();
if (isEditing) {
await service.updatePlaylist(
id: playlist.id,
name: nameController.text,
dns: dnsController.text,
username: userController.text,
password: passController.text,
);
} else {
await service.createPlaylist(
name: nameController.text,
dns: dnsController.text,
username: userController.text,
password: passController.text,
);
}
if (context.mounted) {
Navigator.pop(context);
ref.invalidate(playlistsProvider);
}
},
child: const Text('Enregistrer'),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser;
final playlistsAsync = ref.watch(playlistsProvider); final playlistsAsync = ref.watch(playlistsProvider);
return Theme( return Theme(
@@ -56,11 +204,17 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
actions: [ actions: [
if (widget.manageMode) if (widget.manageMode) ...[
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.add),
onPressed: () => Navigator.pop(context), onPressed: () => _showPlaylistDialog(),
), tooltip: 'Ajouter une playlist',
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
], ],
), ),
body: Container( body: Container(
@@ -73,11 +227,13 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error), const Icon(Icons.error_outline,
size: 48, color: AppColors.error),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Erreur de chargement', style: GoogleFonts.inter(color: Colors.white)), Text('Erreur de chargement',
style: GoogleFonts.inter(color: Colors.white)),
TextButton( TextButton(
onPressed: () => ref.refresh(playlistsProvider), onPressed: () => ref.invalidate(playlistsProvider),
child: const Text('Réessayer'), child: const Text('Réessayer'),
), ),
], ],
@@ -85,51 +241,51 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
), ),
data: (playlists) { data: (playlists) {
if (playlists.isEmpty) { if (playlists.isEmpty) {
// Even in auto-mode, if empty we show this (or add button) return Center(
return Center( child: Column(
child: Column( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ const Icon(Icons.playlist_add,
const Icon(Icons.playlist_add, size: 64, color: Colors.white54), size: 64, color: Colors.white54),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Aucune playlist configurée', 'Aucune playlist configurée',
style: GoogleFonts.inter(fontSize: 18, color: Colors.white70), style: GoogleFonts.inter(
fontSize: 18, color: Colors.white70),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () => _showPlaylistDialog(),
icon: const Icon(Icons.add),
label: const Text('Ajouter une Playlist'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
), ),
const SizedBox(height: 32), ),
ElevatedButton.icon( ],
onPressed: () { ),
// Logic to add playlist (usually dialog or separate screen) );
// specific logic for add playlist
},
icon: const Icon(Icons.add),
label: const Text('Ajouter une Playlist'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
);
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 100, 16, 16), // Top padding for AppBar padding: const EdgeInsets.fromLTRB(
16, 100, 16, 16), // Top padding for AppBar
itemCount: playlists.length, itemCount: playlists.length,
separatorBuilder: (context, index) => const SizedBox(height: 16), separatorBuilder: (context, index) =>
const SizedBox(height: 16),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final playlist = playlists[index]; final playlist = playlists[index];
return _MobilePlaylistCard( return _MobilePlaylistCard(
playlist: playlist, playlist: playlist,
manageMode: widget.manageMode,
onTap: () { onTap: () {
if (widget.manageMode) { if (widget.manageMode) {
// In manage mode, maybe we want to edit or delete? _showPlaylistDialog(playlist: playlist);
// For now, just switch to it.
context.go('/dashboard', extra: playlist);
} else { } else {
context.go('/dashboard', extra: playlist); context.go('/dashboard', extra: playlist);
} }
}, },
); );
@@ -146,10 +302,12 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
class _MobilePlaylistCard extends StatelessWidget { class _MobilePlaylistCard extends StatelessWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
final VoidCallback onTap; final VoidCallback onTap;
final bool manageMode;
const _MobilePlaylistCard({ const _MobilePlaylistCard({
required this.playlist, required this.playlist,
required this.onTap, required this.onTap,
this.manageMode = false,
}); });
@override @override
@@ -183,7 +341,8 @@ 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, color: AppColors.primary, size: 28), child: const Icon(Icons.playlist_play,
color: AppColors.primary, size: 28),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -213,7 +372,11 @@ class _MobilePlaylistCard extends StatelessWidget {
], ],
), ),
), ),
const Icon(Icons.chevron_right, color: AppColors.textTertiary), if (manageMode)
const Icon(Icons.edit, color: AppColors.primary)
else
const Icon(Icons.chevron_right,
color: AppColors.textTertiary),
], ],
), ),
), ),
@@ -292,15 +292,11 @@ class _MobileSeriesDetailScreenState
); );
final isWatched = watchHistory.isEpisodeWatched(episodeKey); final isWatched = watchHistory.isEpisodeWatched(episodeKey);
// Use theme colors for proper light/dark mode support // Force dark mode colors since background is hardcoded dark
final theme = Theme.of(context); const textColor = Colors.white;
final isDark = theme.brightness == Brightness.dark; final secondaryTextColor = Colors.grey.shade400;
final textColor = isDark ? Colors.white : Colors.black; const surfaceColor = Color(0xFF1C1C1E);
final secondaryTextColor = const primaryColor = Colors.white; // Or accent color if preferred
isDark ? Colors.grey.shade400 : Colors.grey.shade600;
final surfaceColor =
isDark ? const Color(0xFF1C1C1E) : Colors.grey.shade200;
final primaryColor = isDark ? Colors.white : Colors.blue;
return InkWell( return InkWell(
onTap: () { onTap: () {
@@ -326,10 +322,8 @@ class _MobileSeriesDetailScreenState
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(bottom: BorderSide(color: Colors.white10)),
bottom:
BorderSide(color: isDark ? Colors.white10 : Colors.black12)),
), ),
child: Row( child: Row(
children: [ children: [
@@ -347,8 +341,8 @@ class _MobileSeriesDetailScreenState
child: Icon( child: Icon(
isWatched ? Icons.check : Icons.play_arrow, isWatched ? Icons.check : Icons.play_arrow,
color: isWatched color: isWatched
? (isDark ? Colors.black : Colors.white) ? Colors.black // Checkmark on white bg
: primaryColor, : primaryColor, // Play icon
size: 20, size: 20,
), ),
), ),
@@ -21,7 +21,8 @@ class MobileSeriesTab extends ConsumerStatefulWidget {
ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState(); ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState();
} }
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with AutomaticKeepAliveClientMixin { class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
with AutomaticKeepAliveClientMixin {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode(); final FocusNode _searchFocusNode = FocusNode();
@@ -33,7 +34,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
bool _isSearching = false; bool _isSearching = false;
bool _hasMore = true; bool _hasMore = true;
int _currentOffset = 0; int _currentOffset = 0;
static const int _pageSize = 50; static const int _pageSize = 50;
Timer? _searchDebounce; Timer? _searchDebounce;
@override @override
@@ -54,9 +55,10 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
void _onScroll() { void _onScroll() {
// Only trigger load when near bottom and not already loading // Only trigger load when near bottom and not already loading
if (!_isLoading && _hasMore && if (!_isLoading &&
_hasMore &&
_scrollController.position.pixels >= _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) { _scrollController.position.maxScrollExtent * 0.8) {
_loadMoreSeries(); _loadMoreSeries();
} }
} }
@@ -70,7 +72,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
_isSearching = false; _isSearching = false;
} }
}); });
if (query.length >= 2) { if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () { _searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query); _performSearch(query);
@@ -81,11 +83,12 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
Future<void> _performSearch(String query) async { Future<void> _performSearch(String query) async {
if (query.isEmpty) return; if (query.isEmpty) return;
setState(() => _isSearching = true); setState(() => _isSearching = true);
try { try {
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final results = await service.searchSeries(query); final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) { if (mounted && _searchQuery == query) {
setState(() { setState(() {
_searchResults = results; _searchResults = results;
@@ -104,13 +107,16 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
// Add timeout to prevent infinite loading state // Add timeout to prevent infinite loading state
final newSeries = await service.getSeriesPaginated( final newSeries = await service
offset: _currentOffset, .getSeriesPaginated(
limit: _pageSize, offset: _currentOffset,
).timeout(const Duration(seconds: 15)); limit: _pageSize,
)
.timeout(const Duration(seconds: 15));
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -134,10 +140,11 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
Future<void> _refresh() async { Future<void> _refresh() async {
// Clear service cache to force fresh data // Clear service cache to force fresh data
try { try {
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
service.clearCache(); service.clearCache();
} catch (_) {} } catch (_) {}
setState(() { setState(() {
_series.clear(); _series.clear();
_currentOffset = 0; _currentOffset = 0;
@@ -184,159 +191,176 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
final settings = ref.watch(mobileSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
List<Series> displaySeries; List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) { if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!; displaySeries = _searchResults!;
} else { } else {
displaySeries = settings.seriesKeywords.isEmpty displaySeries = settings.seriesKeywords.isEmpty
? _series ? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); : _series
.where((s) => settings.matchesSeriesFilter(s.categoryName))
.toList();
} }
// Hero Items (use filtered series) // Hero Items (use filtered series)
final heroItems = displaySeries.take(3).map((s) => HeroItem( final heroItems = displaySeries
id: s.seriesId.toString(), .take(3)
title: s.name, .map(
imageUrl: _getImageUrl(s.cover), (s) => HeroItem(
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, id: s.seriesId.toString(),
onMoreInfo: () => _openSeries(s), title: s.name,
)).toList(); imageUrl: _getImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s),
),
)
.toList();
return Container( return Container(
decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), decoration: const BoxDecoration(gradient: AppColors.appleTvGradient),
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _refresh, onRefresh: _refresh,
color: AppColors.primary, color: AppColors.primary,
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
// Hero Section (reduced height, moved above search) // Hero Section (reduced height, moved above search)
if (_searchQuery.isEmpty && heroItems.isNotEmpty) if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8), padding: const EdgeInsets.only(top: 8, bottom: 8),
child: SizedBox( child: SizedBox(
height: 180, // Reduced from 250 height: 180, // Reduced from 250
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
try { try {
final series = _series.firstWhere((s) => s.seriesId.toString() == item.id); final series = _series.firstWhere(
_openSeries(series); (s) => s.seriesId.toString() == item.id);
} catch (e) { _openSeries(series);
// Fallback logic not critical } catch (e) {
} // Fallback logic not critical
}, }
), },
), ),
),
),
// Search Bar (moved below carousel)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TVFocusable(
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: _isSearchEditing
? Border.all(color: AppColors.primary)
: Border.all(color: AppColors.border),
), ),
padding: const EdgeInsets.symmetric(horizontal: 12), ),
child: Row( ),
children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary), // Search Bar (moved below carousel)
const SizedBox(width: 8), SliverToBoxAdapter(
Expanded( child: Padding(
child: ExcludeFocus( padding:
excluding: !_isSearchEditing, const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField( child: TVFocusable(
controller: _searchController, onPressed: () {
focusNode: _searchFocusNode, setState(() => _isSearchEditing = true);
readOnly: !_isSearchEditing, WidgetsBinding.instance.addPostFrameCallback((_) {
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), _searchFocusNode.requestFocus();
decoration: const InputDecoration( });
hintText: 'Rechercher une série...', },
border: InputBorder.none, borderRadius: BorderRadius.circular(12),
isDense: true, child: Container(
contentPadding: EdgeInsets.only(bottom: 11), height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: _isSearchEditing
? Border.all(color: AppColors.primary)
: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.search,
size: 20, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: const TextStyle(
fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Rechercher une série...',
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) =>
setState(() => _isSearchEditing = false),
), ),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
), ),
), ),
), if (_isSearching)
if (_isSearching) const SizedBox(
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), width: 12,
if (_searchQuery.isNotEmpty) height: 12,
GestureDetector( child:
onTap: () { CircularProgressIndicator(strokeWidth: 2)),
_searchController.clear(); if (_searchQuery.isNotEmpty)
_onSearchChanged(''); GestureDetector(
}, onTap: () {
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), _searchController.clear();
), _onSearchChanged('');
], },
child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
),
],
),
), ),
), ),
), ),
), ),
// Grid
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8, // Reduced size further (was 4)
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.7,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displaySeries.length) return null;
final series = displaySeries[index];
return MobilePosterCard(
title: series.name,
imageUrl: _getImageUrl(series.cover),
rating: _formatRating(series.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(series),
);
},
childCount: displaySeries.length,
),
),
),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
],
), ),
),
// Grid
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8, // Reduced size further (was 4)
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.7,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displaySeries.length) return null;
final series = displaySeries[index];
return MobilePosterCard(
title: series.name,
imageUrl: _getImageUrl(series.cover),
rating: _formatRating(series.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(series),
);
},
childCount: displaySeries.length,
),
),
),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
),
), ),
); );
} }
@@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import '../../../providers/mobile_settings_providers.dart'; import '../../../providers/mobile_settings_providers.dart';
import '../../../widgets/tv_focusable.dart'; import '../../../widgets/tv_focusable.dart';
import '../../../../core/providers/theme_provider.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../screens/mobile_playlist_selection_screen.dart'; import '../screens/mobile_playlist_selection_screen.dart';
@@ -61,8 +61,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(mobileSettingsProvider); final settings = ref.watch(mobileSettingsProvider);
final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings); if (!_initialized) _syncControllers(settings);
@@ -114,16 +112,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
// === APPARENCE ===
_buildSectionHeader('Apparence'),
_buildSettingItem(
icon: isDark ? Icons.dark_mode : Icons.light_mode,
title: 'Mode Sombre',
value: isDark ? 'Activé' : 'Désactivé',
onTap: () => themeNotifier.toggleTheme(),
),
_buildSettingItem( _buildSettingItem(
icon: Icons.access_time, icon: Icons.access_time,
title: 'Afficher l\'heure', title: 'Afficher l\'heure',
@@ -138,16 +126,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
// === LECTURE VIDEO === // === LECTURE VIDEO ===
_buildSectionHeader('Lecture Vidéo'), _buildSectionHeader('Lecture Vidéo'),
_buildSettingItem(
icon: Icons.bug_report,
title: 'Stats pour Nerds',
subtitle: 'Affiche FPS, Buffer, Bitrate',
value: settings.showDebugStats ? 'Activé' : 'Désactivé',
onTap: () => ref
.read(mobileSettingsProvider.notifier)
.toggleShowDebugStats(!settings.showDebugStats),
),
_buildSettingItem( _buildSettingItem(
icon: Icons.memory, icon: Icons.memory,
title: 'Décodeur Vidéo', title: 'Décodeur Vidéo',
@@ -52,8 +52,9 @@ class MobileSettings {
if (liveTvKeywords.isEmpty) return true; if (liveTvKeywords.isEmpty) return true;
if (category == null) return true; if (category == null) return true;
final lowerCategory = category.toLowerCase(); final lowerCategory = category.toLowerCase();
return liveTvKeywords.any((keyword) => return liveTvKeywords.any(
lowerCategory.contains(keyword.toLowerCase())); (keyword) => lowerCategory.contains(keyword.toLowerCase()),
);
} }
/// Check if category matches Movies filter /// Check if category matches Movies filter
@@ -61,8 +62,9 @@ class MobileSettings {
if (moviesKeywords.isEmpty) return true; if (moviesKeywords.isEmpty) return true;
if (category == null) return true; if (category == null) return true;
final lowerCategory = category.toLowerCase(); final lowerCategory = category.toLowerCase();
return moviesKeywords.any((keyword) => return moviesKeywords.any(
lowerCategory.contains(keyword.toLowerCase())); (keyword) => lowerCategory.contains(keyword.toLowerCase()),
);
} }
/// Check if category matches Series filter /// Check if category matches Series filter
@@ -70,8 +72,9 @@ class MobileSettings {
if (seriesKeywords.isEmpty) return true; if (seriesKeywords.isEmpty) return true;
if (category == null) return true; if (category == null) return true;
final lowerCategory = category.toLowerCase(); final lowerCategory = category.toLowerCase();
return seriesKeywords.any((keyword) => return seriesKeywords.any(
lowerCategory.contains(keyword.toLowerCase())); (keyword) => lowerCategory.contains(keyword.toLowerCase()),
);
} }
} }
@@ -85,16 +88,22 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
Future<void> _init() async { Future<void> _init() async {
_box = await Hive.openBox(_boxName); _box = await Hive.openBox(_boxName);
final liveTv = _box?.get('liveTvKeywords', defaultValue: <String>[]) as List?; final liveTv =
final movies = _box?.get('moviesKeywords', defaultValue: <String>[]) as List?; _box?.get('liveTvKeywords', defaultValue: <String>[]) as List?;
final series = _box?.get('seriesKeywords', defaultValue: <String>[]) as List?; final movies =
_box?.get('moviesKeywords', defaultValue: <String>[]) as List?;
final series =
_box?.get('seriesKeywords', defaultValue: <String>[]) as List?;
final showClock = _box?.get('showClock', defaultValue: false) as bool?; final showClock = _box?.get('showClock', defaultValue: false) as bool?;
final showDebugStats = _box?.get('showDebugStats', defaultValue: false) as bool?; final showDebugStats =
final decoderMode = _box?.get('decoderMode', defaultValue: 'auto') as String?; _box?.get('showDebugStats', defaultValue: false) as bool?;
final playerEngine = _box?.get('playerEngine', defaultValue: 'ultra') as String?; final decoderMode =
_box?.get('decoderMode', defaultValue: 'auto') as String?;
final playerEngine =
_box?.get('playerEngine', defaultValue: 'ultra') as String?;
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?; final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
state = MobileSettings( state = MobileSettings(
liveTvKeywords: liveTv?.cast<String>() ?? [], liveTvKeywords: liveTv?.cast<String>() ?? [],
moviesKeywords: movies?.cast<String>() ?? [], moviesKeywords: movies?.cast<String>() ?? [],
@@ -148,7 +157,8 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
} }
} }
final mobileSettingsProvider = StateNotifierProvider<MobileSettingsNotifier, MobileSettings>((ref) { final mobileSettingsProvider =
StateNotifierProvider<MobileSettingsNotifier, MobileSettings>((ref) {
return MobileSettingsNotifier(); return MobileSettingsNotifier();
}); });
@@ -177,8 +187,9 @@ class MobileWatchHistory {
} }
bool isMovieWatched(String streamId) => watchedMovies.contains(streamId); bool isMovieWatched(String streamId) => watchedMovies.contains(streamId);
bool isEpisodeWatched(String episodeKey) => watchedEpisodes.contains(episodeKey); bool isEpisodeWatched(String episodeKey) =>
watchedEpisodes.contains(episodeKey);
/// Get resume position in seconds (0 if none saved) /// Get resume position in seconds (0 if none saved)
int getResumePosition(String contentId) => resumePositions[contentId] ?? 0; int getResumePosition(String contentId) => resumePositions[contentId] ?? 0;
@@ -197,11 +208,13 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
Future<void> _init() async { Future<void> _init() async {
_box = await Hive.openBox(_boxName); _box = await Hive.openBox(_boxName);
final movies = _box?.get('watchedMovies', defaultValue: <String>[]) as List?; final movies =
final episodes = _box?.get('watchedEpisodes', defaultValue: <String>[]) as List?; _box?.get('watchedMovies', defaultValue: <String>[]) as List?;
final episodes =
_box?.get('watchedEpisodes', defaultValue: <String>[]) as List?;
final positionsRaw = _box?.get('resumePositions'); final positionsRaw = _box?.get('resumePositions');
// Safe conversion for resumePositions map // Safe conversion for resumePositions map
Map<String, int> positions = {}; Map<String, int> positions = {};
if (positionsRaw != null && positionsRaw is Map) { if (positionsRaw != null && positionsRaw is Map) {
@@ -213,7 +226,7 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
} }
}); });
} }
state = MobileWatchHistory( state = MobileWatchHistory(
watchedMovies: movies?.cast<String>().toSet() ?? {}, watchedMovies: movies?.cast<String>().toSet() ?? {},
watchedEpisodes: episodes?.cast<String>().toSet() ?? {}, watchedEpisodes: episodes?.cast<String>().toSet() ?? {},
@@ -247,25 +260,28 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
// Clear resume position when marked as fully watched // Clear resume position when marked as fully watched
final positions = {...state.resumePositions}; final positions = {...state.resumePositions};
positions.remove(episodeKey); positions.remove(episodeKey);
state = state.copyWith(watchedEpisodes: updated, resumePositions: positions); state =
state.copyWith(watchedEpisodes: updated, resumePositions: positions);
_box?.put('watchedEpisodes', updated.toList()); _box?.put('watchedEpisodes', updated.toList());
_box?.put('resumePositions', positions); _box?.put('resumePositions', positions);
} }
/// Save resume position for movie or episode /// Save resume position for movie or episode
void saveResumePosition(String contentId, int positionSeconds) { void saveResumePosition(String contentId, int positionSeconds) {
// Only save if position is meaningful (> 30 seconds) // Only save if position is meaningful (> 30 seconds)
if (positionSeconds < 30) { if (positionSeconds < 30) {
debugPrint('[WatchHistory] Position too short ($positionSeconds), skipping save'); debugPrint(
'[WatchHistory] Position too short ($positionSeconds), skipping save');
return; return;
} }
debugPrint('[WatchHistory] Saving position for $contentId: ${positionSeconds}s'); debugPrint(
'[WatchHistory] Saving position for $contentId: ${positionSeconds}s');
final positions = {...state.resumePositions, contentId: positionSeconds}; final positions = {...state.resumePositions, contentId: positionSeconds};
state = state.copyWith(resumePositions: positions); state = state.copyWith(resumePositions: positions);
_box?.put('resumePositions', Map<String, int>.from(positions)); _box?.put('resumePositions', Map<String, int>.from(positions));
} }
/// Clear resume position (when content finished or user wants to restart) /// Clear resume position (when content finished or user wants to restart)
void clearResumePosition(String contentId) { void clearResumePosition(String contentId) {
final positions = {...state.resumePositions}; final positions = {...state.resumePositions};
@@ -275,7 +291,9 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
} }
} }
final mobileWatchHistoryProvider = StateNotifierProvider<MobileWatchHistoryNotifier, MobileWatchHistory>((ref) { final mobileWatchHistoryProvider =
StateNotifierProvider<MobileWatchHistoryNotifier, MobileWatchHistory>(
(ref) {
return MobileWatchHistoryNotifier(); return MobileWatchHistoryNotifier();
}); });
@@ -308,6 +326,7 @@ class MobileFavoritesNotifier extends StateNotifier<Set<String>> {
bool isFavorite(String streamId) => state.contains(streamId); bool isFavorite(String streamId) => state.contains(streamId);
} }
final mobileFavoritesProvider = StateNotifierProvider<MobileFavoritesNotifier, Set<String>>((ref) { final mobileFavoritesProvider =
StateNotifierProvider<MobileFavoritesNotifier, Set<String>>((ref) {
return MobileFavoritesNotifier(); return MobileFavoritesNotifier();
}); });
+13 -10
View File
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../../core/theme/app_colors.dart';
class TVFocusable extends StatefulWidget { class TVFocusable extends StatefulWidget {
final Widget child; final Widget child;
@@ -107,7 +106,7 @@ class _TVFocusableState extends State<TVFocusable> {
if (!_isKeyDown) { if (!_isKeyDown) {
_isKeyDown = true; _isKeyDown = true;
_longPressTriggered = false; _longPressTriggered = false;
// Start long press timer only if onLongPress is defined // Start long press timer only if onLongPress is defined
if (widget.onLongPress != null) { if (widget.onLongPress != null) {
_longPressTimer?.cancel(); _longPressTimer?.cancel();
@@ -121,20 +120,20 @@ class _TVFocusableState extends State<TVFocusable> {
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (event is KeyUpEvent && isSelectKey) { if (event is KeyUpEvent && isSelectKey) {
_longPressTimer?.cancel(); _longPressTimer?.cancel();
// If long press wasn't triggered, it's a normal press // If long press wasn't triggered, it's a normal press
if (_isKeyDown && !_longPressTriggered) { if (_isKeyDown && !_longPressTriggered) {
_handlePress(); _handlePress();
} }
_isKeyDown = false; _isKeyDown = false;
_longPressTriggered = false; _longPressTriggered = false;
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;
}, },
child: GestureDetector( child: GestureDetector(
@@ -153,15 +152,18 @@ class _TVFocusableState extends State<TVFocusable> {
child: Container( child: Container(
decoration: _isFocused decoration: _isFocused
? BoxDecoration( ? BoxDecoration(
borderRadius: widget.borderRadius ?? BorderRadius.circular(12), borderRadius:
widget.borderRadius ?? BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.5), // Shadow for depth color: Colors.black
.withOpacity(0.5), // Shadow for depth
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
BoxShadow( BoxShadow(
color: widget.focusColor.withOpacity(0.3), // Glow effect color: widget.focusColor
.withOpacity(0.3), // Glow effect
blurRadius: 12, blurRadius: 12,
spreadRadius: 2, spreadRadius: 2,
), ),
@@ -170,7 +172,8 @@ class _TVFocusableState extends State<TVFocusable> {
: null, : null,
foregroundDecoration: _isFocused foregroundDecoration: _isFocused
? BoxDecoration( ? BoxDecoration(
borderRadius: widget.borderRadius ?? BorderRadius.circular(12), borderRadius:
widget.borderRadius ?? BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: widget.focusColor, color: widget.focusColor,
width: widget.borderWidth, width: widget.borderWidth,