feat: Add initial mobile dashboard screen with Xtream data and UI state providers.

This commit is contained in:
Michael committed 2025-12-20 16:05:01 +01:00
1 parent 3145af9e8d
commit b249074db1
30 files changed
+1217 -582

No files matched your search

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