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