diff --git a/bin/database/database.dart b/bin/database/database.dart index 2a980f5..d0b5f74 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -12,8 +12,8 @@ class AppDatabase { /// Initialize database and create tables Future init() async { - final dbPath = '/app/data/xtremflow.db'; - + const dbPath = '/app/data/xtremflow.db'; + // Ensure data directory exists final dir = Directory('/app/data'); if (!await dir.exists()) { @@ -21,7 +21,7 @@ class AppDatabase { } _db = sqlite3.open(dbPath); - + await _createTables(); print('Database initialized: $dbPath'); } @@ -79,9 +79,12 @@ class AppDatabase { '''); // Indexes - _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); - _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); - _db.execute('CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); } /// Seed default admin user if no users exist @@ -93,10 +96,13 @@ class AppDatabase { final adminId = _uuid.v4(); final passwordHash = PasswordHasher.hash('admin'); - _db.execute(''' + _db.execute( + ''' INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, 1) - ''', [adminId, 'admin', passwordHash],); + ''', + [adminId, 'admin', passwordHash], + ); print('Default admin user created (username: admin, password: admin)'); } @@ -148,10 +154,13 @@ class AppDatabase { final userId = _uuid.v4(); final passwordHash = PasswordHasher.hash(password); - _db.execute(''' + _db.execute( + ''' INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?) - ''', [userId, username, passwordHash, isAdmin ? 1 : 0],); + ''', + [userId, username, passwordHash, isAdmin ? 1 : 0], + ); return User( id: userId, @@ -197,10 +206,13 @@ class AppDatabase { final token = _uuid.v4(); final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7)); - _db.execute(''' + _db.execute( + ''' INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?) - ''', [sessionId, userId, token, expiresAt.toIso8601String()],); + ''', + [sessionId, userId, token, expiresAt.toIso8601String()], + ); return models.Session( id: sessionId, @@ -221,7 +233,7 @@ class AppDatabase { if (result.isEmpty) return null; final session = models.Session.fromMap(result.first); - + // Check if expired if (session.isExpired) { deleteSession(token); @@ -279,10 +291,13 @@ class AppDatabase { final playlistId = _uuid.v4(); final now = DateTime.now().toIso8601String(); - _db.execute(''' + _db.execute( + ''' INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', [playlistId, userId, name, serverUrl, username, password, dns, now, now],); + ''', + [playlistId, userId, name, serverUrl, username, password, dns, now, now], + ); return Playlist( id: playlistId, @@ -308,11 +323,14 @@ class AppDatabase { }) { final now = DateTime.now().toIso8601String(); - _db.execute(''' + _db.execute( + ''' UPDATE playlists SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? WHERE id = ? - ''', [name, serverUrl, username, password, dns, now, playlistId],); + ''', + [name, serverUrl, username, password, dns, now, playlistId], + ); return getPlaylistById(playlistId)!; } @@ -338,15 +356,18 @@ class AppDatabase { /// Update user settings void updateUserSettings(String userId, String settingsJson) { final now = DateTime.now().toIso8601String(); - + // UPSERT (Insert or Replace) - _db.execute(''' + _db.execute( + ''' INSERT INTO user_settings (user_id, settings_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at - ''', [userId, settingsJson, now]); + ''', + [userId, settingsJson, now], + ); } /// Close database connection diff --git a/lib/core/api/dns_interceptor.dart b/lib/core/api/dns_interceptor.dart index 2755afb..ec48ca2 100644 --- a/lib/core/api/dns_interceptor.dart +++ b/lib/core/api/dns_interceptor.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; /// Interceptor to fallback to DNS-over-HTTPS if standard DNS fails /// Useful for emulators or restricted networks @@ -15,7 +14,9 @@ class DnsFallbackInterceptor extends Interceptor { @override Future onError( - DioException err, ErrorInterceptorHandler handler) async { + DioException err, + ErrorInterceptorHandler handler, + ) async { // Basic check for connection/host lookup errors if (_isDnsError(err)) { final uri = err.requestOptions.uri; diff --git a/lib/core/api/dns_resolver.dart b/lib/core/api/dns_resolver.dart index e02dbff..be84c85 100644 --- a/lib/core/api/dns_resolver.dart +++ b/lib/core/api/dns_resolver.dart @@ -38,8 +38,10 @@ class DnsResolver { return ip; } - static Future _queryDoH(String url, - {Map? headers}) async { + static Future _queryDoH( + String url, { + Map? headers, + }) async { try { final client = HttpClient() ..connectionTimeout = const Duration(seconds: 10) diff --git a/lib/core/services/ip_service.dart b/lib/core/services/ip_service.dart index f7012dd..5b7d048 100644 --- a/lib/core/services/ip_service.dart +++ b/lib/core/services/ip_service.dart @@ -27,7 +27,8 @@ class IpService { _cachedCountry = data['country']; _cachedCountryCode = data['countryCode']; debugPrint( - 'IpService: Detected country: $_cachedCountry ($_cachedCountryCode)'); + 'IpService: Detected country: $_cachedCountry ($_cachedCountryCode)', + ); } } } catch (e) { diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index c00066d..bbacda0 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -48,43 +48,52 @@ class AppTheme { // Typography: San Francisco style (using Inter as proxy) textTheme: TextTheme( displayLarge: GoogleFonts.inter( - fontSize: 56, - fontWeight: FontWeight.w700, - letterSpacing: -1.0, - color: AppColors.textPrimary), + fontSize: 56, + fontWeight: FontWeight.w700, + letterSpacing: -1.0, + color: AppColors.textPrimary, + ), displayMedium: GoogleFonts.inter( - fontSize: 48, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: AppColors.textPrimary), + fontSize: 48, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: AppColors.textPrimary, + ), displaySmall: GoogleFonts.inter( - fontSize: 32, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 32, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), headlineMedium: GoogleFonts.inter( - fontSize: 24, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), titleLarge: GoogleFonts.inter( - fontSize: 20, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), titleMedium: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), bodyLarge: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w400, - color: AppColors.textSecondary), + fontSize: 16, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), bodyMedium: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.textSecondary), + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), labelLarge: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), ), // AppBar: Transparent / Glass @@ -106,7 +115,9 @@ class AppTheme { filled: true, fillColor: AppColors.surface, contentPadding: const EdgeInsets.symmetric( - horizontal: spacing24, vertical: spacing16), + horizontal: spacing24, + vertical: spacing16, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none, @@ -130,15 +141,18 @@ class AppTheme { elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusMd)), + borderRadius: BorderRadius.circular(radiusMd), + ), textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16), ).copyWith( overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.hovered)) + if (states.contains(WidgetState.hovered)) { return Colors.black.withOpacity(0.1); - if (states.contains(WidgetState.pressed)) + } + if (states.contains(WidgetState.pressed)) { return Colors.black.withOpacity(0.2); + } return null; }), ), diff --git a/lib/core/utils/platform_utils.dart b/lib/core/utils/platform_utils.dart index 7e26935..e1979b9 100644 --- a/lib/core/utils/platform_utils.dart +++ b/lib/core/utils/platform_utils.dart @@ -1,4 +1,6 @@ /// Platform utilities - conditional export /// Uses web implementation on web, stub on other platforms +library; + export 'platform_utils_stub.dart' if (dart.library.html) 'platform_utils_web.dart'; diff --git a/lib/core/utils/platform_utils_stub.dart b/lib/core/utils/platform_utils_stub.dart index e1c831f..af1486f 100644 --- a/lib/core/utils/platform_utils_stub.dart +++ b/lib/core/utils/platform_utils_stub.dart @@ -1,5 +1,6 @@ /// Platform-specific utilities for web /// This file is used when compiling for web +library; /// Check if we're running on HTTPS bool isHttps() { diff --git a/lib/core/utils/platform_utils_web.dart b/lib/core/utils/platform_utils_web.dart index 877510c..270c076 100644 --- a/lib/core/utils/platform_utils_web.dart +++ b/lib/core/utils/platform_utils_web.dart @@ -1,5 +1,7 @@ /// Platform-specific utilities for web /// This file is used when compiling for web +library; + import 'dart:html' as html; /// Check if we're running on HTTPS diff --git a/lib/features/admin/screens/admin_panel.dart b/lib/features/admin/screens/admin_panel.dart index b493095..da7418a 100644 --- a/lib/features/admin/screens/admin_panel.dart +++ b/lib/features/admin/screens/admin_panel.dart @@ -183,11 +183,13 @@ class _PlaylistsTab extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, 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( label: 'Add Playlist', icon: Icons.add, @@ -200,20 +202,27 @@ class _PlaylistsTab extends ConsumerWidget { child: playlistsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center( - child: Text('Error: $error', - style: const TextStyle(color: AppColors.error))), + child: Text( + 'Error: $error', + style: const TextStyle(color: AppColors.error), + ), + ), data: (playlists) { if (playlists.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), - Text('No playlists configured', - style: Theme.of(context).textTheme.titleMedium), + Text( + 'No playlists configured', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), TextButton( onPressed: () => _showPlaylistDialog(context, ref), @@ -242,25 +251,31 @@ class _PlaylistsTab extends ConsumerWidget { color: AppColors.surface, 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), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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), - 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), + ), ], ), ), @@ -294,8 +309,11 @@ 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 dnsController = TextEditingController(text: playlist?.dns); final usernameController = TextEditingController(text: playlist?.username); @@ -307,41 +325,44 @@ class _PlaylistsTab extends ConsumerWidget { context: context, builder: (dialogContext) => StatefulBuilder( 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( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( - controller: nameController, - decoration: const InputDecoration(labelText: 'Nom')), + controller: nameController, + decoration: const InputDecoration(labelText: 'Nom'), + ), const SizedBox(height: 12), TextField( - controller: dnsController, - decoration: const InputDecoration( - labelText: 'URL du serveur', - hintText: 'http://server.com:8080')), + controller: dnsController, + decoration: const InputDecoration( + labelText: 'URL du serveur', + hintText: 'http://server.com:8080', + ), + ), const SizedBox(height: 12), TextField( - controller: usernameController, - decoration: - const InputDecoration(labelText: 'Utilisateur')), + controller: usernameController, + decoration: const InputDecoration(labelText: 'Utilisateur'), + ), const SizedBox(height: 12), TextField( - controller: passwordController, - decoration: - const InputDecoration(labelText: 'Mot de passe'), - obscureText: true), + controller: passwordController, + decoration: const InputDecoration(labelText: 'Mot de passe'), + obscureText: true, + ), ], ), ), actions: [ TextButton( - onPressed: - isLoading ? null : () => Navigator.pop(dialogContext), - child: const Text('Annuler')), + onPressed: isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler'), + ), FilledButton( onPressed: isLoading ? null @@ -349,8 +370,10 @@ class _PlaylistsTab extends ConsumerWidget { if (nameController.text.isEmpty || dnsController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Tous les champs sont requis'))); + const SnackBar( + content: Text('Tous les champs sont requis'), + ), + ); return; } setState(() => isLoading = true); @@ -359,17 +382,19 @@ class _PlaylistsTab extends ConsumerWidget { .replaceAll(RegExp(r'/$'), ''); if (playlist == null) { await service.createPlaylist( - name: nameController.text.trim(), - dns: dns, - username: usernameController.text.trim(), - password: passwordController.text); + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text, + ); } else { await service.updatePlaylist( - id: playlist.id, - name: nameController.text.trim(), - dns: dns, - username: usernameController.text.trim(), - password: passwordController.text); + id: playlist.id, + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text, + ); } if (dialogContext.mounted) Navigator.pop(dialogContext); ref.invalidate(playlistsProvider); @@ -378,7 +403,8 @@ class _PlaylistsTab extends ConsumerWidget { ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Text('Sauvegarder'), ), ], @@ -388,7 +414,10 @@ class _PlaylistsTab extends ConsumerWidget { } void _deletePlaylist( - BuildContext context, WidgetRef ref, PlaylistConfig playlist) { + BuildContext context, + WidgetRef ref, + PlaylistConfig playlist, + ) { showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -396,8 +425,9 @@ class _PlaylistsTab extends ConsumerWidget { content: Text('Supprimer "${playlist.name}" ?'), actions: [ TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Annuler')), + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Annuler'), + ), FilledButton( onPressed: () async { Navigator.pop(dialogContext); @@ -428,11 +458,13 @@ class _UsersTab extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, 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( label: 'Create User', icon: Icons.person_add, @@ -445,20 +477,27 @@ class _UsersTab extends ConsumerWidget { child: usersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center( - child: Text('Error: $error', - style: const TextStyle(color: AppColors.error))), + child: Text( + 'Error: $error', + style: const TextStyle(color: AppColors.error), + ), + ), data: (users) { if (users.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), - Text('No users found', - style: Theme.of(context).textTheme.titleMedium), + Text( + 'No users found', + style: Theme.of(context).textTheme.titleMedium, + ), ], ), ); @@ -501,45 +540,57 @@ class _UsersTab extends ConsumerWidget { children: [ Row( 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) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: AppColors.focusColor .withOpacity(0.2), 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), - 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( - icon: const Icon(Icons.more_vert, - color: Colors.white70), + icon: const Icon( + Icons.more_vert, + color: Colors.white70, + ), color: AppColors.surface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), + borderRadius: BorderRadius.circular(12), + ), onSelected: (value) { switch (value) { case 'password': @@ -552,21 +603,32 @@ class _UsersTab extends ConsumerWidget { }, itemBuilder: (context) => [ const PopupMenuItem( - value: 'password', - child: Row(children: [ + value: 'password', + child: Row( + children: [ Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), - Text('Change Password') - ])), + Text('Change Password'), + ], + ), + ), const PopupMenuItem( - value: 'delete', - child: Row(children: [ - Icon(Icons.delete, - color: AppColors.error, size: 18), + value: 'delete', + child: Row( + children: [ + Icon( + Icons.delete, + color: AppColors.error, + size: 18, + ), SizedBox(width: 8), - Text('Delete', - style: TextStyle(color: AppColors.error)) - ])), + Text( + 'Delete', + style: TextStyle(color: AppColors.error), + ), + ], + ), + ), ], ), ], @@ -596,17 +658,21 @@ class _UsersTab extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ TextField( - controller: usernameController, - decoration: const InputDecoration( - labelText: 'Nom d\'utilisateur', - prefixIcon: Icon(Icons.person_outline))), + controller: usernameController, + decoration: const InputDecoration( + labelText: 'Nom d\'utilisateur', + prefixIcon: Icon(Icons.person_outline), + ), + ), const SizedBox(height: 12), TextField( - controller: passwordController, - decoration: const InputDecoration( - labelText: 'Mot de passe', - prefixIcon: Icon(Icons.lock_outline)), - obscureText: true), + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Mot de passe', + prefixIcon: Icon(Icons.lock_outline), + ), + obscureText: true, + ), const SizedBox(height: 16), SwitchListTile( title: const Text('Administrateur'), @@ -618,9 +684,9 @@ class _UsersTab extends ConsumerWidget { ), actions: [ TextButton( - onPressed: - isLoading ? null : () => Navigator.pop(dialogContext), - child: const Text('Annuler')), + onPressed: isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler'), + ), FilledButton( onPressed: isLoading ? null @@ -628,28 +694,35 @@ class _UsersTab extends ConsumerWidget { if (usernameController.text.isEmpty || passwordController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Tous les champs sont requis'))); + const SnackBar( + content: Text('Tous les champs sont requis'), + ), + ); return; } setState(() => isLoading = true); final result = await UserApiService().createUser( - username: usernameController.text.trim(), - password: passwordController.text, - isAdmin: isAdmin); + username: usernameController.text.trim(), + password: passwordController.text, + isAdmin: isAdmin, + ); if (dialogContext.mounted) Navigator.pop(dialogContext); ref.invalidate(usersProvider); if (!result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( content: Text(result.error ?? 'Erreur'), - backgroundColor: AppColors.error)); + backgroundColor: AppColors.error, + ), + ); } }, child: isLoading ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Text('Créer'), ), ], @@ -659,7 +732,10 @@ class _UsersTab extends ConsumerWidget { } void _showChangePasswordDialog( - BuildContext context, WidgetRef ref, AppUser user) { + BuildContext context, + WidgetRef ref, + AppUser user, + ) { final passwordController = TextEditingController(); bool isLoading = false; @@ -669,47 +745,58 @@ class _UsersTab extends ConsumerWidget { builder: (context, setState) => AlertDialog( 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), + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Nouveau mot de passe', + prefixIcon: Icon(Icons.lock_outline), + ), + obscureText: true, + ), actions: [ TextButton( - onPressed: - isLoading ? null : () => Navigator.pop(dialogContext), - child: const Text('Annuler')), + onPressed: isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler'), + ), FilledButton( onPressed: isLoading ? null : () async { if (passwordController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mot de passe requis'))); + const SnackBar( + content: Text('Mot de passe requis'), + ), + ); return; } setState(() => isLoading = true); final result = await UserApiService().updatePassword( - userId: user.id, - newPassword: passwordController.text); + userId: user.id, + newPassword: passwordController.text, + ); if (dialogContext.mounted) Navigator.pop(dialogContext); if (result.success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mot de passe modifié'), - backgroundColor: AppColors.success)); + const SnackBar( + content: Text('Mot de passe modifié'), + backgroundColor: AppColors.success, + ), + ); } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( content: Text(result.error ?? 'Erreur'), - backgroundColor: AppColors.error)); + backgroundColor: AppColors.error, + ), + ); } }, child: isLoading ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Text('Changer'), ), ], @@ -724,20 +811,25 @@ class _UsersTab extends ConsumerWidget { builder: (dialogContext) => AlertDialog( title: const Text('Supprimer l\'utilisateur'), content: Text( - 'Supprimer "${user.username}" ? Cette action est irréversible.'), + 'Supprimer "${user.username}" ? Cette action est irréversible.', + ), actions: [ TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Annuler')), + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Annuler'), + ), FilledButton( onPressed: () async { Navigator.pop(dialogContext); final result = await UserApiService().deleteUser(user.id); ref.invalidate(usersProvider); if (!result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( content: Text(result.error ?? 'Erreur'), - backgroundColor: AppColors.error)); + backgroundColor: AppColors.error, + ), + ); } }, style: FilledButton.styleFrom(backgroundColor: AppColors.error), diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index 17f3fc2..56ce28a 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../providers/auth_provider.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/components/ui_components.dart'; class LoginScreen extends ConsumerStatefulWidget { @@ -148,24 +147,29 @@ class _LoginScreenState extends ConsumerState { labelText: 'Username', labelStyle: const TextStyle(color: Colors.white70), - prefixIcon: const Icon(Icons.person_outline, - color: Colors.white70), + prefixIcon: const Icon( + Icons.person_outline, + color: Colors.white70, + ), filled: true, fillColor: Colors.white.withOpacity(0.05), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.white.withOpacity(0.1)), + color: Colors.white.withOpacity(0.1), + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.white.withOpacity(0.1)), + color: Colors.white.withOpacity(0.1), + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide( - color: AppColors.primary), + color: AppColors.primary, + ), ), ), validator: (value) { @@ -186,24 +190,29 @@ class _LoginScreenState extends ConsumerState { labelText: 'Password', labelStyle: const TextStyle(color: Colors.white70), - prefixIcon: const Icon(Icons.lock_outline, - color: Colors.white70), + prefixIcon: const Icon( + Icons.lock_outline, + color: Colors.white70, + ), filled: true, fillColor: Colors.white.withOpacity(0.05), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.white.withOpacity(0.1)), + color: Colors.white.withOpacity(0.1), + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.white.withOpacity(0.1)), + color: Colors.white.withOpacity(0.1), + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide( - color: AppColors.primary), + color: AppColors.primary, + ), ), suffixIcon: IconButton( icon: Icon( diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index b6dacfb..23b10fe 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -33,7 +33,7 @@ class _DashboardScreenState extends ConsumerState Icons.live_tv, Icons.movie, Icons.tv, - Icons.settings + Icons.settings, ]; @override @@ -85,7 +85,8 @@ class _DashboardScreenState extends ConsumerState child: Container( height: 50, constraints: const BoxConstraints( - maxWidth: 600), // Prevent taking too much space + maxWidth: 600, + ), // Prevent taking too much space decoration: BoxDecoration( color: Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(30), @@ -105,7 +106,9 @@ class _DashboardScreenState extends ConsumerState unselectedLabelColor: Colors.white70, // Fixed: Improved readability labelStyle: GoogleFonts.inter( - fontWeight: FontWeight.w700, fontSize: 13), + fontWeight: FontWeight.w700, + fontSize: 13, + ), overlayColor: WidgetStateProperty.all(Colors.transparent), tabs: List.generate(_tabs.length, (index) { return Tab( @@ -182,16 +185,21 @@ class _DashboardScreenState extends ConsumerState children: [ // Padding top to account for the transparent AppBar Padding( - padding: const EdgeInsets.only(top: 80), - child: LiveTVTab(playlist: widget.playlist)), + padding: const EdgeInsets.only(top: 80), + child: LiveTVTab(playlist: widget.playlist), + ), Padding( - padding: const EdgeInsets.only(top: 80), - child: MoviesTab(playlist: widget.playlist)), + padding: const EdgeInsets.only(top: 80), + child: MoviesTab(playlist: widget.playlist), + ), Padding( - padding: const EdgeInsets.only(top: 80), - child: SeriesTab(playlist: widget.playlist)), + padding: const EdgeInsets.only(top: 80), + child: SeriesTab(playlist: widget.playlist), + ), const Padding( - padding: EdgeInsets.only(top: 80), child: SettingsTab()), + padding: EdgeInsets.only(top: 80), + child: SettingsTab(), + ), ], ), ], @@ -206,10 +214,12 @@ class _DashboardScreenState extends ConsumerState width: 36, height: 36, 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), ), child: const Icon(Icons.play_arrow_rounded, color: Colors.white), @@ -222,9 +232,10 @@ class _DashboardScreenState extends ConsumerState Text( 'Xtrem', style: GoogleFonts.inter( - fontWeight: FontWeight.w800, - fontSize: 16, - color: Colors.white), + fontWeight: FontWeight.w800, + fontSize: 16, + color: Colors.white, + ), ), Text( 'Flow', diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 17b4c09..7ac38dd 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -2,9 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:universal_html/html.dart' as html; import '../../../core/shims/ui_web.dart' as ui_web; -import 'dart:convert'; import 'dart:async'; -import 'package:http/http.dart' as http; import '../providers/xtream_provider.dart'; import '../providers/playback_positions_provider.dart'; import '../providers/settings_provider.dart'; @@ -187,7 +185,8 @@ class _PlayerScreenState extends ConsumerState { if (usingTranscoding) { debugPrint( - 'Seeking in transcoded VOD - reloading player at ${value.round()}s'); + 'Seeking in transcoded VOD - reloading player at ${value.round()}s', + ); ref .read(playbackPositionsProvider.notifier) .savePosition(_contentId, value, _totalDuration); @@ -365,9 +364,13 @@ class _PlayerScreenState extends ConsumerState { final encodedUrl = Uri.encodeComponent( widget.streamType == StreamType.vod ? xtreamService.getVodStreamUrl( - currentStreamId, widget.containerExtension) + currentStreamId, + widget.containerExtension, + ) : xtreamService.getSeriesStreamUrl( - currentStreamId, widget.containerExtension), + currentStreamId, + widget.containerExtension, + ), ); String qualityParam = 'medium'; @@ -419,7 +422,8 @@ class _PlayerScreenState extends ConsumerState { startTime = positions.getPosition(_contentId); if (startTime > 0) { debugPrint( - 'PlayerScreen: Resuming from ${startTime.toStringAsFixed(0)}s'); + 'PlayerScreen: Resuming from ${startTime.toStringAsFixed(0)}s', + ); } } @@ -556,20 +560,25 @@ class _PlayerScreenState extends ConsumerState { ), ), IconButton( - icon: const Icon(Icons.close, - color: Colors.white54), + icon: const Icon( + Icons.close, + color: Colors.white54, + ), onPressed: () => Navigator.pop(context), tooltip: 'Fermer', ), ], ), 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), Container( padding: const EdgeInsets.all(4), @@ -579,23 +588,38 @@ class _PlayerScreenState extends ConsumerState { ), child: Row( children: [ - _buildRatioOption('Original', 'contain', - Icons.crop_original, setDialogState), - _buildRatioOption('Remplir', 'cover', - Icons.crop_free, setDialogState), - _buildRatioOption('Étirer', 'fill', - Icons.aspect_ratio, setDialogState), + _buildRatioOption( + 'Original', + 'contain', + Icons.crop_original, + setDialogState, + ), + _buildRatioOption( + 'Remplir', + 'cover', + Icons.crop_free, + setDialogState, + ), + _buildRatioOption( + 'Étirer', + 'fill', + Icons.aspect_ratio, + setDialogState, + ), ], ), ), if (_audioTracks.isNotEmpty) ...[ 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), Container( constraints: @@ -604,7 +628,8 @@ class _PlayerScreenState extends ConsumerState { color: Colors.black26, borderRadius: BorderRadius.circular(16), border: Border.all( - color: Colors.white.withOpacity(0.05)), + color: Colors.white.withOpacity(0.05), + ), ), child: ClipRRect( borderRadius: BorderRadius.circular(16), @@ -615,24 +640,30 @@ class _PlayerScreenState extends ConsumerState { color: Colors.transparent, child: ListTile( title: Text( - 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)), + 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: () { _setAudioTrack(track['id']); setDialogState(() {}); }, dense: true, leading: const Icon( - Icons.audiotrack, - size: 18, - color: Colors.white54), + Icons.audiotrack, + size: 18, + color: Colors.white54, + ), hoverColor: Colors.white.withOpacity(0.1), ), @@ -644,12 +675,15 @@ class _PlayerScreenState extends ConsumerState { ), ], 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), SwitchListTile( value: showClock, @@ -659,11 +693,17 @@ class _PlayerScreenState extends ConsumerState { }); settingsNotifier.setShowClock(val); }, - title: const Text('Afficher l\'heure', - style: TextStyle( - color: Colors.white, fontSize: 14)), - secondary: const Icon(Icons.access_time, - color: Colors.white54), + title: const Text( + 'Afficher l\'heure', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + secondary: const Icon( + Icons.access_time, + color: Colors.white54, + ), activeThumbColor: AppColors.primary, contentPadding: EdgeInsets.zero, ), @@ -682,7 +722,11 @@ class _PlayerScreenState extends ConsumerState { } Widget _buildRatioOption( - String label, String value, IconData icon, StateSetter setDialogState) { + String label, + String value, + IconData icon, + StateSetter setDialogState, + ) { final isSelected = _aspectRatio == value; return Expanded( child: GestureDetector( @@ -705,16 +749,20 @@ class _PlayerScreenState extends ConsumerState { boxShadow: isSelected ? [ const BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2)) + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ), ] : null, ), child: Column( 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), Text( label, @@ -767,14 +815,21 @@ class _PlayerScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, 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 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), - Text(_errorMessage!, - style: const TextStyle(color: Colors.white70)), + Text( + _errorMessage!, + style: const TextStyle(color: Colors.white70), + ), const SizedBox(height: 24), ElevatedButton( onPressed: () { @@ -858,7 +913,8 @@ class _PlayerScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), ), padding: const EdgeInsets.symmetric( - vertical: 12), + vertical: 12, + ), child: Column( children: [ PointerInterceptor( @@ -887,10 +943,12 @@ class _PlayerScreenState extends ConsumerState { trackHeight: 4, thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 6), + enabledThumbRadius: 6, + ), overlayShape: const RoundSliderOverlayShape( - overlayRadius: 12), + overlayRadius: 12, + ), activeTrackColor: Colors.white, inactiveTrackColor: Colors.white24, @@ -961,8 +1019,9 @@ class _PlayerScreenState extends ConsumerState { Colors.black.withOpacity(0.5), shape: BoxShape.circle, border: Border.all( - color: Colors.white, - width: 2), + color: Colors.white, + width: 2, + ), ), child: Icon( _isPlaying @@ -1040,10 +1099,12 @@ class _PlayerScreenState extends ConsumerState { data: SliderThemeData( thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8), + enabledThumbRadius: 8, + ), overlayShape: const RoundSliderOverlayShape( - overlayRadius: 16), + overlayRadius: 16, + ), activeTrackColor: AppColors.primary, inactiveTrackColor: Colors.white24, thumbColor: AppColors.primary, @@ -1060,31 +1121,40 @@ class _PlayerScreenState extends ConsumerState { ), Padding( padding: const EdgeInsets.symmetric( - horizontal: 12), + horizontal: 12, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _formatDuration(Duration( + _formatDuration( + Duration( seconds: _currentPosition .isFinite ? _currentPosition .toInt() - : 0)), + : 0, + ), + ), style: const TextStyle( - color: Colors.white, - fontSize: 12), + color: Colors.white, + fontSize: 12, + ), ), Text( - _formatDuration(Duration( + _formatDuration( + Duration( seconds: _totalDuration .isFinite ? _totalDuration.toInt() - : 0)), + : 0, + ), + ), style: const TextStyle( - color: Colors.white, - fontSize: 12), + color: Colors.white, + fontSize: 12, + ), ), ], ), @@ -1113,7 +1183,9 @@ class _PlayerScreenState extends ConsumerState { fontWeight: FontWeight.bold, shadows: [ Shadow( - color: Colors.black, blurRadius: 8) + color: Colors.black, + blurRadius: 8, + ), ], ), ), diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 8d325eb..608d76d 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -27,7 +27,9 @@ class PlaylistSelectionScreen extends ConsumerWidget { title: Text( 'Select Playlist', style: GoogleFonts.roboto( - fontWeight: FontWeight.w600, color: Colors.white), + fontWeight: FontWeight.w600, + color: Colors.white, + ), ), backgroundColor: Colors.transparent, elevation: 0, @@ -71,9 +73,13 @@ class PlaylistSelectionScreen extends ConsumerWidget { children: [ Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), 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), ElevatedButton( onPressed: () => ref.refresh(playlistsProvider), @@ -118,7 +124,11 @@ class PlaylistSelectionScreen extends ConsumerWidget { return GridView.builder( padding: const EdgeInsets.fromLTRB( - 24, 100, 24, 24), // Top padding for transparent AppBar + 24, + 100, + 24, + 24, + ), // Top padding for transparent AppBar gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 24, diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index e18bd1b..953ff59 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -331,8 +331,10 @@ class XtreamService { } /// Get movies with pagination support - Future> getMoviesPaginated( - {int offset = 0, int limit = 100}) async { + Future> getMoviesPaginated({ + int offset = 0, + int limit = 100, + }) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { @@ -411,8 +413,10 @@ class XtreamService { } /// Get series with pagination support (returns flat list) - Future> getSeriesPaginated( - {int offset = 0, int limit = 100}) async { + Future> getSeriesPaginated({ + int offset = 0, + int limit = 100, + }) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index a8ede25..98f3290 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -74,7 +74,7 @@ class XtreamServiceMobile { print('XtreamServiceMobile: Will use IP $_resolvedIp for ${uri.host}'); } else { print( - 'XtreamServiceMobile: DNS resolution failed, will use hostname directly'); + 'XtreamServiceMobile: DNS resolution failed, will use hostname directly',); } } } @@ -162,8 +162,9 @@ class XtreamServiceMobile { ); if (response.data is! Map) { - if (response.data is String) + if (response.data is String) { throw Exception('Auth Error: ${response.data}'); + } throw Exception('Invalid auth response format'); } return response.data as Map; @@ -188,8 +189,9 @@ class XtreamServiceMobile { ); if (response.data is! List) { - if (response.data is String) + if (response.data is String) { throw Exception('Category Error: ${response.data}'); + } return {}; } final List categories = response.data as List; @@ -229,8 +231,9 @@ class XtreamServiceMobile { ); if (response.data is! List) { - if (response.data is String) + if (response.data is String) { throw Exception('Live TV Error: ${response.data}'); + } return {}; } final List streams = response.data as List; @@ -325,7 +328,7 @@ class XtreamServiceMobile { /// Get movies with pagination support (uses in-memory cache for performance) Future> getMoviesPaginated( - {int offset = 0, int limit = 100}) async { + {int offset = 0, int limit = 100,}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { @@ -345,8 +348,9 @@ class XtreamServiceMobile { ); if (response.data is! List) { - if (response.data is String) + if (response.data is String) { throw Exception('VOD Error: ${response.data}'); + } _cachedMoviesRaw = []; } else { _cachedMoviesRaw = response.data as List; @@ -419,7 +423,7 @@ class XtreamServiceMobile { /// Get series with pagination support (uses in-memory cache for performance) Future> getSeriesPaginated( - {int offset = 0, int limit = 100}) async { + {int offset = 0, int limit = 100,}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { @@ -439,8 +443,9 @@ class XtreamServiceMobile { ); if (response.data is! List) { - if (response.data is String) + if (response.data is String) { throw Exception('Series Error: ${response.data}'); + } _cachedSeriesRaw = []; } else { _cachedSeriesRaw = response.data as List; @@ -580,7 +585,7 @@ class XtreamServiceMobile { /// Get short EPG as ShortEPG object (for EPGWidget) /// Caches results for 12 hours by default Future getShortEPG(String streamId, - {bool forceRefresh = false}) async { + {bool forceRefresh = false,}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 0166a97..24d82a6 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -116,8 +116,10 @@ class _LiveTVTabState extends ConsumerState _searchQuery.isEmpty && !_showFavoritesOnly) ...[ IconButton( - icon: const Icon(Icons.arrow_back, - color: AppColors.textPrimary), + icon: const Icon( + Icons.arrow_back, + color: AppColors.textPrimary, + ), onPressed: () { setState(() { _selectedCategory = null; @@ -156,14 +158,19 @@ class _LiveTVTabState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - const Icon(Icons.search, - size: 20, color: AppColors.textSecondary), + const Icon( + Icons.search, + size: 20, + color: AppColors.textSecondary, + ), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, style: const TextStyle( - fontSize: 14, color: Colors.white), + fontSize: 14, + color: Colors.white, + ), decoration: const InputDecoration( hintText: 'Rechercher...', hintStyle: TextStyle(color: Colors.white54), @@ -176,8 +183,11 @@ class _LiveTVTabState extends ConsumerState if (_searchQuery.isNotEmpty) GestureDetector( onTap: () => _searchController.clear(), - child: const Icon(Icons.close, - size: 16, color: AppColors.textSecondary), + child: const Icon( + Icons.close, + size: 16, + color: AppColors.textSecondary, + ), ), ], ), @@ -250,7 +260,9 @@ class _LiveTVTabState extends ConsumerState } Widget _buildCategoryGrid( - List categories, Map> groupedChannels) { + List categories, + Map> groupedChannels, + ) { final columns = ResponsiveLayout.value( context, mobile: 2, @@ -307,8 +319,11 @@ class _LiveTVTabState extends ConsumerState color: AppColors.primary.withOpacity(0.1), 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), Text( diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index a104611..50b27f4 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_theme.dart'; @@ -238,14 +237,19 @@ class _MoviesTabState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - const Icon(Icons.search, - size: 20, color: AppColors.textSecondary), + const Icon( + Icons.search, + size: 20, + color: AppColors.textSecondary, + ), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, style: const TextStyle( - fontSize: 14, color: Colors.white), + fontSize: 14, + color: Colors.white, + ), decoration: const InputDecoration( hintText: 'Rechercher...', hintStyle: TextStyle(color: Colors.white54), @@ -260,17 +264,21 @@ class _MoviesTabState extends ConsumerState { ), if (_isSearching) const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(strokeWidth: 2)), + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), if (_searchQuery.isNotEmpty) GestureDetector( onTap: () { _searchController.clear(); _onSearchChanged(''); }, - child: const Icon(Icons.close, - size: 16, color: AppColors.textSecondary), + child: const Icon( + Icons.close, + size: 16, + color: AppColors.textSecondary, + ), ), ], ), diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index 05fdff0..8ec7485 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_theme.dart'; @@ -230,14 +229,19 @@ class _SeriesTabState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - const Icon(Icons.search, - size: 20, color: AppColors.textSecondary), + const Icon( + Icons.search, + size: 20, + color: AppColors.textSecondary, + ), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, style: const TextStyle( - fontSize: 14, color: Colors.white), + fontSize: 14, + color: Colors.white, + ), decoration: const InputDecoration( hintText: 'Rechercher...', hintStyle: TextStyle(color: Colors.white54), @@ -252,17 +256,21 @@ class _SeriesTabState extends ConsumerState { ), if (_isSearching) const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(strokeWidth: 2)), + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), if (_searchQuery.isNotEmpty) GestureDetector( onTap: () { _searchController.clear(); _onSearchChanged(''); }, - child: const Icon(Icons.close, - size: 16, color: AppColors.textSecondary), + child: const Icon( + Icons.close, + size: 16, + color: AppColors.textSecondary, + ), ), ], ), @@ -280,8 +288,9 @@ class _SeriesTabState extends ConsumerState { child: HeroCarousel( items: heroItems, 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); }, ), diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart index 0752357..6cf6e2d 100644 --- a/lib/features/iptv/widgets/settings_tab.dart +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -96,8 +96,9 @@ class _SettingsTabState extends ConsumerState const Tab(icon: Icon(Icons.palette), text: 'Apparence'), if (currentUser?.isAdmin ?? false) const Tab( - icon: Icon(Icons.admin_panel_settings), - text: 'Administration'), + icon: Icon(Icons.admin_panel_settings), + text: 'Administration', + ), ], ), ), @@ -212,7 +213,9 @@ class _SettingsTabState extends ConsumerState title: Text( 'Mode actuel', style: GoogleFonts.inter( - fontWeight: FontWeight.w500, color: Colors.white), + fontWeight: FontWeight.w500, + color: Colors.white, + ), ), subtitle: Text( isDark ? 'Thème sombre actif' : 'Thème clair actif', @@ -305,7 +308,10 @@ class _SettingsTabState extends ConsumerState } Widget _buildFiltersTab( - BuildContext context, dynamic currentUser, IptvSettings settings) { + BuildContext context, + dynamic currentUser, + IptvSettings settings, + ) { return ListView( padding: const EdgeInsets.all(16), children: [ @@ -326,13 +332,19 @@ class _SettingsTabState extends ConsumerState subtitle: Text( currentUser?.username ?? 'Unknown', style: GoogleFonts.roboto( - fontWeight: FontWeight.w600, color: Colors.white), + fontWeight: FontWeight.w600, + color: Colors.white, + ), ), trailing: currentUser?.isAdmin ?? false ? 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, ) : null, @@ -416,8 +428,10 @@ class _SettingsTabState extends ConsumerState ), child: ListTile( 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), onTap: () => context.go('/playlists'), ), @@ -433,8 +447,10 @@ class _SettingsTabState extends ConsumerState ), child: ListTile( 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( 'XtremFlow IPTV v1.0.0', style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), @@ -452,8 +468,10 @@ class _SettingsTabState extends ConsumerState ), child: ListTile( 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( 'En cas de problèmes d\'affichage ou de mise à jour', style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), @@ -465,8 +483,10 @@ class _SettingsTabState extends ConsumerState context: context, builder: (context) => AlertDialog( 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( 'Cette action va recharger l\'application et effacer les données temporaires.\n' 'Vos réglages et favoris sont sauvegardés sur le serveur.', @@ -479,7 +499,8 @@ class _SettingsTabState extends ConsumerState ), FilledButton( style: FilledButton.styleFrom( - backgroundColor: Colors.orange), + backgroundColor: Colors.orange, + ), onPressed: () { Navigator.pop(context); @@ -580,8 +601,11 @@ class _SettingsTabState extends ConsumerState ), suffixIcon: controller.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear, - size: 16, color: Colors.white54), + icon: const Icon( + Icons.clear, + size: 16, + color: Colors.white54, + ), onPressed: onClear, padding: EdgeInsets.zero, constraints: const BoxConstraints(), diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart index 9dd2472..3fb5a96 100644 --- a/lib/features/iptv/widgets/streaming_settings_tab.dart +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -248,10 +248,14 @@ class StreamingSettingsTab extends ConsumerWidget { required String Function(T) labelBuilder, }) { return ListTile( - title: Text(title, - style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), - subtitle: Text(subtitle, - style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), + title: Text( + title, + style: GoogleFonts.roboto(fontSize: 14, color: Colors.white), + ), + subtitle: Text( + subtitle, + style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), + ), trailing: DropdownButton( value: value, underline: const SizedBox(), diff --git a/lib/mobile/features/auth/screens/mobile_login_screen.dart b/lib/mobile/features/auth/screens/mobile_login_screen.dart index 17c434c..1580733 100644 --- a/lib/mobile/features/auth/screens/mobile_login_screen.dart +++ b/lib/mobile/features/auth/screens/mobile_login_screen.dart @@ -123,7 +123,8 @@ class _MobileLoginScreenState extends ConsumerState { : Icons.visibility_off_outlined, ), onPressed: () => setState( - () => _obscurePassword = !_obscurePassword), + () => _obscurePassword = !_obscurePassword, + ), ), ), validator: (value) => (value == null || value.isEmpty) @@ -136,7 +137,9 @@ class _MobileLoginScreenState extends ConsumerState { Text( authState.errorMessage!, style: const TextStyle( - color: AppColors.error, fontSize: 12), + color: AppColors.error, + fontSize: 12, + ), textAlign: TextAlign.center, ), ], @@ -150,7 +153,9 @@ class _MobileLoginScreenState extends ConsumerState { width: 20, height: 20, child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.black), + strokeWidth: 2, + color: Colors.black, + ), ) : const Text('Sign In'), ), diff --git a/lib/mobile/features/iptv/screens/lite_player_screen.dart b/lib/mobile/features/iptv/screens/lite_player_screen.dart index de92118..2fbc2f4 100644 --- a/lib/mobile/features/iptv/screens/lite_player_screen.dart +++ b/lib/mobile/features/iptv/screens/lite_player_screen.dart @@ -12,7 +12,6 @@ import 'package:xtremflow/core/models/playlist_config.dart'; import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart'; import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremflow/core/theme/app_colors.dart'; -import 'package:xtremflow/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremflow/mobile/features/iptv/screens/native_player_screen.dart' show StreamType; import 'package:xtremflow/features/iptv/models/xtream_models.dart' as xm; @@ -163,10 +162,14 @@ class _LitePlayerScreenState extends ConsumerState streamUrl = _xtreamService!.getLiveStreamUrl(currentStreamId); } else if (widget.streamType == StreamType.vod) { streamUrl = _xtreamService!.getVodStreamUrl( - currentStreamId, widget.containerExtension ?? 'mp4'); + currentStreamId, + widget.containerExtension ?? 'mp4', + ); } else { streamUrl = _xtreamService!.getSeriesStreamUrl( - currentStreamId, widget.containerExtension ?? 'mp4'); + currentStreamId, + widget.containerExtension ?? 'mp4', + ); } final controller = VideoPlayerController.networkUrl( @@ -272,8 +275,9 @@ class _LitePlayerScreenState extends ConsumerState if (_controller == null || !_controller!.value.isInitialized) return; final newPos = _controller!.value.position + offset; final clamped = Duration( - milliseconds: newPos.inMilliseconds - .clamp(0, _controller!.value.duration.inMilliseconds)); + milliseconds: newPos.inMilliseconds + .clamp(0, _controller!.value.duration.inMilliseconds), + ); await _controller!.seekTo(clamped); _onUserInteraction(); } @@ -329,6 +333,12 @@ class _LitePlayerScreenState extends ConsumerState } } + // Cycle Aspect Ratio - secret key 'A' or digit 1 + if (key == LogicalKeyboardKey.keyA || key == LogicalKeyboardKey.digit1) { + _cycleAspectRatio(); + return true; + } + if (key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.goBack) { if (_showControls) { setState(() => _showControls = false); @@ -387,6 +397,62 @@ class _LitePlayerScreenState extends ConsumerState return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; } + Widget _buildVideoOutput() { + final mode = ref.watch(mobileSettingsProvider).aspectRatioMode; + + switch (mode) { + case 'cover': + return SizedBox.expand( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!), + ), + ), + ); + case 'fill': + return SizedBox.expand( + child: VideoPlayer(_controller!), + ); + case 'contain': + default: + return AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), + ); + } + } + + void _cycleAspectRatio() { + final current = ref.read(mobileSettingsProvider).aspectRatioMode; + String next; + String label; + if (current == 'contain') { + next = 'cover'; + label = 'Zoom (Cover)'; + } else if (current == 'cover') { + next = 'fill'; + label = 'Étirer (Fill)'; + } else { + next = 'contain'; + label = 'Original (Contain)'; + } + + ref.read(mobileSettingsProvider.notifier).setAspectRatioMode(next); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Format: $label'), + duration: const Duration(seconds: 1), + backgroundColor: Colors.black87, + ), + ); + } + } + @override Widget build(BuildContext context) { final title = widget.channels != null && widget.channels!.isNotEmpty @@ -403,17 +469,17 @@ class _LitePlayerScreenState extends ConsumerState // Video Output - Standard AspectRatio Center( child: _controller != null && _controller!.value.isInitialized - ? AspectRatio( - aspectRatio: _controller!.value.aspectRatio, - child: VideoPlayer(_controller!), - ) + ? _buildVideoOutput() : const CircularProgressIndicator(color: AppColors.primary), ), if (_errorMessage != null) Center( - child: Text(_errorMessage!, - style: const TextStyle(color: Colors.red))), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), if (_showControls) _buildControls(title), ], @@ -458,10 +524,20 @@ class _LitePlayerScreenState extends ConsumerState style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold)), const Spacer(), - // NO RATIO TOGGLE HERE + // Aspect Ratio Toggle + TVFocusable( + focusNode: FocusNode(), + onPressed: _cycleAspectRatio, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Icon(Icons.aspect_ratio, color: Colors.white70), + ), + ), const SizedBox(width: 16), - Text(_currentTime, - style: const TextStyle(color: Colors.white70)), + Text( + _currentTime, + style: const TextStyle(color: Colors.white70), + ), ], ), ), @@ -482,8 +558,11 @@ class _LitePlayerScreenState extends ConsumerState Padding( padding: const EdgeInsets.only(right: 24), child: IconButton( - icon: const Icon(Icons.skip_previous, - color: Colors.white, size: 48), + icon: const Icon( + Icons.skip_previous, + color: Colors.white, + size: 48, + ), onPressed: _playPrevious, ), ), @@ -491,8 +570,11 @@ class _LitePlayerScreenState extends ConsumerState // VOD: Replay 10s if (widget.streamType != StreamType.live) ...[ IconButton( - icon: const Icon(Icons.replay_10, - color: Colors.white70, size: 36), + icon: const Icon( + Icons.replay_10, + color: Colors.white70, + size: 36, + ), onPressed: () => _seek(const Duration(seconds: -10)), ), const SizedBox(width: 24), @@ -525,8 +607,11 @@ class _LitePlayerScreenState extends ConsumerState if (widget.streamType != StreamType.live) ...[ const SizedBox(width: 24), IconButton( - icon: const Icon(Icons.forward_10, - color: Colors.white70, size: 36), + icon: const Icon( + Icons.forward_10, + color: Colors.white70, + size: 36, + ), onPressed: () => _seek(const Duration(seconds: 10)), ), ], @@ -537,8 +622,11 @@ class _LitePlayerScreenState extends ConsumerState Padding( padding: const EdgeInsets.only(left: 24), child: IconButton( - icon: const Icon(Icons.skip_next, - color: Colors.white, size: 48), + icon: const Icon( + Icons.skip_next, + color: Colors.white, + size: 48, + ), onPressed: _playNext, ), ), @@ -572,9 +660,10 @@ class _LitePlayerScreenState extends ConsumerState Text( _epg!.nowPlaying!, style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold), + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -584,7 +673,9 @@ class _LitePlayerScreenState extends ConsumerState child: Text( "A suivre: ${_epg!.nextPlaying}", style: const TextStyle( - color: Colors.white70, fontSize: 14), + color: Colors.white70, + fontSize: 14, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -600,11 +691,14 @@ class _LitePlayerScreenState extends ConsumerState color: Colors.red, borderRadius: BorderRadius.circular(4), ), - child: const Text('LIVE', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12)), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), ), ], ), @@ -622,8 +716,10 @@ class _LitePlayerScreenState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Row( children: [ - Text(_formatDuration(_position), - style: const TextStyle(color: Colors.white)), + Text( + _formatDuration(_position), + style: const TextStyle(color: Colors.white), + ), Expanded( child: Slider( value: _position.inSeconds @@ -648,8 +744,10 @@ class _LitePlayerScreenState extends ConsumerState }, ), ), - Text(_formatDuration(_duration), - style: const TextStyle(color: Colors.white)), + Text( + _formatDuration(_duration), + style: const TextStyle(color: Colors.white), + ), ], ), ), diff --git a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart index c10754f..62a95e1 100644 --- a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart @@ -7,27 +7,29 @@ import '../widgets/mobile_live_tv_tab.dart'; import '../widgets/mobile_movies_tab.dart'; import '../widgets/mobile_series_tab.dart'; import '../widgets/mobile_settings_tab.dart'; +import '../../../providers/mobile_xtream_providers.dart'; class MobileDashboardScreen extends ConsumerStatefulWidget { final PlaylistConfig playlist; - + const MobileDashboardScreen({ - super.key, + super.key, required this.playlist, }); @override - ConsumerState createState() => _MobileDashboardScreenState(); + ConsumerState createState() => + _MobileDashboardScreenState(); } class _MobileDashboardScreenState extends ConsumerState { - int _currentIndex = 0; - @override Widget build(BuildContext context) { + final currentIndex = ref.watch(mobileDashboardIndexProvider); + // Determine which tab to show Widget currentTab; - switch (_currentIndex) { + switch (currentIndex) { case 0: currentTab = MobileLiveTVTab(playlist: widget.playlist); break; @@ -46,14 +48,15 @@ class _MobileDashboardScreenState extends ConsumerState { return Theme( data: MobileTheme.darkTheme, - child: MobileScaffold( - currentIndex: _currentIndex, - onIndexChanged: (index) { - setState(() { - _currentIndex = index; - }); - }, - child: currentTab, + child: PopScope( + canPop: false, // Prevent accidental exit to playlist selection + child: MobileScaffold( + currentIndex: currentIndex, + onIndexChanged: (index) { + ref.read(mobileDashboardIndexProvider.notifier).state = index; + }, + child: currentTab, + ), ), ); } diff --git a/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart b/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart index 97a76ae..1cd00b8 100644 --- a/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart @@ -224,9 +224,11 @@ class _MobilePlaylistScreenState extends ConsumerState { _refreshPlaylists(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(isEditing - ? 'Playlist modifiée' - : 'Playlist ajoutée'), + content: Text( + isEditing + ? 'Playlist modifiée' + : 'Playlist ajoutée', + ), backgroundColor: Colors.green, ), ); @@ -308,8 +310,10 @@ class _MobilePlaylistScreenState extends ConsumerState { const SizedBox(height: 16), ListTile( 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: () { Navigator.pop(context); _showPlaylistDialog(playlist: playlist); @@ -317,8 +321,10 @@ class _MobilePlaylistScreenState extends ConsumerState { ), ListTile( 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: () { Navigator.pop(context); _showDeleteConfirmation(playlist); @@ -388,8 +394,11 @@ class _MobilePlaylistScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, 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 Text( 'Erreur de chargement', @@ -399,7 +408,9 @@ class _MobilePlaylistScreenState extends ConsumerState { Text( error.toString(), style: const TextStyle( - color: AppColors.textSecondary, fontSize: 12), + color: AppColors.textSecondary, + fontSize: 12, + ), ), const SizedBox(height: 16), TextButton.icon( @@ -563,8 +574,10 @@ class _PlaylistCard extends StatelessWidget { color: AppColors.textTertiary, ), 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 ), ], @@ -691,14 +704,17 @@ class _TVTextFieldState extends State<_TVTextField> { decoration: InputDecoration( labelText: widget.label, hintText: widget.hint, - prefixIcon: Icon(widget.icon, - color: isFocused - ? AppColors.primary - : AppColors.textSecondary), + prefixIcon: Icon( + widget.icon, + color: isFocused + ? AppColors.primary + : AppColors.textSecondary, + ), labelStyle: TextStyle( - color: isFocused - ? AppColors.primary - : AppColors.textSecondary), + color: isFocused + ? AppColors.primary + : AppColors.textSecondary, + ), hintStyle: const TextStyle(color: AppColors.textTertiary), filled: true, fillColor: AppColors.surface, @@ -707,7 +723,9 @@ class _TVTextFieldState extends State<_TVTextField> { borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 14), + horizontal: 16, + vertical: 14, + ), ), ), ), diff --git a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart index e227cfa..d0111e4 100644 --- a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart @@ -72,7 +72,8 @@ class _MobilePlaylistSelectionScreenState labelText: 'Nom', labelStyle: TextStyle(color: Colors.white70), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24)), + borderSide: BorderSide(color: Colors.white24), + ), ), ), const SizedBox(height: 8), @@ -83,7 +84,8 @@ class _MobilePlaylistSelectionScreenState labelText: 'URL (DNS)', labelStyle: TextStyle(color: Colors.white70), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24)), + borderSide: BorderSide(color: Colors.white24), + ), ), ), const SizedBox(height: 8), @@ -94,7 +96,8 @@ class _MobilePlaylistSelectionScreenState labelText: 'Utilisateur', labelStyle: TextStyle(color: Colors.white70), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24)), + borderSide: BorderSide(color: Colors.white24), + ), ), ), const SizedBox(height: 8), @@ -106,7 +109,8 @@ class _MobilePlaylistSelectionScreenState labelText: 'Mot de passe', labelStyle: TextStyle(color: Colors.white70), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white24)), + borderSide: BorderSide(color: Colors.white24), + ), ), ), ], @@ -120,10 +124,14 @@ class _MobilePlaylistSelectionScreenState 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)), + 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), @@ -227,11 +235,16 @@ class _MobilePlaylistSelectionScreenState child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), - Text('Erreur de chargement', - style: GoogleFonts.inter(color: Colors.white)), + Text( + 'Erreur de chargement', + style: GoogleFonts.inter(color: Colors.white), + ), TextButton( onPressed: () => ref.invalidate(playlistsProvider), child: const Text('Réessayer'), @@ -245,13 +258,18 @@ class _MobilePlaylistSelectionScreenState child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), Text( 'Aucune playlist configurée', style: GoogleFonts.inter( - fontSize: 18, color: Colors.white70), + fontSize: 18, + color: Colors.white70, + ), ), const SizedBox(height: 32), ElevatedButton.icon( @@ -262,7 +280,9 @@ class _MobilePlaylistSelectionScreenState backgroundColor: AppColors.primary, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 12), + horizontal: 24, + vertical: 12, + ), ), ), ], @@ -272,7 +292,11 @@ class _MobilePlaylistSelectionScreenState return ListView.separated( padding: const EdgeInsets.fromLTRB( - 16, 100, 16, 16), // Top padding for AppBar + 16, + 100, + 16, + 16, + ), // Top padding for AppBar itemCount: playlists.length, separatorBuilder: (context, index) => const SizedBox(height: 16), @@ -341,8 +365,11 @@ class _MobilePlaylistCard extends StatelessWidget { color: AppColors.primary.withOpacity(0.2), 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), Expanded( @@ -375,8 +402,10 @@ class _MobilePlaylistCard extends StatelessWidget { if (manageMode) const Icon(Icons.edit, color: AppColors.primary) else - const Icon(Icons.chevron_right, - color: AppColors.textTertiary), + const Icon( + Icons.chevron_right, + color: AppColors.textTertiary, + ), ], ), ), diff --git a/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart index f09d73a..800977d 100644 --- a/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart @@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../core/models/playlist_config.dart'; -import '../../../../features/iptv/models/xtream_models.dart'; -import 'package:xtremflow/mobile/features/iptv/screens/lite_player_screen.dart'; import '../../../providers/mobile_xtream_providers.dart'; import '../../../providers/mobile_settings_providers.dart'; import 'native_player_screen.dart'; @@ -89,15 +87,20 @@ class _MobileSeriesDetailScreenState child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), - Text('Error: $_error', - style: - const TextStyle(color: AppColors.textSecondary)), + Text( + 'Error: $_error', + style: const TextStyle(color: AppColors.textSecondary), + ), TextButton( - onPressed: _loadSeriesInfo, - child: const Text('Retry')), + onPressed: _loadSeriesInfo, + child: const Text('Retry'), + ), ], ), ) @@ -176,20 +179,27 @@ class _MobileSeriesDetailScreenState Row( children: [ if (_seriesInfo!.rating != null) ...[ - const Icon(Icons.star, - size: 16, color: Colors.amber), + const Icon( + Icons.star, + size: 16, + color: Colors.amber, + ), const SizedBox(width: 4), Text( _formatRating(_seriesInfo!.rating!)!, style: const TextStyle( - color: AppColors.textSecondary, fontSize: 13), + color: AppColors.textSecondary, + fontSize: 13, + ), ), const SizedBox(width: 16), ], Text( '${_seriesInfo!.episodes.keys.length} Saisons', style: const TextStyle( - color: AppColors.textSecondary, fontSize: 13), + color: AppColors.textSecondary, + fontSize: 13, + ), ), ], ), @@ -209,7 +219,9 @@ class _MobileSeriesDetailScreenState child: Text( _seriesInfo!.plot!, style: const TextStyle( - color: AppColors.textSecondary, height: 1.4), + color: AppColors.textSecondary, + height: 1.4, + ), maxLines: 4, overflow: TextOverflow.ellipsis, ), @@ -236,8 +248,9 @@ class _MobileSeriesDetailScreenState label: Text('Saison $seasonNum'), selected: isSelected, onSelected: (selected) { - if (selected) + if (selected) { setState(() => _selectedSeason = seasonNum); + } }, backgroundColor: isDark ? const Color(0xFF1C1C1E) @@ -253,7 +266,8 @@ class _MobileSeriesDetailScreenState isSelected ? FontWeight.w600 : FontWeight.w400, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20)), + borderRadius: BorderRadius.circular(20), + ), showCheckmark: false, side: BorderSide.none, ); @@ -378,7 +392,9 @@ class _MobileSeriesDetailScreenState Text( _formatDuration(episode.durationSecs!), style: TextStyle( - color: secondaryTextColor, fontSize: 12), + color: secondaryTextColor, + fontSize: 12, + ), ), ], ), diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index 2996ab4..62d1765 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -14,7 +13,6 @@ import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart'; import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremflow/core/theme/app_colors.dart'; import 'package:xtremflow/mobile/providers/mobile_xtream_providers.dart'; -import 'package:xtremflow/features/iptv/models/xtream_models.dart' as xm; /// Stream type enum for player enum StreamType { live, vod, series } @@ -125,7 +123,7 @@ class _NativePlayerScreenState extends ConsumerState // Initialize media_kit player with potential software decoding fallback // 'hwdec': 'auto' tries hardware first, then software. // 'vo': 'gpu' is standard. - final config = PlayerConfiguration( + const config = PlayerConfiguration( vo: 'gpu', // msgLevel removed as it might be deprecated or invalid ); @@ -191,11 +189,15 @@ class _NativePlayerScreenState extends ConsumerState ?.setProperty('http-header-fields', 'User-Agent: XtremFlow/1.0'); // Live Stream optimizations - aggressive reconnection - (_player.platform as dynamic)?.setProperty('stream-lavf-o', - 'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=10,reconnect_on_http_error=4xx,5xx'); + (_player.platform as dynamic)?.setProperty( + 'stream-lavf-o', + 'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=10,reconnect_on_http_error=4xx,5xx', + ); (_player.platform as dynamic)?.setProperty('force-seekable', 'yes'); - (_player.platform as dynamic)?.setProperty('demuxer-lavf-o', - 'live_start_index=-1,analyzeduration=10000000,probesize=5000000'); + (_player.platform as dynamic)?.setProperty( + 'demuxer-lavf-o', + 'live_start_index=-1,analyzeduration=10000000,probesize=5000000', + ); // Prevent stalls (readahead already set above) (_player.platform as dynamic) @@ -204,8 +206,10 @@ class _NativePlayerScreenState extends ConsumerState ?.setProperty('hr-seek-framedrop', 'yes'); // Drop frames to catch up // Frame dropping policy - prefer dropping frames over stuttering - (_player.platform as dynamic)?.setProperty('framedrop', - 'decoder+vo'); // Allow frame drops at decoder and video output + (_player.platform as dynamic)?.setProperty( + 'framedrop', + 'decoder+vo', + ); // Allow frame drops at decoder and video output // ============ AUDIO CODEC SUPPORT FOR VOD ============ // V3: Explicit LAVC Downmix & Audiotrack @@ -300,7 +304,8 @@ class _NativePlayerScreenState extends ConsumerState // If we haven't tried SW decoding yet, retry with it enabled. if (!_useSoftwareDecoder) { debugPrint( - 'MediaKitPlayer: Error encountered. Auto-retrying with Software Decoding...'); + 'MediaKitPlayer: Error encountered. Auto-retrying with Software Decoding...', + ); setState(() => _useSoftwareDecoder = true); // Short delay to allow player cleanup @@ -320,7 +325,8 @@ class _NativePlayerScreenState extends ConsumerState _player.stream.completed.listen((completed) { if (completed && mounted && widget.streamType == StreamType.live) { debugPrint( - 'MediaKitPlayer: Live stream completed unexpectedly, attempting reconnect...'); + 'MediaKitPlayer: Live stream completed unexpectedly, attempting reconnect...', + ); _attemptReconnect(); } }); @@ -413,7 +419,10 @@ class _NativePlayerScreenState extends ConsumerState widget.season != null && widget.episodeNum != null) { return MobileWatchHistory.episodeKey( - widget.seriesId, widget.season!, widget.episodeNum!); + widget.seriesId, + widget.season!, + widget.episodeNum!, + ); } return widget.streamId; } @@ -423,7 +432,8 @@ class _NativePlayerScreenState extends ConsumerState if (widget.streamType == StreamType.live) return; debugPrint( - 'MediaKitPlayer: Saving position - pos: ${_position.inSeconds}s, dur: ${_duration.inSeconds}s, contentId: $_contentId'); + 'MediaKitPlayer: Saving position - pos: ${_position.inSeconds}s, dur: ${_duration.inSeconds}s, contentId: $_contentId', + ); // If already marked as watched, clear any saved position if (_hasMarkedAsWatched) { @@ -442,7 +452,8 @@ class _NativePlayerScreenState extends ConsumerState .read(mobileWatchHistoryProvider.notifier) .clearResumePosition(_contentId); debugPrint( - 'MediaKitPlayer: Almost finished (${(progress * 100).toStringAsFixed(1)}%), clearing position'); + 'MediaKitPlayer: Almost finished (${(progress * 100).toStringAsFixed(1)}%), clearing position', + ); return; } } @@ -463,7 +474,8 @@ class _NativePlayerScreenState extends ConsumerState if (_isPlaying && _duration.inSeconds > 0) { debugPrint( - 'MediaKitPlayer: Seeking to resume position ${positionSeconds}s (attempt $attempt)'); + 'MediaKitPlayer: Seeking to resume position ${positionSeconds}s (attempt $attempt)', + ); _player.seek(Duration(seconds: positionSeconds)); // Show visual notification @@ -472,14 +484,16 @@ class _NativePlayerScreenState extends ConsumerState ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'), + 'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}', + ), duration: const Duration(seconds: 2), backgroundColor: Colors.green.shade700, ), ); } else { debugPrint( - 'MediaKitPlayer: Player not ready, retrying resume seek (attempt $attempt)'); + 'MediaKitPlayer: Player not ready, retrying resume seek (attempt $attempt)', + ); _seekToResume(positionSeconds, attempt + 1); } }); @@ -498,7 +512,8 @@ class _NativePlayerScreenState extends ConsumerState _reconnectAttempts++; debugPrint( - 'MediaKitPlayer: Reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts'); + 'MediaKitPlayer: Reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts', + ); // Delay before reconnect to avoid hammering the server Future.delayed(Duration(seconds: 2 * _reconnectAttempts), () { @@ -555,7 +570,8 @@ class _NativePlayerScreenState extends ConsumerState // If we're supposedly playing but position hasn't changed, reconnect if (!_isPlaying && !_isLoading && _errorMessage == null) { debugPrint( - 'MediaKitPlayer: Watchdog detected stalled stream, reconnecting...'); + 'MediaKitPlayer: Watchdog detected stalled stream, reconnecting...', + ); _attemptReconnect(); } }); @@ -614,10 +630,14 @@ class _NativePlayerScreenState extends ConsumerState streamUrl = _xtreamService!.getLiveStreamUrl(currentStreamId); } else if (widget.streamType == StreamType.vod) { streamUrl = _xtreamService!.getVodStreamUrl( - currentStreamId, widget.containerExtension ?? 'mp4'); + currentStreamId, + widget.containerExtension ?? 'mp4', + ); } else { streamUrl = _xtreamService!.getSeriesStreamUrl( - currentStreamId, widget.containerExtension ?? 'mp4'); + currentStreamId, + widget.containerExtension ?? 'mp4', + ); } debugPrint('MediaKitPlayer: Loading stream: $streamUrl'); @@ -653,7 +673,8 @@ class _NativePlayerScreenState extends ConsumerState if (widget.initialPosition != null && widget.initialPosition!.inSeconds > 0) { debugPrint( - 'MediaKitPlayer: Using explicit initial position: ${widget.initialPosition!.inSeconds}s'); + 'MediaKitPlayer: Using explicit initial position: ${widget.initialPosition!.inSeconds}s', + ); _seekToResume(widget.initialPosition!.inSeconds); } else { // Priority 2: Internal lookup (fallback) @@ -661,7 +682,8 @@ class _NativePlayerScreenState extends ConsumerState .read(mobileWatchHistoryProvider) .getResumePosition(_contentId); debugPrint( - 'MediaKitPlayer: Resume check - contentId: $_contentId, resumePos: $resumePos'); + 'MediaKitPlayer: Resume check - contentId: $_contentId, resumePos: $resumePos', + ); if (resumePos > 30) { _seekToResume(resumePos); } @@ -826,6 +848,12 @@ class _NativePlayerScreenState extends ConsumerState return true; } + // Cycle Aspect Ratio - secret key 'A' or digit 1 + if (key == LogicalKeyboardKey.keyA || key == LogicalKeyboardKey.digit1) { + _cycleAspectRatio(); + return true; + } + // Escape / Back - Exit player if (key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.goBack) { // If controls are visible, close them first @@ -846,8 +874,9 @@ class _NativePlayerScreenState extends ConsumerState if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Aucune piste audio détectée'), - duration: Duration(seconds: 1)), + content: Text('Aucune piste audio détectée'), + duration: Duration(seconds: 1), + ), ); } return; @@ -864,7 +893,8 @@ class _NativePlayerScreenState extends ConsumerState ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Audio: ${nextTrack.language ?? "inconnu"} (${nextTrack.title ?? nextTrack.id})'), + 'Audio: ${nextTrack.language ?? "inconnu"} (${nextTrack.title ?? nextTrack.id})', + ), duration: const Duration(seconds: 2), ), ); @@ -894,6 +924,8 @@ class _NativePlayerScreenState extends ConsumerState child: Video( controller: _controller, controls: NoVideoControls, // We use our own custom controls + fit: _getBoxFit( + ref.watch(mobileSettingsProvider).aspectRatioMode), ), ), @@ -933,8 +965,10 @@ class _NativePlayerScreenState extends ConsumerState onFocus: _resetControlsTimer, borderRadius: BorderRadius.circular(50), child: IconButton( - icon: const Icon(Icons.arrow_back, - color: Colors.white), + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), onPressed: () => Navigator.pop(context), ), ), @@ -948,9 +982,11 @@ class _NativePlayerScreenState extends ConsumerState .toggleShowDebugStats(!current); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - 'Debug Mode: ${!current ? "ON" : "OFF"}'), - duration: const Duration(seconds: 1)), + content: Text( + 'Debug Mode: ${!current ? "ON" : "OFF"}', + ), + duration: const Duration(seconds: 1), + ), ); }, child: const SizedBox(width: 20, height: 40), @@ -1002,6 +1038,46 @@ class _NativePlayerScreenState extends ConsumerState ); } + BoxFit _getBoxFit(String mode) { + switch (mode) { + case 'cover': + return BoxFit.cover; + case 'fill': + return BoxFit.fill; + case 'contain': + default: + return BoxFit.contain; + } + } + + void _cycleAspectRatio() { + final current = ref.read(mobileSettingsProvider).aspectRatioMode; + String next; + String label; + if (current == 'contain') { + next = 'cover'; + label = 'Zoom (Cover)'; + } else if (current == 'cover') { + next = 'fill'; + label = 'Étirer (Fill)'; + } else { + next = 'contain'; + label = 'Original (Contain)'; + } + + ref.read(mobileSettingsProvider.notifier).setAspectRatioMode(next); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Format: $label'), + duration: const Duration(seconds: 1), + backgroundColor: Colors.black87, + ), + ); + } + } + String _formatDuration(Duration duration) { String twoDigits(int n) => n.toString().padLeft(2, '0'); String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); @@ -1037,8 +1113,11 @@ class _NativePlayerScreenState extends ConsumerState onFocus: _resetControlsTimer, borderRadius: BorderRadius.circular(50), child: IconButton( - icon: const Icon(Icons.skip_previous, - color: Colors.white, size: 48), + icon: const Icon( + Icons.skip_previous, + color: Colors.white, + size: 48, + ), onPressed: _playPrevious, ), ) @@ -1053,8 +1132,11 @@ class _NativePlayerScreenState extends ConsumerState onFocus: _resetControlsTimer, borderRadius: BorderRadius.circular(50), child: IconButton( - icon: const Icon(Icons.replay_10, - color: Colors.white, size: 48), + icon: const Icon( + Icons.replay_10, + color: Colors.white, + size: 48, + ), onPressed: () async { final pos = await _player.stream.position.first; _player.seek(pos - const Duration(seconds: 5)); @@ -1095,8 +1177,11 @@ class _NativePlayerScreenState extends ConsumerState onFocus: _resetControlsTimer, borderRadius: BorderRadius.circular(50), child: IconButton( - icon: const Icon(Icons.skip_next, - color: Colors.white, size: 48), + icon: const Icon( + Icons.skip_next, + color: Colors.white, + size: 48, + ), onPressed: _playNext, ), ) @@ -1111,8 +1196,11 @@ class _NativePlayerScreenState extends ConsumerState onFocus: _resetControlsTimer, borderRadius: BorderRadius.circular(50), child: IconButton( - icon: const Icon(Icons.forward_10, - color: Colors.white, size: 48), + icon: const Icon( + Icons.forward_10, + color: Colors.white, + size: 48, + ), onPressed: () async { final pos = await _player.stream.position.first; _player.seek(pos + const Duration(seconds: 5)); @@ -1153,9 +1241,10 @@ class _NativePlayerScreenState extends ConsumerState Text( _epg!.nowPlaying!, style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold), + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -1165,8 +1254,9 @@ class _NativePlayerScreenState extends ConsumerState child: Text( "A suivre: ${_epg!.nextPlaying}", style: const TextStyle( - color: Colors.white70, - fontSize: 14), + color: Colors.white70, + fontSize: 14, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -1177,22 +1267,44 @@ class _NativePlayerScreenState extends ConsumerState // LIVE Badge inside the box, right side Container( padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4), + horizontal: 8, + vertical: 4, + ), margin: const EdgeInsets.only(left: 8), decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(4), ), - child: const Text('LIVE', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12)), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), ), ], ), ), ), + + const SizedBox(width: 32), + + // Aspect Ratio Toggle + TVFocusable( + focusNode: + FocusNode(), // Temporary node or add to state if needed + onPressed: _cycleAspectRatio, + onFocus: _resetControlsTimer, + borderRadius: BorderRadius.circular(50), + child: IconButton( + icon: const Icon(Icons.aspect_ratio, + color: Colors.white, size: 36), + onPressed: _cycleAspectRatio, + tooltip: 'Format d\'image', + ), + ), ], ), ), @@ -1220,7 +1332,9 @@ class _NativePlayerScreenState extends ConsumerState Text( _formatDuration(_position), style: const TextStyle( - color: Colors.white, fontSize: 14), + color: Colors.white, + fontSize: 14, + ), ), Expanded( child: Slider( @@ -1251,7 +1365,9 @@ class _NativePlayerScreenState extends ConsumerState Text( _formatDuration(_duration), style: const TextStyle( - color: Colors.white, fontSize: 14), + color: Colors.white, + fontSize: 14, + ), ), ], ) @@ -1266,16 +1382,21 @@ class _NativePlayerScreenState extends ConsumerState // Clock Container( padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), + horizontal: 12, + vertical: 6, + ), margin: const EdgeInsets.only(right: 12), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(4), ), - child: Text(_currentTime, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold)), + child: Text( + _currentTime, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), ), // LIVE Badge removed from here ], @@ -1399,8 +1520,9 @@ class _StatsOverlayWidgetState extends ConsumerState { @override Widget build(BuildContext context) { - if (!ref.watch(mobileSettingsProvider).showDebugStats) + if (!ref.watch(mobileSettingsProvider).showDebugStats) { return const SizedBox.shrink(); + } return Container( padding: const EdgeInsets.all(8), @@ -1413,16 +1535,23 @@ class _StatsOverlayWidgetState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Text('STATS FOR NERDS 🤓', - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold, - fontSize: 12)), + const Text( + 'STATS FOR NERDS 🤓', + style: TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), const SizedBox(height: 4), - Text('Buffer: ${_buffer.inSeconds}s', - style: const TextStyle(color: Colors.white, fontSize: 12)), - Text('Decoder: $_decoder', - style: const TextStyle(color: Colors.white, fontSize: 12)), + Text( + 'Buffer: ${_buffer.inSeconds}s', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + Text( + 'Decoder: $_decoder', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), ], ), ); diff --git a/lib/mobile/providers/mobile_settings_providers.dart b/lib/mobile/providers/mobile_settings_providers.dart index 6db7802..6dd8ca6 100644 --- a/lib/mobile/providers/mobile_settings_providers.dart +++ b/lib/mobile/providers/mobile_settings_providers.dart @@ -12,6 +12,7 @@ class MobileSettings { final bool showDebugStats; final String decoderMode; // 'auto', 'mediacodec' (HW), 'no' (SW) final String playerEngine; // 'ultra' (MPV), 'lite' (ExoPlayer) + final String aspectRatioMode; // 'contain', 'cover', 'fill' final int bufferDuration; // seconds, 0 = auto const MobileSettings({ @@ -22,6 +23,7 @@ class MobileSettings { this.showDebugStats = false, this.decoderMode = 'auto', this.playerEngine = 'ultra', + this.aspectRatioMode = 'contain', this.bufferDuration = 0, }); @@ -33,6 +35,7 @@ class MobileSettings { bool? showDebugStats, String? decoderMode, String? playerEngine, + String? aspectRatioMode, int? bufferDuration, }) { return MobileSettings( @@ -43,6 +46,7 @@ class MobileSettings { showDebugStats: showDebugStats ?? this.showDebugStats, decoderMode: decoderMode ?? this.decoderMode, playerEngine: playerEngine ?? this.playerEngine, + aspectRatioMode: aspectRatioMode ?? this.aspectRatioMode, bufferDuration: bufferDuration ?? this.bufferDuration, ); } @@ -102,6 +106,8 @@ class MobileSettingsNotifier extends StateNotifier { _box?.get('decoderMode', defaultValue: 'auto') as String?; final playerEngine = _box?.get('playerEngine', defaultValue: 'ultra') as String?; + final aspectRatioMode = + _box?.get('aspectRatioMode', defaultValue: 'contain') as String?; final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?; state = MobileSettings( @@ -112,6 +118,7 @@ class MobileSettingsNotifier extends StateNotifier { showDebugStats: showDebugStats ?? false, decoderMode: decoderMode ?? 'auto', playerEngine: playerEngine ?? 'ultra', + aspectRatioMode: aspectRatioMode ?? 'contain', bufferDuration: buffer ?? 0, ); } @@ -151,6 +158,11 @@ class MobileSettingsNotifier extends StateNotifier { _box?.put('playerEngine', engine); } + void setAspectRatioMode(String mode) { + state = state.copyWith(aspectRatioMode: mode); + _box?.put('aspectRatioMode', mode); + } + void setBufferDuration(int seconds) { state = state.copyWith(bufferDuration: seconds); _box?.put('bufferDuration', seconds); diff --git a/lib/mobile/providers/mobile_xtream_providers.dart b/lib/mobile/providers/mobile_xtream_providers.dart index 7cdacb4..ebddff9 100644 --- a/lib/mobile/providers/mobile_xtream_providers.dart +++ b/lib/mobile/providers/mobile_xtream_providers.dart @@ -10,33 +10,41 @@ export '../../features/iptv/models/xtream_models.dart'; import 'package:path_provider/path_provider.dart'; /// Mobile-specific Xtream service provider (no dart:html dependency) -final mobileXtreamServiceProvider = FutureProvider.family((ref, playlist) async { +final mobileXtreamServiceProvider = + FutureProvider.family( + (ref, playlist) async { final dir = await getApplicationDocumentsDirectory(); final service = XtreamServiceMobile(dir.path); - await service.setPlaylistAsync(playlist); // Async to resolve DNS before API calls + await service + .setPlaylistAsync(playlist); // Async to resolve DNS before API calls return service; }); /// Mobile-specific live channels provider -final mobileLiveChannelsProvider = FutureProvider.family>, PlaylistConfig>((ref, playlist) async { +final mobileLiveChannelsProvider = + FutureProvider.family>, PlaylistConfig>( + (ref, playlist) async { final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); return service.getLiveChannels(); }); /// Mobile-specific movies pagination provider -final mobileMoviesProvider = FutureProvider.family, PlaylistConfig>((ref, playlist) async { +final mobileMoviesProvider = + FutureProvider.family, PlaylistConfig>((ref, playlist) async { final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); return service.getMoviesPaginated(offset: 0, limit: 100); }); /// Mobile-specific series pagination provider -final mobileSeriesProvider = FutureProvider.family, PlaylistConfig>((ref, playlist) async { +final mobileSeriesProvider = + FutureProvider.family, PlaylistConfig>((ref, playlist) async { final service = await ref.watch(mobileXtreamServiceProvider(playlist).future); return service.getSeriesPaginated(offset: 0, limit: 100); }); /// Mobile-specific series info provider -final mobileSeriesInfoProvider = FutureProvider.family((ref, seriesId) { +final mobileSeriesInfoProvider = + FutureProvider.family((ref, seriesId) { throw UnimplementedError('Use mobileSeriesInfoByPlaylistProvider instead'); }); @@ -44,9 +52,9 @@ final mobileSeriesInfoProvider = FutureProvider.family((ref, class SeriesInfoRequest { final PlaylistConfig playlist; final String seriesId; - + SeriesInfoRequest({required this.playlist, required this.seriesId}); - + @override bool operator ==(Object other) => identical(this, other) || @@ -54,25 +62,28 @@ class SeriesInfoRequest { runtimeType == other.runtimeType && playlist == other.playlist && seriesId == other.seriesId; - + @override int get hashCode => playlist.hashCode ^ seriesId.hashCode; } -final mobileSeriesInfoByPlaylistProvider = FutureProvider.family((ref, request) async { - final service = await ref.watch(mobileXtreamServiceProvider(request.playlist).future); +final mobileSeriesInfoByPlaylistProvider = + FutureProvider.family((ref, request) async { + final service = + await ref.watch(mobileXtreamServiceProvider(request.playlist).future); return service.getSeriesInfo(request.seriesId); }); // UI State Provider for persistent navigation -final mobileLiveTvUiStateProvider = StateProvider((ref) => LiveTvUiState()); +final mobileLiveTvUiStateProvider = + StateProvider((ref) => LiveTvUiState()); class LiveTvUiState { final String? selectedCategory; final bool isCategoryView; - + LiveTvUiState({this.selectedCategory, this.isCategoryView = true}); - + LiveTvUiState copyWith({String? selectedCategory, bool? isCategoryView}) { return LiveTvUiState( selectedCategory: selectedCategory ?? this.selectedCategory, @@ -80,3 +91,6 @@ class LiveTvUiState { ); } } + +/// Persistent index for mobile dashboard navigation +final mobileDashboardIndexProvider = StateProvider((ref) => 0); diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index 4289e1c..d9cfb7c 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -40,9 +40,9 @@ class _MobileScaffoldState extends ConsumerState { // Top Navigation Bar (Redesigned) Container( padding: const EdgeInsets.symmetric( - vertical: 24, - horizontal: - 24), // Increased vertical padding for floating effect + vertical: 24, + horizontal: 24, + ), // Increased vertical padding for floating effect decoration: const BoxDecoration( color: Colors.transparent, // Transparent background ), @@ -61,7 +61,7 @@ class _MobileScaffoldState extends ConsumerState { color: Colors.black.withOpacity(0.3), blurRadius: 10, spreadRadius: 2, - ) + ), ], ), child: Row( @@ -69,10 +69,12 @@ class _MobileScaffoldState extends ConsumerState { // --- LEFT: LOGO & BRAND --- Padding( padding: const EdgeInsets.only( - left: 8, right: 32), // More spacing after logo + left: 8, + right: 32, + ), // More spacing after logo child: Row( children: [ - Container( + SizedBox( width: 52, // Larger Logo height: 52, // No decoration for free-floating logo @@ -121,7 +123,9 @@ class _MobileScaffoldState extends ConsumerState { padding: const EdgeInsets.only(left: 32, right: 16), child: Container( padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), + horizontal: 16, + vertical: 8, + ), decoration: BoxDecoration( color: Colors.white .withOpacity(0.08), // Subtle background @@ -246,8 +250,8 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { borderRadius: BorderRadius.circular(20), // Matches pill shape border: _isFocused && !widget.isSelected ? Border.all( - color: Colors.white - .withOpacity(0.3)) // Subtle border for focus + color: Colors.white.withOpacity(0.3), + ) // Subtle border for focus : null, ), child: Row(