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
+1060 -425

No files matched your search

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