mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: Implement a web-based IPTV player with HLS/MPEG-TS support and Flutter communication.
This commit is contained in:
1 parent
2ca92d8425
commit
55acbbc135
28 files changed
+2075
-1448
No files matched your search
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../../features/auth/providers/auth_provider.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../theme/mobile_theme.dart';
|
||||
import '../../../../core/widgets/components/ui_components.dart';
|
||||
|
||||
class MobileLoginScreen extends ConsumerStatefulWidget {
|
||||
const MobileLoginScreen({super.key});
|
||||
@@ -103,8 +102,10 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
|
||||
labelText: 'Username',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty) ? 'Required' : null,
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? 'Required'
|
||||
: null,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -121,18 +122,21 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.isEmpty) ? 'Required' : null,
|
||||
validator: (value) => (value == null || value.isEmpty)
|
||||
? 'Required'
|
||||
: null,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
),
|
||||
if (authState.errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
authState.errorMessage!,
|
||||
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: AppColors.error, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -145,7 +149,8 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.black),
|
||||
)
|
||||
: const Text('Sign In'),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,8 @@ import '../../../services/local_playlist_service.dart';
|
||||
import 'mobile_dashboard_screen.dart';
|
||||
|
||||
/// Provider for local playlists
|
||||
final localPlaylistsProvider = FutureProvider<List<PlaylistConfig>>((ref) async {
|
||||
final localPlaylistsProvider =
|
||||
FutureProvider<List<PlaylistConfig>>((ref) async {
|
||||
final service = LocalPlaylistService();
|
||||
return service.getPlaylists();
|
||||
});
|
||||
@@ -18,7 +19,8 @@ class MobilePlaylistScreen extends ConsumerStatefulWidget {
|
||||
const MobilePlaylistScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MobilePlaylistScreen> createState() => _MobilePlaylistScreenState();
|
||||
ConsumerState<MobilePlaylistScreen> createState() =>
|
||||
_MobilePlaylistScreenState();
|
||||
}
|
||||
|
||||
class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
@@ -28,7 +30,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_playlistService.init();
|
||||
|
||||
|
||||
// Auto-login: if playlists exist, navigate to first one automatically
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkAutoLogin();
|
||||
@@ -64,8 +66,10 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
final isEditing = playlist != null;
|
||||
final nameController = TextEditingController(text: playlist?.name ?? '');
|
||||
final dnsController = TextEditingController(text: playlist?.dns ?? '');
|
||||
final usernameController = TextEditingController(text: playlist?.username ?? '');
|
||||
final passwordController = TextEditingController(text: playlist?.password ?? '');
|
||||
final usernameController =
|
||||
TextEditingController(text: playlist?.username ?? '');
|
||||
final passwordController =
|
||||
TextEditingController(text: playlist?.password ?? '');
|
||||
|
||||
// Focus nodes for TV navigation
|
||||
final nameFocus = FocusNode();
|
||||
@@ -90,7 +94,8 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
policy: OrderedTraversalPolicy(), // Ensure logical order
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView( // Ensure scrolling when keyboard/focus moves
|
||||
child: SingleChildScrollView(
|
||||
// Ensure scrolling when keyboard/focus moves
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -107,7 +112,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// Title
|
||||
Text(
|
||||
isEditing ? 'Modifier la playlist' : 'Ajouter une playlist',
|
||||
@@ -118,7 +123,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// Form fields
|
||||
_buildTextField(
|
||||
controller: nameController,
|
||||
@@ -126,10 +131,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
hint: 'Ma Playlist IPTV',
|
||||
icon: Icons.label_outline,
|
||||
textAction: TextInputAction.next,
|
||||
onSubmitted: () => FocusScope.of(context).requestFocus(dnsFocus),
|
||||
onSubmitted: () =>
|
||||
FocusScope.of(context).requestFocus(dnsFocus),
|
||||
focusNode: nameFocus,
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: dnsController,
|
||||
@@ -138,10 +144,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
icon: Icons.dns_outlined,
|
||||
keyboardType: TextInputType.url,
|
||||
textAction: TextInputAction.next,
|
||||
onSubmitted: () => FocusScope.of(context).requestFocus(usernameFocus),
|
||||
onSubmitted: () =>
|
||||
FocusScope.of(context).requestFocus(usernameFocus),
|
||||
focusNode: dnsFocus,
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: usernameController,
|
||||
@@ -149,10 +156,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
hint: 'username',
|
||||
icon: Icons.person_outline,
|
||||
textAction: TextInputAction.next,
|
||||
onSubmitted: () => FocusScope.of(context).requestFocus(passwordFocus),
|
||||
onSubmitted: () =>
|
||||
FocusScope.of(context).requestFocus(passwordFocus),
|
||||
focusNode: usernameFocus,
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: passwordController,
|
||||
@@ -161,12 +169,13 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
icon: Icons.lock_outline,
|
||||
obscureText: true,
|
||||
textAction: TextInputAction.done,
|
||||
onSubmitted: () => FocusScope.of(context).requestFocus(buttonFocus),
|
||||
onSubmitted: () =>
|
||||
FocusScope.of(context).requestFocus(buttonFocus),
|
||||
focusNode: passwordFocus,
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
// Save button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -186,55 +195,58 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fix URL scheme
|
||||
String dns = dnsController.text.trim();
|
||||
if (!dns.startsWith('http://') && !dns.startsWith('https://')) {
|
||||
dns = 'http://$dns';
|
||||
}
|
||||
// Auto-fix URL scheme
|
||||
String dns = dnsController.text.trim();
|
||||
if (!dns.startsWith('http://') &&
|
||||
!dns.startsWith('https://')) {
|
||||
dns = 'http://$dns';
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
await _playlistService.updatePlaylist(
|
||||
id: playlist!.id,
|
||||
name: nameController.text.trim(),
|
||||
dns: dns,
|
||||
username: usernameController.text.trim(),
|
||||
password: passwordController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await _playlistService.addPlaylist(
|
||||
name: nameController.text.trim(),
|
||||
dns: dns,
|
||||
username: usernameController.text.trim(),
|
||||
password: passwordController.text.trim(),
|
||||
);
|
||||
}
|
||||
if (isEditing) {
|
||||
await _playlistService.updatePlaylist(
|
||||
id: playlist.id,
|
||||
name: nameController.text.trim(),
|
||||
dns: dns,
|
||||
username: usernameController.text.trim(),
|
||||
password: passwordController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await _playlistService.addPlaylist(
|
||||
name: nameController.text.trim(),
|
||||
dns: dns,
|
||||
username: usernameController.text.trim(),
|
||||
password: passwordController.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
_refreshPlaylists();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isEditing ? 'Playlist modifiée' : 'Playlist ajoutée'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: Icon(isEditing ? Icons.save : Icons.add),
|
||||
label: Text(isEditing ? 'Enregistrer' : 'Ajouter'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
_refreshPlaylists();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isEditing
|
||||
? 'Playlist modifiée'
|
||||
: 'Playlist ajoutée'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: Icon(isEditing ? Icons.save : Icons.add),
|
||||
label: Text(isEditing ? 'Enregistrer' : 'Ajouter'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,11 +263,11 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
}) {
|
||||
// Create a dedicated internal focus node for the actual TextField
|
||||
// We don't expose this, allowing us to control when the keyboard opens
|
||||
|
||||
|
||||
// Since we are inside a stateless method but need state for focus nodes if we want to create them on the fly...
|
||||
// Actually, to avoid State complexity, we will trust the passed 'focusNode' is for NAVIGATION.
|
||||
// And we will use a LayoutBuilder + Stateful wrapper or just a customized Focus widget.
|
||||
|
||||
|
||||
return _TVTextField(
|
||||
controller: controller,
|
||||
label: label,
|
||||
@@ -269,7 +281,6 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _showOptionsSheet(PlaylistConfig playlist) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -297,7 +308,8 @@ 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);
|
||||
@@ -305,7 +317,8 @@ 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);
|
||||
@@ -324,10 +337,14 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Supprimer la playlist ?',
|
||||
style: TextStyle(color: AppColors.textPrimary)),
|
||||
content: Text('Voulez-vous supprimer "${playlist.name}" ?',
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
title: const Text(
|
||||
'Supprimer la playlist ?',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
content: Text(
|
||||
'Voulez-vous supprimer "${playlist.name}" ?',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -371,13 +388,19 @@ 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',
|
||||
style: TextStyle(color: AppColors.textPrimary)),
|
||||
const Text(
|
||||
'Erreur de chargement',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(error.toString(),
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: _refreshPlaylists,
|
||||
@@ -540,7 +563,8 @@ 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
|
||||
),
|
||||
],
|
||||
@@ -589,7 +613,7 @@ class _TVTextFieldState extends State<_TVTextField> {
|
||||
super.initState();
|
||||
_inputFocus = FocusNode();
|
||||
_navFocus = widget.navigationFocus ?? FocusNode();
|
||||
|
||||
|
||||
// When input loses focus (keyboard closed), return to nav focus
|
||||
_inputFocus.addListener(() {
|
||||
if (!_inputFocus.hasFocus && _isEditing) {
|
||||
@@ -622,8 +646,8 @@ class _TVTextFieldState extends State<_TVTextField> {
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
final key = event.logicalKey;
|
||||
if (key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.enter ||
|
||||
if (key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.space ||
|
||||
key == LogicalKeyboardKey.gameButtonA ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
@@ -640,7 +664,9 @@ class _TVTextFieldState extends State<_TVTextField> {
|
||||
onTap: _activateInput,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: isFocused ? Border.all(color: AppColors.primary, width: 2) : null,
|
||||
border: isFocused
|
||||
? Border.all(color: AppColors.primary, width: 2)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
// Ignore standard focus traversal to child, only allow programatic focus
|
||||
@@ -650,37 +676,44 @@ class _TVTextFieldState extends State<_TVTextField> {
|
||||
controller: widget.controller,
|
||||
focusNode: _inputFocus,
|
||||
// Prevent keyboard if somehow focused while not editing
|
||||
readOnly: !_isEditing,
|
||||
readOnly: !_isEditing,
|
||||
showCursor: _isEditing,
|
||||
obscureText: widget.obscureText,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textAction,
|
||||
onSubmitted: (_) {
|
||||
setState(() => _isEditing = false);
|
||||
// Give focus back to nav node first so we can move to next
|
||||
_navFocus.requestFocus();
|
||||
widget.onSubmitted?.call();
|
||||
setState(() => _isEditing = false);
|
||||
// Give focus back to nav node first so we can move to next
|
||||
_navFocus.requestFocus();
|
||||
widget.onSubmitted?.call();
|
||||
},
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hint,
|
||||
prefixIcon: Icon(widget.icon, color: isFocused ? AppColors.primary : AppColors.textSecondary),
|
||||
labelStyle: TextStyle(color: isFocused ? AppColors.primary : AppColors.textSecondary),
|
||||
hintStyle: const TextStyle(color: AppColors.textTertiary),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
labelText: widget.label,
|
||||
hintText: widget.hint,
|
||||
prefixIcon: Icon(widget.icon,
|
||||
color: isFocused
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary),
|
||||
labelStyle: TextStyle(
|
||||
color: isFocused
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary),
|
||||
hintStyle: const TextStyle(color: AppColors.textTertiary),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 14),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/services/playlist_api_service.dart';
|
||||
import '../../../../core/models/playlist_config.dart';
|
||||
import '../../../../features/auth/providers/auth_provider.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../theme/mobile_theme.dart';
|
||||
|
||||
@@ -18,10 +18,12 @@ class MobilePlaylistSelectionScreen extends ConsumerStatefulWidget {
|
||||
const MobilePlaylistSelectionScreen({super.key, this.manageMode = false});
|
||||
|
||||
@override
|
||||
ConsumerState<MobilePlaylistSelectionScreen> createState() => _MobilePlaylistSelectionScreenState();
|
||||
ConsumerState<MobilePlaylistSelectionScreen> createState() =>
|
||||
_MobilePlaylistSelectionScreenState();
|
||||
}
|
||||
|
||||
class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSelectionScreen> {
|
||||
class _MobilePlaylistSelectionScreenState
|
||||
extends ConsumerState<MobilePlaylistSelectionScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -42,9 +44,155 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showPlaylistDialog({PlaylistConfig? playlist}) async {
|
||||
final isEditing = playlist != null;
|
||||
final nameController = TextEditingController(text: playlist?.name ?? '');
|
||||
final dnsController = TextEditingController(text: playlist?.dns ?? '');
|
||||
final userController =
|
||||
TextEditingController(text: playlist?.username ?? '');
|
||||
final passController =
|
||||
TextEditingController(text: playlist?.password ?? '');
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: Text(
|
||||
isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
|
||||
style: GoogleFonts.inter(color: Colors.white),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom',
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white24)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: dnsController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL (DNS)',
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white24)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: userController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Utilisateur',
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white24)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: passController,
|
||||
obscureText: true,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mot de passe',
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white24)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (isEditing)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Confirmer',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
content: const Text('Supprimer cette playlist ?',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style:
|
||||
TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
await PlaylistApiService().deletePlaylist(playlist.id);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ref.invalidate(playlistsProvider);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (nameController.text.isEmpty || dnsController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final service = PlaylistApiService();
|
||||
if (isEditing) {
|
||||
await service.updatePlaylist(
|
||||
id: playlist.id,
|
||||
name: nameController.text,
|
||||
dns: dnsController.text,
|
||||
username: userController.text,
|
||||
password: passController.text,
|
||||
);
|
||||
} else {
|
||||
await service.createPlaylist(
|
||||
name: nameController.text,
|
||||
dns: dnsController.text,
|
||||
username: userController.text,
|
||||
password: passController.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ref.invalidate(playlistsProvider);
|
||||
}
|
||||
},
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentUser = ref.watch(authProvider).currentUser;
|
||||
final playlistsAsync = ref.watch(playlistsProvider);
|
||||
|
||||
return Theme(
|
||||
@@ -56,11 +204,17 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (widget.manageMode)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
if (widget.manageMode) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _showPlaylistDialog(),
|
||||
tooltip: 'Ajouter une playlist',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
@@ -73,11 +227,13 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
|
||||
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.refresh(playlistsProvider),
|
||||
onPressed: () => ref.invalidate(playlistsProvider),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
@@ -85,51 +241,51 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
|
||||
),
|
||||
data: (playlists) {
|
||||
if (playlists.isEmpty) {
|
||||
// Even in auto-mode, if empty we show this (or add button)
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
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),
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showPlaylistDialog(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter une Playlist'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 12),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Logic to add playlist (usually dialog or separate screen)
|
||||
// specific logic for add playlist
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter une Playlist'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 100, 16, 16), // Top padding for AppBar
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16, 100, 16, 16), // Top padding for AppBar
|
||||
itemCount: playlists.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = playlists[index];
|
||||
return _MobilePlaylistCard(
|
||||
playlist: playlist,
|
||||
manageMode: widget.manageMode,
|
||||
onTap: () {
|
||||
if (widget.manageMode) {
|
||||
// In manage mode, maybe we want to edit or delete?
|
||||
// For now, just switch to it.
|
||||
context.go('/dashboard', extra: playlist);
|
||||
_showPlaylistDialog(playlist: playlist);
|
||||
} else {
|
||||
context.go('/dashboard', extra: playlist);
|
||||
context.go('/dashboard', extra: playlist);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -146,10 +302,12 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState<MobilePlaylistSe
|
||||
class _MobilePlaylistCard extends StatelessWidget {
|
||||
final PlaylistConfig playlist;
|
||||
final VoidCallback onTap;
|
||||
final bool manageMode;
|
||||
|
||||
const _MobilePlaylistCard({
|
||||
required this.playlist,
|
||||
required this.onTap,
|
||||
this.manageMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -183,7 +341,8 @@ 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(
|
||||
@@ -213,7 +372,11 @@ class _MobilePlaylistCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppColors.textTertiary),
|
||||
if (manageMode)
|
||||
const Icon(Icons.edit, color: AppColors.primary)
|
||||
else
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textTertiary),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -292,15 +292,11 @@ class _MobileSeriesDetailScreenState
|
||||
);
|
||||
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
|
||||
|
||||
// Use theme colors for proper light/dark mode support
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final textColor = isDark ? Colors.white : Colors.black;
|
||||
final secondaryTextColor =
|
||||
isDark ? Colors.grey.shade400 : Colors.grey.shade600;
|
||||
final surfaceColor =
|
||||
isDark ? const Color(0xFF1C1C1E) : Colors.grey.shade200;
|
||||
final primaryColor = isDark ? Colors.white : Colors.blue;
|
||||
// Force dark mode colors since background is hardcoded dark
|
||||
const textColor = Colors.white;
|
||||
final secondaryTextColor = Colors.grey.shade400;
|
||||
const surfaceColor = Color(0xFF1C1C1E);
|
||||
const primaryColor = Colors.white; // Or accent color if preferred
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -326,10 +322,8 @@ class _MobileSeriesDetailScreenState
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom:
|
||||
BorderSide(color: isDark ? Colors.white10 : Colors.black12)),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Colors.white10)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -347,8 +341,8 @@ class _MobileSeriesDetailScreenState
|
||||
child: Icon(
|
||||
isWatched ? Icons.check : Icons.play_arrow,
|
||||
color: isWatched
|
||||
? (isDark ? Colors.black : Colors.white)
|
||||
: primaryColor,
|
||||
? Colors.black // Checkmark on white bg
|
||||
: primaryColor, // Play icon
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -21,7 +21,8 @@ class MobileSeriesTab extends ConsumerStatefulWidget {
|
||||
ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState();
|
||||
}
|
||||
|
||||
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with AutomaticKeepAliveClientMixin {
|
||||
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
@@ -33,7 +34,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
|
||||
bool _isSearching = false;
|
||||
bool _hasMore = true;
|
||||
int _currentOffset = 0;
|
||||
static const int _pageSize = 50;
|
||||
static const int _pageSize = 50;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
@@ -54,9 +55,10 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
|
||||
|
||||
void _onScroll() {
|
||||
// Only trigger load when near bottom and not already loading
|
||||
if (!_isLoading && _hasMore &&
|
||||
if (!_isLoading &&
|
||||
_hasMore &&
|
||||
_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent * 0.8) {
|
||||
_scrollController.position.maxScrollExtent * 0.8) {
|
||||
_loadMoreSeries();
|
||||
}
|
||||
}
|
||||
@@ -70,7 +72,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (query.length >= 2) {
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
|
||||
_performSearch(query);
|
||||
@@ -81,11 +83,12 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (query.isEmpty) return;
|
||||
setState(() => _isSearching = true);
|
||||
|
||||
|
||||
try {
|
||||
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
|
||||
final service =
|
||||
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
|
||||
final results = await service.searchSeries(query);
|
||||
|
||||
|
||||
if (mounted && _searchQuery == query) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
@@ -104,13 +107,16 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
|
||||
|
||||
final service =
|
||||
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
|
||||
|
||||
// Add timeout to prevent infinite loading state
|
||||
final newSeries = await service.getSeriesPaginated(
|
||||
offset: _currentOffset,
|
||||
limit: _pageSize,
|
||||
).timeout(const Duration(seconds: 15));
|
||||
final newSeries = await service
|
||||
.getSeriesPaginated(
|
||||
offset: _currentOffset,
|
||||
limit: _pageSize,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -134,10 +140,11 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
|
||||
Future<void> _refresh() async {
|
||||
// Clear service cache to force fresh data
|
||||
try {
|
||||
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
|
||||
final service =
|
||||
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
|
||||
service.clearCache();
|
||||
} catch (_) {}
|
||||
|
||||
|
||||
setState(() {
|
||||
_series.clear();
|
||||
_currentOffset = 0;
|
||||
@@ -184,159 +191,176 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> with Automati
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final settings = ref.watch(mobileSettingsProvider);
|
||||
|
||||
|
||||
List<Series> displaySeries;
|
||||
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
||||
displaySeries = _searchResults!;
|
||||
} else {
|
||||
displaySeries = settings.seriesKeywords.isEmpty
|
||||
? _series
|
||||
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
|
||||
: _series
|
||||
.where((s) => settings.matchesSeriesFilter(s.categoryName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Hero Items (use filtered series)
|
||||
final heroItems = displaySeries.take(3).map((s) => HeroItem(
|
||||
id: s.seriesId.toString(),
|
||||
title: s.name,
|
||||
imageUrl: _getImageUrl(s.cover),
|
||||
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
|
||||
onMoreInfo: () => _openSeries(s),
|
||||
)).toList();
|
||||
final heroItems = displaySeries
|
||||
.take(3)
|
||||
.map(
|
||||
(s) => HeroItem(
|
||||
id: s.seriesId.toString(),
|
||||
title: s.name,
|
||||
imageUrl: _getImageUrl(s.cover),
|
||||
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
|
||||
onMoreInfo: () => _openSeries(s),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.appleTvGradient),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
color: AppColors.primary,
|
||||
backgroundColor: AppColors.surface,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
// Hero Section (reduced height, moved above search)
|
||||
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: SizedBox(
|
||||
height: 180, // Reduced from 250
|
||||
child: HeroCarousel(
|
||||
items: heroItems,
|
||||
onTap: (item) {
|
||||
try {
|
||||
final series = _series.firstWhere((s) => s.seriesId.toString() == item.id);
|
||||
_openSeries(series);
|
||||
} catch (e) {
|
||||
// Fallback logic not critical
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search Bar (moved below carousel)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TVFocusable(
|
||||
onPressed: () {
|
||||
setState(() => _isSearchEditing = true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_searchFocusNode.requestFocus();
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: _isSearchEditing
|
||||
? Border.all(color: AppColors.primary)
|
||||
: Border.all(color: AppColors.border),
|
||||
bottom: false,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
color: AppColors.primary,
|
||||
backgroundColor: AppColors.surface,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
// Hero Section (reduced height, moved above search)
|
||||
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: SizedBox(
|
||||
height: 180, // Reduced from 250
|
||||
child: HeroCarousel(
|
||||
items: heroItems,
|
||||
onTap: (item) {
|
||||
try {
|
||||
final series = _series.firstWhere(
|
||||
(s) => s.seriesId.toString() == item.id);
|
||||
_openSeries(series);
|
||||
} catch (e) {
|
||||
// Fallback logic not critical
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ExcludeFocus(
|
||||
excluding: !_isSearchEditing,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
readOnly: !_isSearchEditing,
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Rechercher une série...',
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.only(bottom: 11),
|
||||
),
|
||||
),
|
||||
|
||||
// Search Bar (moved below carousel)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TVFocusable(
|
||||
onPressed: () {
|
||||
setState(() => _isSearchEditing = true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_searchFocusNode.requestFocus();
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: _isSearchEditing
|
||||
? Border.all(color: AppColors.primary)
|
||||
: Border.all(color: AppColors.border),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search,
|
||||
size: 20, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ExcludeFocus(
|
||||
excluding: !_isSearchEditing,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
readOnly: !_isSearchEditing,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: AppColors.textPrimary),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Rechercher une série...',
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.only(bottom: 11),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (_) =>
|
||||
setState(() => _isSearchEditing = false),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (_) => setState(() => _isSearchEditing = false),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
if (_isSearching)
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2)),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Grid
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 8, // Reduced size further (was 4)
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 0.7,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= displaySeries.length) return null;
|
||||
final series = displaySeries[index];
|
||||
|
||||
return MobilePosterCard(
|
||||
title: series.name,
|
||||
imageUrl: _getImageUrl(series.cover),
|
||||
rating: _formatRating(series.rating),
|
||||
placeholderIcon: Icons.tv,
|
||||
onTap: () => _openSeries(series),
|
||||
);
|
||||
},
|
||||
childCount: displaySeries.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Grid
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 8, // Reduced size further (was 4)
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 0.7,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= displaySeries.length) return null;
|
||||
final series = displaySeries[index];
|
||||
|
||||
return MobilePosterCard(
|
||||
title: series.name,
|
||||
imageUrl: _getImageUrl(series.cover),
|
||||
rating: _formatRating(series.rating),
|
||||
placeholderIcon: Icons.tv,
|
||||
onTap: () => _openSeries(series),
|
||||
);
|
||||
},
|
||||
childCount: displaySeries.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import '../../../providers/mobile_settings_providers.dart';
|
||||
import '../../../widgets/tv_focusable.dart';
|
||||
import '../../../../core/providers/theme_provider.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../screens/mobile_playlist_selection_screen.dart';
|
||||
|
||||
@@ -61,8 +61,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(mobileSettingsProvider);
|
||||
final themeNotifier = ref.read(themeProvider.notifier);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_initialized) _syncControllers(settings);
|
||||
@@ -114,16 +112,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === APPARENCE ===
|
||||
_buildSectionHeader('Apparence'),
|
||||
|
||||
_buildSettingItem(
|
||||
icon: isDark ? Icons.dark_mode : Icons.light_mode,
|
||||
title: 'Mode Sombre',
|
||||
value: isDark ? 'Activé' : 'Désactivé',
|
||||
onTap: () => themeNotifier.toggleTheme(),
|
||||
),
|
||||
|
||||
_buildSettingItem(
|
||||
icon: Icons.access_time,
|
||||
title: 'Afficher l\'heure',
|
||||
@@ -138,16 +126,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
// === LECTURE VIDEO ===
|
||||
_buildSectionHeader('Lecture Vidéo'),
|
||||
|
||||
_buildSettingItem(
|
||||
icon: Icons.bug_report,
|
||||
title: 'Stats pour Nerds',
|
||||
subtitle: 'Affiche FPS, Buffer, Bitrate',
|
||||
value: settings.showDebugStats ? 'Activé' : 'Désactivé',
|
||||
onTap: () => ref
|
||||
.read(mobileSettingsProvider.notifier)
|
||||
.toggleShowDebugStats(!settings.showDebugStats),
|
||||
),
|
||||
|
||||
_buildSettingItem(
|
||||
icon: Icons.memory,
|
||||
title: 'Décodeur Vidéo',
|
||||
|
||||
@@ -52,8 +52,9 @@ class MobileSettings {
|
||||
if (liveTvKeywords.isEmpty) return true;
|
||||
if (category == null) return true;
|
||||
final lowerCategory = category.toLowerCase();
|
||||
return liveTvKeywords.any((keyword) =>
|
||||
lowerCategory.contains(keyword.toLowerCase()));
|
||||
return liveTvKeywords.any(
|
||||
(keyword) => lowerCategory.contains(keyword.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if category matches Movies filter
|
||||
@@ -61,8 +62,9 @@ class MobileSettings {
|
||||
if (moviesKeywords.isEmpty) return true;
|
||||
if (category == null) return true;
|
||||
final lowerCategory = category.toLowerCase();
|
||||
return moviesKeywords.any((keyword) =>
|
||||
lowerCategory.contains(keyword.toLowerCase()));
|
||||
return moviesKeywords.any(
|
||||
(keyword) => lowerCategory.contains(keyword.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if category matches Series filter
|
||||
@@ -70,8 +72,9 @@ class MobileSettings {
|
||||
if (seriesKeywords.isEmpty) return true;
|
||||
if (category == null) return true;
|
||||
final lowerCategory = category.toLowerCase();
|
||||
return seriesKeywords.any((keyword) =>
|
||||
lowerCategory.contains(keyword.toLowerCase()));
|
||||
return seriesKeywords.any(
|
||||
(keyword) => lowerCategory.contains(keyword.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,16 +88,22 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
||||
|
||||
Future<void> _init() async {
|
||||
_box = await Hive.openBox(_boxName);
|
||||
|
||||
final liveTv = _box?.get('liveTvKeywords', defaultValue: <String>[]) as List?;
|
||||
final movies = _box?.get('moviesKeywords', defaultValue: <String>[]) as List?;
|
||||
final series = _box?.get('seriesKeywords', defaultValue: <String>[]) as List?;
|
||||
|
||||
final liveTv =
|
||||
_box?.get('liveTvKeywords', defaultValue: <String>[]) as List?;
|
||||
final movies =
|
||||
_box?.get('moviesKeywords', defaultValue: <String>[]) as List?;
|
||||
final series =
|
||||
_box?.get('seriesKeywords', defaultValue: <String>[]) as List?;
|
||||
final showClock = _box?.get('showClock', defaultValue: false) as bool?;
|
||||
final showDebugStats = _box?.get('showDebugStats', defaultValue: false) as bool?;
|
||||
final decoderMode = _box?.get('decoderMode', defaultValue: 'auto') as String?;
|
||||
final playerEngine = _box?.get('playerEngine', defaultValue: 'ultra') as String?;
|
||||
final showDebugStats =
|
||||
_box?.get('showDebugStats', defaultValue: false) as bool?;
|
||||
final decoderMode =
|
||||
_box?.get('decoderMode', defaultValue: 'auto') as String?;
|
||||
final playerEngine =
|
||||
_box?.get('playerEngine', defaultValue: 'ultra') as String?;
|
||||
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
|
||||
|
||||
|
||||
state = MobileSettings(
|
||||
liveTvKeywords: liveTv?.cast<String>() ?? [],
|
||||
moviesKeywords: movies?.cast<String>() ?? [],
|
||||
@@ -148,7 +157,8 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
final mobileSettingsProvider = StateNotifierProvider<MobileSettingsNotifier, MobileSettings>((ref) {
|
||||
final mobileSettingsProvider =
|
||||
StateNotifierProvider<MobileSettingsNotifier, MobileSettings>((ref) {
|
||||
return MobileSettingsNotifier();
|
||||
});
|
||||
|
||||
@@ -177,8 +187,9 @@ class MobileWatchHistory {
|
||||
}
|
||||
|
||||
bool isMovieWatched(String streamId) => watchedMovies.contains(streamId);
|
||||
bool isEpisodeWatched(String episodeKey) => watchedEpisodes.contains(episodeKey);
|
||||
|
||||
bool isEpisodeWatched(String episodeKey) =>
|
||||
watchedEpisodes.contains(episodeKey);
|
||||
|
||||
/// Get resume position in seconds (0 if none saved)
|
||||
int getResumePosition(String contentId) => resumePositions[contentId] ?? 0;
|
||||
|
||||
@@ -197,11 +208,13 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
|
||||
|
||||
Future<void> _init() async {
|
||||
_box = await Hive.openBox(_boxName);
|
||||
|
||||
final movies = _box?.get('watchedMovies', defaultValue: <String>[]) as List?;
|
||||
final episodes = _box?.get('watchedEpisodes', defaultValue: <String>[]) as List?;
|
||||
|
||||
final movies =
|
||||
_box?.get('watchedMovies', defaultValue: <String>[]) as List?;
|
||||
final episodes =
|
||||
_box?.get('watchedEpisodes', defaultValue: <String>[]) as List?;
|
||||
final positionsRaw = _box?.get('resumePositions');
|
||||
|
||||
|
||||
// Safe conversion for resumePositions map
|
||||
Map<String, int> positions = {};
|
||||
if (positionsRaw != null && positionsRaw is Map) {
|
||||
@@ -213,7 +226,7 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
state = MobileWatchHistory(
|
||||
watchedMovies: movies?.cast<String>().toSet() ?? {},
|
||||
watchedEpisodes: episodes?.cast<String>().toSet() ?? {},
|
||||
@@ -247,25 +260,28 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
|
||||
// Clear resume position when marked as fully watched
|
||||
final positions = {...state.resumePositions};
|
||||
positions.remove(episodeKey);
|
||||
state = state.copyWith(watchedEpisodes: updated, resumePositions: positions);
|
||||
state =
|
||||
state.copyWith(watchedEpisodes: updated, resumePositions: positions);
|
||||
_box?.put('watchedEpisodes', updated.toList());
|
||||
_box?.put('resumePositions', positions);
|
||||
}
|
||||
|
||||
|
||||
/// Save resume position for movie or episode
|
||||
void saveResumePosition(String contentId, int positionSeconds) {
|
||||
// Only save if position is meaningful (> 30 seconds)
|
||||
if (positionSeconds < 30) {
|
||||
debugPrint('[WatchHistory] Position too short ($positionSeconds), skipping save');
|
||||
debugPrint(
|
||||
'[WatchHistory] Position too short ($positionSeconds), skipping save');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[WatchHistory] Saving position for $contentId: ${positionSeconds}s');
|
||||
|
||||
debugPrint(
|
||||
'[WatchHistory] Saving position for $contentId: ${positionSeconds}s');
|
||||
final positions = {...state.resumePositions, contentId: positionSeconds};
|
||||
state = state.copyWith(resumePositions: positions);
|
||||
_box?.put('resumePositions', Map<String, int>.from(positions));
|
||||
}
|
||||
|
||||
|
||||
/// Clear resume position (when content finished or user wants to restart)
|
||||
void clearResumePosition(String contentId) {
|
||||
final positions = {...state.resumePositions};
|
||||
@@ -275,7 +291,9 @@ class MobileWatchHistoryNotifier extends StateNotifier<MobileWatchHistory> {
|
||||
}
|
||||
}
|
||||
|
||||
final mobileWatchHistoryProvider = StateNotifierProvider<MobileWatchHistoryNotifier, MobileWatchHistory>((ref) {
|
||||
final mobileWatchHistoryProvider =
|
||||
StateNotifierProvider<MobileWatchHistoryNotifier, MobileWatchHistory>(
|
||||
(ref) {
|
||||
return MobileWatchHistoryNotifier();
|
||||
});
|
||||
|
||||
@@ -308,6 +326,7 @@ class MobileFavoritesNotifier extends StateNotifier<Set<String>> {
|
||||
bool isFavorite(String streamId) => state.contains(streamId);
|
||||
}
|
||||
|
||||
final mobileFavoritesProvider = StateNotifierProvider<MobileFavoritesNotifier, Set<String>>((ref) {
|
||||
final mobileFavoritesProvider =
|
||||
StateNotifierProvider<MobileFavoritesNotifier, Set<String>>((ref) {
|
||||
return MobileFavoritesNotifier();
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
class TVFocusable extends StatefulWidget {
|
||||
final Widget child;
|
||||
@@ -107,7 +106,7 @@ class _TVFocusableState extends State<TVFocusable> {
|
||||
if (!_isKeyDown) {
|
||||
_isKeyDown = true;
|
||||
_longPressTriggered = false;
|
||||
|
||||
|
||||
// Start long press timer only if onLongPress is defined
|
||||
if (widget.onLongPress != null) {
|
||||
_longPressTimer?.cancel();
|
||||
@@ -121,20 +120,20 @@ class _TVFocusableState extends State<TVFocusable> {
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
|
||||
if (event is KeyUpEvent && isSelectKey) {
|
||||
_longPressTimer?.cancel();
|
||||
|
||||
|
||||
// If long press wasn't triggered, it's a normal press
|
||||
if (_isKeyDown && !_longPressTriggered) {
|
||||
_handlePress();
|
||||
}
|
||||
|
||||
|
||||
_isKeyDown = false;
|
||||
_longPressTriggered = false;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: GestureDetector(
|
||||
@@ -153,15 +152,18 @@ class _TVFocusableState extends State<TVFocusable> {
|
||||
child: Container(
|
||||
decoration: _isFocused
|
||||
? BoxDecoration(
|
||||
borderRadius: widget.borderRadius ?? BorderRadius.circular(12),
|
||||
borderRadius:
|
||||
widget.borderRadius ?? BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5), // Shadow for depth
|
||||
color: Colors.black
|
||||
.withOpacity(0.5), // Shadow for depth
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: widget.focusColor.withOpacity(0.3), // Glow effect
|
||||
color: widget.focusColor
|
||||
.withOpacity(0.3), // Glow effect
|
||||
blurRadius: 12,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
@@ -170,7 +172,8 @@ class _TVFocusableState extends State<TVFocusable> {
|
||||
: null,
|
||||
foregroundDecoration: _isFocused
|
||||
? BoxDecoration(
|
||||
borderRadius: widget.borderRadius ?? BorderRadius.circular(12),
|
||||
borderRadius:
|
||||
widget.borderRadius ?? BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.focusColor,
|
||||
width: widget.borderWidth,
|
||||
|
||||
Reference in new issue
Block a user