mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: Implement a web-based IPTV player with HLS/MPEG-TS support and Flutter communication.
This commit is contained in:
1 parent
2ca92d8425
commit
55acbbc135
28 files changed
+2075
-1448
No files matched your search
+57
-35
@@ -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'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user