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

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

No files matched your search

+47 -25
View File
@@ -47,21 +47,24 @@ void main(List<String> args) async {
// 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(
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0', '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(
headers: {
'Cache-Control': 'public, max-age=86400', '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'},
); );
} }
+16 -8
View File
@@ -14,7 +14,8 @@ class DnsFallbackInterceptor extends Interceptor {
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;
@@ -67,10 +68,12 @@ class DnsFallbackInterceptor extends Interceptor {
} }
bool _isDnsError(DioException err) { bool _isDnsError(DioException err) {
if (err.type == DioExceptionType.connectionError || err.type == DioExceptionType.unknown) { if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.unknown) {
if (err.error is SocketException) { if (err.error is SocketException) {
final msg = (err.error as SocketException).message; final msg = (err.error as SocketException).message;
return msg.contains('Failed host lookup') || msg.contains('No address associated with hostname'); return msg.contains('Failed host lookup') ||
msg.contains('No address associated with hostname');
} }
} }
return false; return false;
@@ -79,12 +82,15 @@ 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,7 +100,8 @@ 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) {
@@ -111,7 +118,8 @@ class DnsFallbackInterceptor extends Interceptor {
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) {
// Type A record
return ans['data']; return ans['data'];
} }
} }
+3 -4
View File
@@ -23,12 +23,10 @@ class DnsResolver {
); );
// Fallback to Cloudflare via IP (1.1.1.1) // Fallback to Cloudflare via IP (1.1.1.1)
if (ip == null) { ip ??= await _queryDoH(
ip = await _queryDoH(
'https://1.1.1.1/dns-query?name=$hostname&type=A', 'https://1.1.1.1/dns-query?name=$hostname&type=A',
headers: {'accept': 'application/dns-json'}, headers: {'accept': 'application/dns-json'},
); );
}
if (ip != null) { if (ip != null) {
print('DnsResolver: Resolved $hostname -> $ip'); print('DnsResolver: Resolved $hostname -> $ip');
@@ -40,7 +38,8 @@ class DnsResolver {
return ip; return ip;
} }
static Future<String?> _queryDoH(String url, {Map<String, String>? headers}) async { static Future<String?> _queryDoH(String url,
{Map<String, String>? headers}) async {
try { try {
final client = HttpClient() final client = HttpClient()
..connectionTimeout = const Duration(seconds: 10) ..connectionTimeout = const Duration(seconds: 10)
+1 -2
View File
@@ -62,7 +62,7 @@ class HiveService {
username: 'admin', username: 'admin',
passwordHash: passwordHash, passwordHash: passwordHash,
isAdmin: true, isAdmin: true,
assignedPlaylistIds: [], assignedPlaylistIds: const [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
@@ -103,7 +103,6 @@ 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);
+41 -68
View File
@@ -1,30 +1,18 @@
import '../api/api_client.dart'; import 'package:uuid/uuid.dart';
import '../database/hive_service.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
/// Service for managing playlists via API /// Service for managing playlists via local storage (Hive)
class PlaylistApiService { class PlaylistApiService {
final ApiClient _api = ApiClient();
/// Get all playlists for current user /// Get all playlists for current user
Future<List<PlaylistConfig>> getPlaylists() async { Future<List<PlaylistConfig>> getPlaylists() async {
try { try {
final response = await _api.get('/api/playlists'); if (!HiveService.playlistsBox.isOpen) {
final data = response.data as Map<String, dynamic>; await HiveService.init();
final playlistsData = data['playlists'] as List<dynamic>; }
return HiveService.playlistsBox.values.toList();
return playlistsData.map((p) {
final playlist = p as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}).toList();
} catch (e) { } catch (e) {
print('Error fetching playlists: $e'); print('Error fetching playlists from Hive: $e');
return []; return [];
} }
} }
@@ -37,30 +25,20 @@ class PlaylistApiService {
required String password, required String password,
}) async { }) async {
try { try {
final response = await _api.post('/api/playlists', data: { final id = const Uuid().v4();
'name': name, final playlist = PlaylistConfig(
'serverUrl': dns, id: id,
'username': username, name: name,
'password': password, dns: dns,
'dns': dns, username: username,
},); password: password,
createdAt: DateTime.now(),
final data = response.data as Map<String, dynamic>;
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; await HiveService.playlistsBox.put(id, playlist);
return playlist;
} 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,
'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; 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;
} }
} }
+2 -3
View File
@@ -73,9 +73,9 @@ class AppColors {
primary: primary, primary: primary,
secondary: accent, secondary: accent,
surface: surface, surface: surface,
background: background,
error: error, error: error,
onPrimary: Colors.black, // White text on buttons -> Black text on White buttons onPrimary: Colors
.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,
@@ -86,7 +86,6 @@ class AppColors {
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,
@@ -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});
+239 -67
View File
@@ -116,7 +116,8 @@ class _AdminContentState extends ConsumerState<AdminContent>
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 {
if (nameController.text.isEmpty ||
dnsController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis')));
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); final dns = dnsController.text
.trim()
.replaceAll(RegExp(r'/$'), '');
if (playlist == null) { if (playlist == null) {
await service.createPlaylist(name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); await service.createPlaylist(
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text);
} else { } else {
await service.updatePlaylist(id: playlist.id, name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); 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); if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(playlistsProvider); ref.invalidate(playlistsProvider);
}, },
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sauvegarder'), 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 {
if (usernameController.text.isEmpty ||
passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis')));
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final result = await UserApiService().createUser(username: usernameController.text.trim(), password: passwordController.text, isAdmin: isAdmin); final result = await UserApiService().createUser(
username: usernameController.text.trim(),
password: passwordController.text,
isAdmin: isAdmin);
if (dialogContext.mounted) Navigator.pop(dialogContext); if (dialogContext.mounted) Navigator.pop(dialogContext);
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));
} }
}, },
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Créer'), 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
? null
: () async {
if (passwordController.text.isEmpty) { if (passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mot de passe requis'))); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe requis')));
return; return;
} }
setState(() => isLoading = true); setState(() => isLoading = true);
final result = await UserApiService().updatePassword(userId: user.id, newPassword: passwordController.text); final result = await UserApiService().updatePassword(
userId: user.id,
newPassword: passwordController.text);
if (dialogContext.mounted) Navigator.pop(dialogContext); if (dialogContext.mounted) Navigator.pop(dialogContext);
if (result.success && context.mounted) { if (result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Mot de passe modifié'), backgroundColor: AppColors.success)); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe modifié'),
backgroundColor: AppColors.success));
} else if (context.mounted) { } else if (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));
} }
}, },
child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Changer'), 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),
+25 -13
View File
@@ -132,7 +132,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
children: [ children: [
Text( Text(
'Welcome Back', 'Welcome Back',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -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> {
], ],
), ),
); );
} }
} }
+37 -15
View File
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/digital_clock.dart'; import '../../../core/widgets/digital_clock.dart';
import '../widgets/live_tv_tab.dart'; import '../widgets/live_tv_tab.dart';
import '../widgets/movies_tab.dart'; import '../widgets/movies_tab.dart';
@@ -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() {
@@ -80,7 +84,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
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(
@@ -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),
), ),
], ],
), ),
@@ -169,13 +177,21 @@ 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,7 +221,10 @@ 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',
+213 -89
View File
@@ -77,8 +77,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
List<Map<String, dynamic>> _audioTracks = []; List<Map<String, dynamic>> _audioTracks = [];
String _aspectRatio = 'contain'; String _aspectRatio = 'contain';
void _setupMessageListener() { void _setupMessageListener() {
// Listen for postMessage from player.html iframe // Listen for postMessage from player.html iframe
_messageSubscription = html.window.onMessage.listen((event) { _messageSubscription = html.window.onMessage.listen((event) {
@@ -95,7 +93,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_totalDuration = duration > 0 ? duration : 1; _totalDuration = duration > 0 ? duration : 1;
}); });
ref.read(playbackPositionsProvider.notifier).savePosition( ref.read(playbackPositionsProvider.notifier).savePosition(
_contentId, currentTime, duration, _contentId,
currentTime,
duration,
); );
} else if (type == 'playback_status') { } else if (type == 'playback_status') {
final status = data['status']; final status = data['status'];
@@ -182,13 +182,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
final settings = ref.read(iptvSettingsProvider); final settings = ref.read(iptvSettingsProvider);
// Only reload for seeking if we are rigorously Transcoding (FFmpeg pipe) // Only reload for seeking if we are rigorously Transcoding (FFmpeg pipe)
// If we are in Direct Proxy mode (default), we can seek natively via JS. // If we are in Direct Proxy mode (default), we can seek natively via JS.
final bool usingTranscoding = settings.transcodingMode == TranscodingMode.forced; final bool usingTranscoding =
settings.transcodingMode == TranscodingMode.forced;
if (usingTranscoding) { if (usingTranscoding) {
debugPrint('Seeking in transcoded VOD - reloading player at ${value.round()}s'); debugPrint(
ref.read(playbackPositionsProvider.notifier).savePosition(_contentId, value, _totalDuration); 'Seeking in transcoded VOD - reloading player at ${value.round()}s');
ref
.read(playbackPositionsProvider.notifier)
.savePosition(_contentId, value, _totalDuration);
_initializePlayer(startTimeOverride: value); _initializePlayer(startTimeOverride: value);
} else { } else {
// Direct play (Proxy) or Live - standard seeking happens in the browser via Range headers // Direct play (Proxy) or Live - standard seeking happens in the browser via Range headers
_sendMessage({'type': 'seek', 'value': value}); _sendMessage({'type': 'seek', 'value': value});
@@ -234,7 +237,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void _playPrevious() { void _playPrevious() {
if (widget.channels == null || widget.channels!.isEmpty) return; if (widget.channels == null || widget.channels!.isEmpty) return;
final prevIndex = (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length; final prevIndex =
(_currentIndex - 1 + widget.channels!.length) % widget.channels!.length;
_switchChannel(prevIndex); _switchChannel(prevIndex);
} }
@@ -285,7 +289,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
xtreamService.setPlaylist(widget.playlist); xtreamService.setPlaylist(widget.playlist);
// Determine effective Stream ID (from navigation or initial) // Determine effective Stream ID (from navigation or initial)
final currentStreamId = widget.channels != null && widget.channels!.isNotEmpty final currentStreamId =
widget.channels != null && widget.channels!.isNotEmpty
? widget.channels![_currentIndex].streamId ? widget.channels![_currentIndex].streamId
: widget.streamId; : widget.streamId;
@@ -305,7 +310,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Build the FFmpeg endpoint URL with streaming settings // Build the FFmpeg endpoint URL with streaming settings
final settings = ref.read(iptvSettingsProvider); final settings = ref.read(iptvSettingsProvider);
final baseUrl = html.window.location.origin; final baseUrl = html.window.location.origin;
final iptvUrl = '${widget.playlist.dns}/live/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.ts'; final iptvUrl =
'${widget.playlist.dns}/live/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.ts';
final encodedUrl = Uri.encodeComponent(iptvUrl); final encodedUrl = Uri.encodeComponent(iptvUrl);
// Map enum values to string params using if-else (avoids switch exhaustivity issues) // Map enum values to string params using if-else (avoids switch exhaustivity issues)
@@ -332,11 +338,11 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
final modeParam = settings.modeString; // direct, transcode, or auto final modeParam = settings.modeString; // direct, transcode, or auto
// Construct the stream URL directly using MPEG-TS format // Construct the stream URL directly using MPEG-TS format
final streamEndpoint = '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&buffer=$bufferParam&timeout=$timeoutParam&mode=$modeParam&ext=.ts'; final streamEndpoint =
'$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&buffer=$bufferParam&timeout=$timeoutParam&mode=$modeParam&ext=.ts';
debugPrint('PlayerScreen: Starting Direct Stream: $streamEndpoint'); debugPrint('PlayerScreen: Starting Direct Stream: $streamEndpoint');
hlsUrl = streamEndpoint; hlsUrl = streamEndpoint;
} else { } else {
// For VOD and Series // For VOD and Series
@@ -350,20 +356,25 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
bool useTranscode = settings.transcodingMode == TranscodingMode.forced; bool useTranscode = settings.transcodingMode == TranscodingMode.forced;
setState(() { setState(() {
_statusMessage = useTranscode ? 'Preparing stream...' : 'Loading video...'; _statusMessage =
useTranscode ? 'Preparing stream...' : 'Loading video...';
}); });
if (useTranscode) { if (useTranscode) {
// Transcoding Mode (FFmpeg pipe) // Transcoding Mode (FFmpeg pipe)
final encodedUrl = Uri.encodeComponent( final encodedUrl = Uri.encodeComponent(
widget.streamType == StreamType.vod widget.streamType == StreamType.vod
? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension) ? xtreamService.getVodStreamUrl(
: xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension) currentStreamId, widget.containerExtension)
: xtreamService.getSeriesStreamUrl(
currentStreamId, widget.containerExtension),
); );
String qualityParam = 'medium'; String qualityParam = 'medium';
if (settings.streamQuality == StreamQuality.low) qualityParam = 'low'; if (settings.streamQuality == StreamQuality.low) {
else if (settings.streamQuality == StreamQuality.high) qualityParam = 'high'; qualityParam = 'low';
} else if (settings.streamQuality == StreamQuality.high)
qualityParam = 'high';
// Calculate start time for seeking support // Calculate start time for seeking support
double startSeconds = startTimeOverride ?? 0; double startSeconds = startTimeOverride ?? 0;
@@ -374,9 +385,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
} }
// We force extension to .ts for mpegts.js compatibility // We force extension to .ts for mpegts.js compatibility
hlsUrl = '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&start=${startSeconds.round()}&ext=.ts'; hlsUrl =
'$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&start=${startSeconds.round()}&ext=.ts';
debugPrint('PlayerScreen: Streaming VOD (Transcoded): $hlsUrl'); debugPrint('PlayerScreen: Streaming VOD (Transcoded): $hlsUrl');
} else { } else {
// Direct/Proxy Mode (Preserves Duration) // Direct/Proxy Mode (Preserves Duration)
final rawUrl = widget.streamType == StreamType.vod final rawUrl = widget.streamType == StreamType.vod
@@ -401,11 +412,14 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Get resume position for VOD/Series // Get resume position for VOD/Series
double startTime = startTimeOverride ?? 0; double startTime = startTimeOverride ?? 0;
if (startTime <= 0 && widget.streamType != StreamType.live && _contentId.isNotEmpty) { if (startTime <= 0 &&
widget.streamType != StreamType.live &&
_contentId.isNotEmpty) {
final positions = ref.read(playbackPositionsProvider); final positions = ref.read(playbackPositionsProvider);
startTime = positions.getPosition(_contentId); startTime = positions.getPosition(_contentId);
if (startTime > 0) { if (startTime > 0) {
debugPrint('PlayerScreen: Resuming from ${startTime.toStringAsFixed(0)}s'); debugPrint(
'PlayerScreen: Resuming from ${startTime.toStringAsFixed(0)}s');
} }
} }
@@ -419,7 +433,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
final settings = ref.read(iptvSettingsProvider); final settings = ref.read(iptvSettingsProvider);
// We force transcoding/proxy usage for all VOD/Series now // We force transcoding/proxy usage for all VOD/Series now
final bool usingTranscoding = settings.transcodingMode == TranscodingMode.forced; final bool usingTranscoding =
settings.transcodingMode == TranscodingMode.forced;
final encodedHlsUrl = Uri.encodeComponent(hlsUrl); final encodedHlsUrl = Uri.encodeComponent(hlsUrl);
// Pass startTime to player.html if we are NOT using server-side transcoding (Direct/Proxy mode) // Pass startTime to player.html if we are NOT using server-side transcoding (Direct/Proxy mode)
@@ -431,7 +446,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
debugPrint('PlayerScreen: Player URL: $playerUrl'); debugPrint('PlayerScreen: Player URL: $playerUrl');
// Generate a new view ID for every load to force iframe recreation // Generate a new view ID for every load to force iframe recreation
_viewId = 'iptv-player-${widget.streamId}-${DateTime.now().millisecondsSinceEpoch}'; _viewId =
'iptv-player-${widget.streamId}-${DateTime.now().millisecondsSinceEpoch}';
// Register platform view factory // Register platform view factory
// ignore: undefined_prefixed_name // ignore: undefined_prefixed_name
@@ -540,14 +556,20 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close, color: Colors.white54), icon: const Icon(Icons.close,
color: Colors.white54),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
tooltip: 'Fermer', tooltip: 'Fermer',
), ),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text('FORMAT D\'IMAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), const Text('FORMAT D\'IMAGE',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1)),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
@@ -557,22 +579,32 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
child: Row( child: Row(
children: [ children: [
_buildRatioOption('Original', 'contain', Icons.crop_original, setDialogState), _buildRatioOption('Original', 'contain',
_buildRatioOption('Remplir', 'cover', Icons.crop_free, setDialogState), Icons.crop_original, setDialogState),
_buildRatioOption('Étirer', 'fill', Icons.aspect_ratio, setDialogState), _buildRatioOption('Remplir', 'cover',
Icons.crop_free, setDialogState),
_buildRatioOption('Étirer', 'fill',
Icons.aspect_ratio, setDialogState),
], ],
), ),
), ),
if (_audioTracks.isNotEmpty) ...[ if (_audioTracks.isNotEmpty) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
const Text('AUDIO', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), const Text('AUDIO',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1)),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
constraints: const BoxConstraints(maxHeight: 150), constraints:
const BoxConstraints(maxHeight: 150),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black26, color: Colors.black26,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.05)), border: Border.all(
color: Colors.white.withOpacity(0.05)),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -582,15 +614,27 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: ListTile( child: ListTile(
title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white, fontSize: 14)), title: Text(
subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54, fontSize: 12)), track['label'] ??
'Piste ${track['id']}',
style: const TextStyle(
color: Colors.white,
fontSize: 14)),
subtitle: Text(track['lang'] ?? '',
style: const TextStyle(
color: Colors.white54,
fontSize: 12)),
onTap: () { onTap: () {
_setAudioTrack(track['id']); _setAudioTrack(track['id']);
setDialogState(() {}); setDialogState(() {});
}, },
dense: true, dense: true,
leading: const Icon(Icons.audiotrack, size: 18, color: Colors.white54), leading: const Icon(
hoverColor: Colors.white.withOpacity(0.1), Icons.audiotrack,
size: 18,
color: Colors.white54),
hoverColor:
Colors.white.withOpacity(0.1),
), ),
); );
}).toList(), }).toList(),
@@ -600,7 +644,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
], ],
const SizedBox(height: 32), const SizedBox(height: 32),
const Text('AFFICHAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), const Text('AFFICHAGE',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1)),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( SwitchListTile(
value: showClock, value: showClock,
@@ -610,9 +659,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}); });
settingsNotifier.setShowClock(val); settingsNotifier.setShowClock(val);
}, },
title: const Text('Afficher l\'heure', style: TextStyle(color: Colors.white, fontSize: 14)), title: const Text('Afficher l\'heure',
secondary: const Icon(Icons.access_time, color: Colors.white54), style: TextStyle(
activeColor: AppColors.primary, color: Colors.white, fontSize: 14)),
secondary: const Icon(Icons.access_time,
color: Colors.white54),
activeThumbColor: AppColors.primary,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
], ],
@@ -629,7 +681,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
); );
} }
Widget _buildRatioOption(String label, String value, IconData icon, StateSetter setDialogState) { Widget _buildRatioOption(
String label, String value, IconData icon, StateSetter setDialogState) {
final isSelected = _aspectRatio == value; final isSelected = _aspectRatio == value;
return Expanded( return Expanded(
child: GestureDetector( child: GestureDetector(
@@ -637,7 +690,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
debugPrint('Changing Aspect Ratio to: $value'); debugPrint('Changing Aspect Ratio to: $value');
_setAspectRatio(value); _setAspectRatio(value);
// Save preference // Save preference
ref.read(iptvSettingsProvider.notifier).setPreferredAspectRatio(value); ref
.read(iptvSettingsProvider.notifier)
.setPreferredAspectRatio(value);
setDialogState(() {}); // Force rebuild of the dialog content setDialogState(() {}); // Force rebuild of the dialog content
}, },
child: AnimatedContainer( child: AnimatedContainer(
@@ -647,11 +702,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
color: isSelected ? AppColors.surfaceVariant : Colors.transparent, color: isSelected ? AppColors.surfaceVariant : Colors.transparent,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: isSelected ? Border.all(color: Colors.white12) : null, border: isSelected ? Border.all(color: Colors.white12) : null,
boxShadow: isSelected ? [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))] : null, boxShadow: isSelected
? [
const BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2))
]
: null,
), ),
child: Column( child: Column(
children: [ children: [
Icon(icon, color: isSelected ? Colors.white : Colors.white54, size: 20), Icon(icon,
color: isSelected ? Colors.white : Colors.white54, size: 20),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
label, label,
@@ -672,10 +735,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
return Scaffold( return Scaffold(
appBar: _showControls ? AppBar( appBar: _showControls
title: Text(widget.channels != null && widget.channels!.isNotEmpty ? AppBar(
title: Text(
widget.channels != null && widget.channels!.isNotEmpty
? widget.channels![_currentIndex].name ? widget.channels![_currentIndex].name
: widget.title,), : widget.title,
),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 0, elevation: 0,
@@ -689,7 +755,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
) : null, )
: null,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: ClipRect( body: ClipRect(
@@ -700,11 +767,14 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red), const Icon(Icons.error_outline,
size: 64, color: Colors.red),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Failed to load stream', style: TextStyle(color: Colors.white, fontSize: 18)), const Text('Failed to load stream',
style: TextStyle(color: Colors.white, fontSize: 18)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(_errorMessage!, style: const TextStyle(color: Colors.white70)), Text(_errorMessage!,
style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
@@ -756,7 +826,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// EPG Overlay (Moved behind controls for Z-ordering) // EPG Overlay (Moved behind controls for Z-ordering)
if (widget.streamType == StreamType.live) if (widget.streamType == StreamType.live)
Positioned( Positioned(
bottom: 15, // Moves EPG down to avoid blocking controls bottom:
15, // Moves EPG down to avoid blocking controls
left: 0, left: 0,
right: 0, right: 0,
child: PointerInterceptor( child: PointerInterceptor(
@@ -764,7 +835,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: IgnorePointer( child: IgnorePointer(
child: EpgOverlay( child: EpgOverlay(
streamId: widget.channels != null streamId: widget.channels != null
? widget.channels![_currentIndex].streamId ? widget.channels![_currentIndex]
.streamId
: widget.streamId, : widget.streamId,
playlist: widget.playlist, playlist: widget.playlist,
), ),
@@ -785,7 +857,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
color: Colors.black45, color: Colors.black45,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(
vertical: 12),
child: Column( child: Column(
children: [ children: [
PointerInterceptor( PointerInterceptor(
@@ -793,12 +866,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
icon: Icon( icon: Icon(
_volume == 0 _volume == 0
? Icons.volume_off ? Icons.volume_off
: _volume < 0.5 ? Icons.volume_down : Icons.volume_up, : _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
color: Colors.white, color: Colors.white,
size: 20, size: 20,
), ),
onPressed: _toggleMute, onPressed: _toggleMute,
tooltip: _volume == 0 ? 'Unmute' : 'Mute', tooltip: _volume == 0
? 'Unmute'
: 'Mute',
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -808,12 +885,18 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: SliderTheme( child: SliderTheme(
data: SliderThemeData( data: SliderThemeData(
trackHeight: 4, trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), thumbShape:
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), const RoundSliderThumbShape(
enabledThumbRadius: 6),
overlayShape:
const RoundSliderOverlayShape(
overlayRadius: 12),
activeTrackColor: Colors.white, activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24, inactiveTrackColor:
Colors.white24,
thumbColor: AppColors.primary, thumbColor: AppColors.primary,
overlayColor: AppColors.primary.withOpacity(0.2), overlayColor: AppColors.primary
.withOpacity(0.2),
), ),
child: Slider( child: Slider(
value: _volume, value: _volume,
@@ -838,17 +921,21 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: PointerInterceptor( child: PointerInterceptor(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment:
MainAxisAlignment.center,
children: [ children: [
// Previous Channel Button // Previous Channel Button
if (widget.channels != null && widget.channels!.isNotEmpty) if (widget.channels != null &&
widget.channels!.isNotEmpty)
InkWell( InkWell(
onTap: _playPrevious, onTap: _playPrevious,
borderRadius: BorderRadius.circular(50), borderRadius:
BorderRadius.circular(50),
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), color: Colors.black
.withOpacity(0.5),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
@@ -858,22 +945,29 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
), ),
), ),
if (widget.channels != null && widget.channels!.isNotEmpty) if (widget.channels != null &&
widget.channels!.isNotEmpty)
const SizedBox(width: 24), const SizedBox(width: 24),
// Play/Pause Button // Play/Pause Button
InkWell( InkWell(
onTap: _togglePlayPause, onTap: _togglePlayPause,
borderRadius: BorderRadius.circular(50), borderRadius:
BorderRadius.circular(50),
child: Container( child: Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), color:
Colors.black.withOpacity(0.5),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2), border: Border.all(
color: Colors.white,
width: 2),
), ),
child: Icon( child: Icon(
_isPlaying ? Icons.pause : Icons.play_arrow, _isPlaying
? Icons.pause
: Icons.play_arrow,
color: Colors.white, color: Colors.white,
size: 40, size: 40,
), ),
@@ -881,16 +975,20 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
// Next Channel Button // Next Channel Button
if (widget.channels != null && widget.channels!.isNotEmpty) if (widget.channels != null &&
widget.channels!.isNotEmpty)
const SizedBox(width: 24), const SizedBox(width: 24),
if (widget.channels != null && widget.channels!.isNotEmpty) if (widget.channels != null &&
widget.channels!.isNotEmpty)
InkWell( InkWell(
onTap: _playNext, onTap: _playNext,
borderRadius: BorderRadius.circular(50), borderRadius:
BorderRadius.circular(50),
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), color: Colors.black
.withOpacity(0.5),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
@@ -905,15 +1003,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
const SizedBox(width: 32), const SizedBox(width: 32),
InkWell( InkWell(
onTap: _toggleFullscreen, onTap: _toggleFullscreen,
borderRadius: BorderRadius.circular(50), borderRadius:
BorderRadius.circular(50),
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), color:
Colors.black.withOpacity(0.5),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, _isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
color: Colors.white, color: Colors.white,
size: 28, size: 28,
), ),
@@ -936,12 +1038,17 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [ children: [
SliderTheme( SliderTheme(
data: SliderThemeData( data: SliderThemeData(
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), thumbShape:
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), const RoundSliderThumbShape(
enabledThumbRadius: 8),
overlayShape:
const RoundSliderOverlayShape(
overlayRadius: 16),
activeTrackColor: AppColors.primary, activeTrackColor: AppColors.primary,
inactiveTrackColor: Colors.white24, inactiveTrackColor: Colors.white24,
thumbColor: AppColors.primary, thumbColor: AppColors.primary,
overlayColor: AppColors.primary.withOpacity(0.2), overlayColor: AppColors.primary
.withOpacity(0.2),
), ),
child: Slider( child: Slider(
value: _currentPosition, value: _currentPosition,
@@ -952,17 +1059,32 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(
horizontal: 12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
_formatDuration(Duration(seconds: _currentPosition.isFinite ? _currentPosition.toInt() : 0)), _formatDuration(Duration(
style: const TextStyle(color: Colors.white, fontSize: 12), seconds: _currentPosition
.isFinite
? _currentPosition
.toInt()
: 0)),
style: const TextStyle(
color: Colors.white,
fontSize: 12),
), ),
Text( Text(
_formatDuration(Duration(seconds: _totalDuration.isFinite ? _totalDuration.toInt() : 0)), _formatDuration(Duration(
style: const TextStyle(color: Colors.white, fontSize: 12), seconds: _totalDuration
.isFinite
? _totalDuration.toInt()
: 0)),
style: const TextStyle(
color: Colors.white,
fontSize: 12),
), ),
], ],
), ),
@@ -971,9 +1093,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
), ),
), ),
], ],
// Clock Widget (Always visible if enabled, or only with controls? User asked "met l'heure dans les options") // Clock Widget (Always visible if enabled, or only with controls? User asked "met l'heure dans les options")
@@ -983,14 +1102,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
top: 24, // Fixed top top: 24, // Fixed top
right: _showControls ? 80 : 24, // Shift left when settings visible right: _showControls
? 80
: 24, // Shift left when settings visible
child: const IgnorePointer( child: const IgnorePointer(
child: ClockWidget( child: ClockWidget(
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 8)], shadows: [
Shadow(
color: Colors.black, blurRadius: 8)
],
), ),
), ),
), ),
@@ -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,
@@ -175,13 +180,15 @@ class _PlaylistCardState extends State<_PlaylistCard> {
: Colors.white.withOpacity(0.1), : Colors.white.withOpacity(0.1),
width: 1.5, width: 1.5,
), ),
boxShadow: _isHovered ? [ boxShadow: _isHovered
? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), color: Colors.black.withOpacity(0.3),
blurRadius: 20, blurRadius: 20,
offset: const Offset(0, 10), 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> {
); );
} }
} }
@@ -118,7 +118,7 @@ class CategoryCarousel<T> extends StatelessWidget {
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,
@@ -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,
), ),
), ),
], ],
+8 -6
View File
@@ -1,10 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
/// Minimal EPG overlay - shows program title and info /// Minimal EPG overlay - shows program title and info
class EpgOverlay extends ConsumerWidget { class EpgOverlay extends ConsumerWidget {
final String streamId; final String streamId;
@@ -18,9 +16,11 @@ class EpgOverlay extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final epgAsync = ref.watch(epgByPlaylistProvider( final epgAsync = ref.watch(
epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: streamId), EpgRequestKey(playlist: playlist, streamId: streamId),
),); ),
);
return epgAsync.when( return epgAsync.when(
data: (epgEntries) { data: (epgEntries) {
@@ -46,7 +46,8 @@ class EpgOverlay extends ConsumerWidget {
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
.withOpacity(0.5), // Semi-transparent backing for readability
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.1)), border: Border.all(color: Colors.white.withOpacity(0.1)),
), ),
@@ -54,7 +55,8 @@ class EpgOverlay extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: Colors.red,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
+54 -27
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/favorites_provider.dart'; import '../providers/favorites_provider.dart';
@@ -10,7 +9,6 @@ import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart'; import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../../../core/widgets/themed_loading_screen.dart'; import '../../../core/widgets/themed_loading_screen.dart';
import 'channel_card.dart'; import 'channel_card.dart';
@@ -26,7 +24,6 @@ class LiveTVTab extends ConsumerStatefulWidget {
class _LiveTVTabState extends ConsumerState<LiveTVTab> class _LiveTVTabState extends ConsumerState<LiveTVTab>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
// State // State
String? _selectedCategory; String? _selectedCategory;
@@ -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);
@@ -76,7 +74,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// 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))
.toList();
} }
categories.sort(); categories.sort();
@@ -86,12 +86,14 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
if (_searchQuery.isNotEmpty) { if (_searchQuery.isNotEmpty) {
// Global Search // Global Search
displayedChannels = groupedChannels.values.expand((l) => l) displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery)) .where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList(); .toList();
} else if (_showFavoritesOnly) { } else if (_showFavoritesOnly) {
// Favorites // Favorites
displayedChannels = groupedChannels.values.expand((l) => l) displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => favorites.contains(c.streamId)) .where((c) => favorites.contains(c.streamId))
.toList(); .toList();
} else if (_selectedCategory != null) { } else if (_selectedCategory != null) {
@@ -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,10 +130,13 @@ 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'
: _selectedCategory ?? 'Live TV',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -141,18 +149,21 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
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),
@@ -165,7 +176,8 @@ 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),
), ),
], ],
), ),
@@ -175,14 +187,19 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// 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',
@@ -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,
@@ -250,7 +275,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
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(
+29 -12
View File
@@ -177,11 +177,16 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
} 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
.take(5)
.map(
(m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon), imageUrl: _getProxiedImageUrl(m.streamIcon),
@@ -189,13 +194,15 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
onMoreInfo: () { onMoreInfo: () {
_playMovie(m); _playMovie(m);
}, },
)).toList(); ),
)
.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,
@@ -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,7 +288,8 @@ 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
.firstWhere((element) => element.streamId == item.id);
_playMovie(movie); _playMovie(movie);
}, },
), ),
@@ -301,13 +315,16 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
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);
}, },
); );
}, },
+26 -11
View File
@@ -171,7 +171,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
} 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
.take(5)
.map(
(s) => HeroItem(
id: s.seriesId.toString(), // ID logic might differ for Series id: s.seriesId.toString(), // ID logic might differ for Series
title: s.name, title: s.name,
imageUrl: _getProxiedImageUrl(s.cover), imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s), onMoreInfo: () => _openSeries(s),
)).toList(); ),
)
.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,
@@ -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,7 +280,8 @@ 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) =>
s.name == item.title); // Fallback by title if ID mismatch
_openSeries(series); _openSeries(series);
}, },
), ),
@@ -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),
+48 -31
View File
@@ -18,7 +18,8 @@ class SettingsTab extends ConsumerStatefulWidget {
ConsumerState<SettingsTab> createState() => _SettingsTabState(); ConsumerState<SettingsTab> createState() => _SettingsTabState();
} }
class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProviderStateMixin { class _SettingsTabState extends ConsumerState<SettingsTab>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late TextEditingController _liveTvController; late TextEditingController _liveTvController;
late TextEditingController _moviesController; late TextEditingController _moviesController;
@@ -45,13 +46,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
} }
void _syncControllers(IptvSettings settings) { void _syncControllers(IptvSettings settings) {
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) { if (!_initialized ||
_liveTvController.text != settings.liveTvCategoryFilter) {
_liveTvController.text = settings.liveTvCategoryFilter; _liveTvController.text = settings.liveTvCategoryFilter;
} }
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) { if (!_initialized ||
_moviesController.text != settings.moviesCategoryFilter) {
_moviesController.text = settings.moviesCategoryFilter; _moviesController.text = settings.moviesCategoryFilter;
} }
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) { if (!_initialized ||
_seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter; _seriesController.text = settings.seriesCategoryFilter;
} }
_initialized = true; _initialized = true;
@@ -85,13 +89,15 @@ 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'),
], ],
), ),
), ),
@@ -111,8 +117,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
_buildAppearanceTab(context), _buildAppearanceTab(context),
// Admin Tab // Admin Tab
if (currentUser?.isAdmin ?? false) if (currentUser?.isAdmin ?? false) const AdminContent(),
const AdminContent(),
], ],
), ),
), ),
@@ -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,
), ),
@@ -206,18 +211,19 @@ 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,
), ),
@@ -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,11 +325,14 @@ 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,
@@ -398,8 +407,6 @@ 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,7 +416,8 @@ 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'),
), ),
@@ -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),
@@ -443,7 +452,8 @@ 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),
@@ -455,7 +465,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
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 ?',
style: TextStyle(color: Colors.white)),
content: const Text( content: const Text(
'Cette action va recharger l\'application et effacer les données temporaires.\n' 'Cette action va recharger l\'application et effacer les données temporaires.\n'
'Vos réglages et favoris sont sauvegardés sur le serveur.', 'Vos réglages et favoris sont sauvegardés sur le serveur.',
@@ -467,7 +478,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
FilledButton( FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.orange), style: FilledButton.styleFrom(
backgroundColor: Colors.orange),
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
@@ -485,7 +497,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
child: const Text('Vider et Recharger'), child: const Text('Vider et Recharger'),
), ),
], ],
) ),
); );
}, },
icon: const Icon(Icons.refresh, size: 16), icon: const Icon(Icons.refresh, size: 16),
@@ -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(),
-3
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -31,7 +30,6 @@ void main() async {
// 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');
} }
@@ -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});
@@ -104,7 +103,9 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
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> {
@@ -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,
@@ -126,7 +131,8 @@ 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,
), ),
@@ -138,7 +144,8 @@ 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,
), ),
@@ -149,7 +156,8 @@ 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,
), ),
@@ -161,7 +169,8 @@ 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,
), ),
@@ -188,13 +197,14 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
// 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.startsWith('https://')) {
dns = 'http://$dns'; 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(),
@@ -214,7 +224,9 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
_refreshPlaylists(); _refreshPlaylists();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(isEditing ? 'Playlist modifiée' : 'Playlist ajoutée'), content: Text(isEditing
? 'Playlist modifiée'
: 'Playlist ajoutée'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -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
), ),
], ],
@@ -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
@@ -665,8 +691,14 @@ class _TVTextFieldState extends State<_TVTextField> {
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
? AppColors.primary
: AppColors.textSecondary),
labelStyle: TextStyle(
color: isFocused
? AppColors.primary
: AppColors.textSecondary),
hintStyle: const TextStyle(color: AppColors.textTertiary), hintStyle: const TextStyle(color: AppColors.textTertiary),
filled: true, filled: true,
fillColor: AppColors.surface, fillColor: AppColors.surface,
@@ -674,13 +706,14 @@ class _TVTextFieldState extends State<_TVTextField> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none, 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,12 +204,18 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
actions: [ actions: [
if (widget.manageMode) if (widget.manageMode) ...[
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showPlaylistDialog(),
tooltip: 'Ajouter une playlist',
),
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
], ],
],
), ),
body: Container( body: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -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,29 +241,28 @@ 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, size: 64, color: Colors.white54), const Icon(Icons.playlist_add,
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), const SizedBox(height: 32),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () { onPressed: () => _showPlaylistDialog(),
// Logic to add playlist (usually dialog or separate screen)
// specific logic for add playlist
},
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Ajouter une Playlist'), label: const Text('Ajouter une Playlist'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
), ),
), ),
], ],
@@ -116,18 +271,19 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
} }
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();
@@ -54,7 +55,8 @@ 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();
@@ -83,7 +85,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
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) {
@@ -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
.getSeriesPaginated(
offset: _currentOffset, offset: _currentOffset,
limit: _pageSize, limit: _pageSize,
).timeout(const Duration(seconds: 15)); )
.timeout(const Duration(seconds: 15));
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -134,7 +140,8 @@ 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 (_) {}
@@ -191,17 +198,24 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
} 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
.take(3)
.map(
(s) => HeroItem(
id: s.seriesId.toString(), id: s.seriesId.toString(),
title: s.name, title: s.name,
imageUrl: _getImageUrl(s.cover), imageUrl: _getImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s), onMoreInfo: () => _openSeries(s),
)).toList(); ),
)
.toList();
return Container( return Container(
decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), decoration: const BoxDecoration(gradient: AppColors.appleTvGradient),
@@ -226,7 +240,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
try { try {
final series = _series.firstWhere((s) => s.seriesId.toString() == item.id); final series = _series.firstWhere(
(s) => s.seriesId.toString() == item.id);
_openSeries(series); _openSeries(series);
} catch (e) { } catch (e) {
// Fallback logic not critical // Fallback logic not critical
@@ -240,7 +255,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
// Search Bar (moved below carousel) // Search Bar (moved below carousel)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TVFocusable( child: TVFocusable(
onPressed: () { onPressed: () {
setState(() => _isSearchEditing = true); setState(() => _isSearchEditing = true);
@@ -261,7 +277,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
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: ExcludeFocus( child: ExcludeFocus(
@@ -270,7 +287,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
readOnly: !_isSearchEditing, readOnly: !_isSearchEditing,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), style: const TextStyle(
fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Rechercher une série...', hintText: 'Rechercher une série...',
border: InputBorder.none, border: InputBorder.none,
@@ -278,19 +296,25 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
contentPadding: EdgeInsets.only(bottom: 11), contentPadding: EdgeInsets.only(bottom: 11),
), ),
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false), onSubmitted: (_) =>
setState(() => _isSearchEditing = false),
), ),
), ),
), ),
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),
), ),
], ],
), ),
@@ -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()),
);
} }
} }
@@ -86,13 +89,19 @@ 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(
@@ -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,7 +187,8 @@ 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;
@@ -198,8 +209,10 @@ 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
@@ -247,7 +260,8 @@ 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);
} }
@@ -256,11 +270,13 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
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));
@@ -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();
}); });
+8 -5
View File
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../../core/theme/app_colors.dart';
class TVFocusable extends StatefulWidget { class TVFocusable extends StatefulWidget {
final Widget child; final Widget child;
@@ -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,