From 8128174ac385a224e2ffc55cc16e08041f43536e Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 16 Dec 2025 07:39:33 +0100 Subject: [PATCH] feat: Implement streaming handlers for live MPEG-TS proxy and VOD HLS transcoding via FFmpeg, and add a release build script. --- BUILD.md | 37 ++ bin/api/streaming_handler.dart | 26 +- bin/database/database.dart | 61 ++- bin/server.dart | 34 +- build_release.ps1 | 45 ++ lib/core/database/hive_service.dart | 19 +- lib/core/theme/app_colors.dart | 55 +- lib/core/widgets/glass_container.dart | 1 - lib/core/widgets/themed_loading_screen.dart | 1 - lib/features/admin/screens/admin_panel.dart | 366 +++++++++---- lib/features/auth/screens/login_screen.dart | 14 +- .../iptv/screens/dashboard_screen.dart | 2 +- lib/features/iptv/screens/player_screen.dart | 503 ++++++++++-------- .../screens/playlist_selection_screen.dart | 43 +- .../iptv/widgets/category_carousel.dart | 27 +- lib/features/iptv/widgets/epg_overlay.dart | 1 - .../iptv/widgets/lite_player_view.dart | 2 +- lib/features/iptv/widgets/live_tv_tab.dart | 262 +++++---- lib/features/iptv/widgets/movies_tab.dart | 211 +++++--- lib/features/iptv/widgets/series_tab.dart | 197 ++++--- lib/features/iptv/widgets/settings_tab.dart | 183 ++++--- .../iptv/widgets/streaming_settings_tab.dart | 11 +- .../auth/screens/mobile_login_screen.dart | 2 +- .../screens/mobile_series_detail_screen.dart | 56 +- .../iptv/widgets/mobile_live_tv_tab.dart | 148 ++++-- .../iptv/widgets/mobile_movies_tab.dart | 149 ++++-- 26 files changed, 1529 insertions(+), 927 deletions(-) create mode 100644 BUILD.md create mode 100644 build_release.ps1 diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..2f763a1 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,37 @@ +# How to Build XtremFlow as an Executable + +To create a standalone `.exe` version of XtremFlow, you need to build it on your machine. + +## Prerequisites + +Since you are currently using Docker, you likely don't have these installed on your Windows system yet. You must install them to build the Windows executable. + +1. **Install Flutter SDK** + * Download from: [flutter.dev/install/windows](https://docs.flutter.dev/get-started/install/windows) + * Extract the zip file to `C:\src\flutter` (for example). + * Add `flutter/bin` to your **System PATH**. + +2. **Verify Installation** + * Open a new terminal. + * Run `flutter doctor`. + * Ensure it says "Flutter ... channel stable". + +## Building the App + +Once Flutter is installed: + +1. Open PowerShell in this folder. +2. Run the build script: + ```powershell + .\build_release.ps1 + ``` + +## Adding FFmpeg + +1. After the build finishes, open the new `dist` folder. +2. Go to `dist/ffmpeg/bin`. +3. Download `ffmpeg.exe` (from [ffmpeg.org](https://ffmpeg.org/download.html)) and place it there. + +## Running + +Double-click `dist/start.bat` to run the server! diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 881547e..18e3961 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -5,13 +5,23 @@ import 'dart:math'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:http/http.dart' as http; -import '../../lib/core/models/playlist_config.dart'; +import 'package:xtremflow/core/models/playlist_config.dart'; /// Active FFmpeg processes for VOD transcoding final Map _vodProcesses = {}; /// Directory for temporary HLS segments -final Directory _hlsTempDir = Directory('/tmp/hls_vod'); +final Directory _hlsTempDir = Directory('${Directory.systemTemp.path}/xtremflow_streams'); + +/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable) +String _getFFmpegPath() { + if (Platform.isWindows) { + if (File('ffmpeg.exe').existsSync()) return 'ffmpeg.exe'; + if (File('bin/ffmpeg.exe').existsSync()) return 'bin/ffmpeg.exe'; + if (File('ffmpeg/bin/ffmpeg.exe').existsSync()) return 'ffmpeg/bin/ffmpeg.exe'; + } + return 'ffmpeg'; // Default to PATH +} /// Initialize streaming subsystem Future initStreaming() async { @@ -76,11 +86,12 @@ Handler createLiveStreamHandler(Future Function(Request) getPla '-c', 'copy', // Direct stream copy (Very low CPU) '-f', 'mpegts', - 'pipe:1' // Output to stdout + 'pipe:1', // Output to stdout ]; try { - final process = await Process.start('ffmpeg', ffmpegArgs); + final ffmpegPath = _getFFmpegPath(); + final process = await Process.start(ffmpegPath, ffmpegArgs); // Cleanup handling: Kill FFmpeg when client disconnects final controller = StreamController>( @@ -100,7 +111,7 @@ Handler createLiveStreamHandler(Future Function(Request) getPla onError: (e) { print('[Live] FFmpeg stream error: $e'); controller.addError(e); - } + }, ); // Log errors @@ -257,10 +268,11 @@ Handler createVodStreamHandler(Future Function(Request) getPlay '-start_number', '0', // Write playlist to stdout or file? File is easier for static serving - '${streamDir.path}/playlist.m3u8' + '${streamDir.path}/playlist.m3u8', ]; - Process.start('ffmpeg', ffmpegArgs).then((process) { + final ffmpegPath = _getFFmpegPath(); + Process.start(ffmpegPath, ffmpegArgs).then((process) { _vodProcesses[streamId] = process; process.stderr.transform(utf8.decoder).listen((data) { diff --git a/bin/database/database.dart b/bin/database/database.dart index 2a980f5..d0b5f74 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -12,8 +12,8 @@ class AppDatabase { /// Initialize database and create tables Future init() async { - final dbPath = '/app/data/xtremflow.db'; - + const dbPath = '/app/data/xtremflow.db'; + // Ensure data directory exists final dir = Directory('/app/data'); if (!await dir.exists()) { @@ -21,7 +21,7 @@ class AppDatabase { } _db = sqlite3.open(dbPath); - + await _createTables(); print('Database initialized: $dbPath'); } @@ -79,9 +79,12 @@ class AppDatabase { '''); // Indexes - _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); - _db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); - _db.execute('CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); } /// Seed default admin user if no users exist @@ -93,10 +96,13 @@ class AppDatabase { final adminId = _uuid.v4(); final passwordHash = PasswordHasher.hash('admin'); - _db.execute(''' + _db.execute( + ''' INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, 1) - ''', [adminId, 'admin', passwordHash],); + ''', + [adminId, 'admin', passwordHash], + ); print('Default admin user created (username: admin, password: admin)'); } @@ -148,10 +154,13 @@ class AppDatabase { final userId = _uuid.v4(); final passwordHash = PasswordHasher.hash(password); - _db.execute(''' + _db.execute( + ''' INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?) - ''', [userId, username, passwordHash, isAdmin ? 1 : 0],); + ''', + [userId, username, passwordHash, isAdmin ? 1 : 0], + ); return User( id: userId, @@ -197,10 +206,13 @@ class AppDatabase { final token = _uuid.v4(); final expiresAt = DateTime.now().add(duration ?? const Duration(days: 7)); - _db.execute(''' + _db.execute( + ''' INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?) - ''', [sessionId, userId, token, expiresAt.toIso8601String()],); + ''', + [sessionId, userId, token, expiresAt.toIso8601String()], + ); return models.Session( id: sessionId, @@ -221,7 +233,7 @@ class AppDatabase { if (result.isEmpty) return null; final session = models.Session.fromMap(result.first); - + // Check if expired if (session.isExpired) { deleteSession(token); @@ -279,10 +291,13 @@ class AppDatabase { final playlistId = _uuid.v4(); final now = DateTime.now().toIso8601String(); - _db.execute(''' + _db.execute( + ''' INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', [playlistId, userId, name, serverUrl, username, password, dns, now, now],); + ''', + [playlistId, userId, name, serverUrl, username, password, dns, now, now], + ); return Playlist( id: playlistId, @@ -308,11 +323,14 @@ class AppDatabase { }) { final now = DateTime.now().toIso8601String(); - _db.execute(''' + _db.execute( + ''' UPDATE playlists SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? WHERE id = ? - ''', [name, serverUrl, username, password, dns, now, playlistId],); + ''', + [name, serverUrl, username, password, dns, now, playlistId], + ); return getPlaylistById(playlistId)!; } @@ -338,15 +356,18 @@ class AppDatabase { /// Update user settings void updateUserSettings(String userId, String settingsJson) { final now = DateTime.now().toIso8601String(); - + // UPSERT (Insert or Replace) - _db.execute(''' + _db.execute( + ''' INSERT INTO user_settings (user_id, settings_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at - ''', [userId, settingsJson, now]); + ''', + [userId, settingsJson, now], + ); } /// Close database connection diff --git a/bin/server.dart b/bin/server.dart index d4eb661..fb6012a 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -10,7 +10,7 @@ import 'package:args/args.dart'; import 'database/database.dart'; import 'models/user.dart'; import 'models/playlist.dart'; -import '../lib/core/models/playlist_config.dart'; +import 'package:xtremflow/core/models/playlist_config.dart'; import 'api/auth_handler.dart'; import 'api/users_handler.dart'; import 'api/playlists_handler.dart'; @@ -49,17 +49,17 @@ void main(List args) async { // Auth endpoints ..mount('/api/auth', authHandler.router) // Playlists endpoints - ..mount('/api/playlists', Pipeline() + ..mount('/api/playlists', const Pipeline() .addMiddleware(authMiddleware(db)) - .addHandler(playlistsHandler.router.call)) + .addHandler(playlistsHandler.router.call),) // Users endpoints - ..mount('/api/users', Pipeline() + ..mount('/api/users', const Pipeline() .addMiddleware(authMiddleware(db)) - .addHandler(usersHandler.router.call)) + .addHandler(usersHandler.router.call),) // Settings endpoints - ..mount('/api/settings', Pipeline() + ..mount('/api/settings', const Pipeline() .addMiddleware(authMiddleware(db)) - .addHandler(settingsHandler.router.call)); + .addHandler(settingsHandler.router.call),); // Initialize Cleanup Service final cleanupService = CleanupService(); @@ -70,7 +70,7 @@ void main(List args) async { cleanupService.start(); // Admin Routes (protected) - apiRouter.mount('/api/admin', Pipeline() + apiRouter.mount('/api/admin', const Pipeline() .addMiddleware(authMiddleware(db)) .addHandler((Request request) { final router = Router(); @@ -90,7 +90,7 @@ void main(List args) async { }); return router(request); - })); + }),); // Create static handler final baseStaticHandler = createStaticHandler( @@ -100,7 +100,7 @@ void main(List args) async { ); // Wrap static handler to enforce cache policies - Handler staticHandler = (Request request) async { + FutureOr staticHandler(Request request) async { final response = await baseStaticHandler(request); // Disable cache for entry points to ensure updates are seen immediately @@ -113,17 +113,17 @@ void main(List args) async { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', 'Expires': '0', - }); + },); } // Allow aggressive caching for hashed assets return response.change(headers: { 'Cache-Control': 'public, max-age=86400', - }); - }; + },); + } // Helper to get playlist from request - Future _getPlaylist(Request request) async { + Future getPlaylist(Request request) async { Playlist? playlist; final user = request.context['user'] as User?; @@ -155,8 +155,8 @@ void main(List args) async { // Create Streaming Router (Mount handlers on correct paths) final streamingRouter = Router() - ..mount('/api/live', createLiveStreamHandler(_getPlaylist)) - ..mount('/api/vod', createVodStreamHandler(_getPlaylist)); + ..mount('/api/live', createLiveStreamHandler(getPlaylist)) + ..mount('/api/vod', createVodStreamHandler(getPlaylist)); // Main handler with API proxy and NEW Streaming Handlers final handler = Cascade() @@ -167,7 +167,7 @@ void main(List args) async { .handler; // Add middleware - final pipeline = Pipeline() + final pipeline = const Pipeline() .addMiddleware(logRequests()) .addMiddleware(securityHeadersMiddleware()) .addMiddleware(honeypotMiddleware()) diff --git a/build_release.ps1 b/build_release.ps1 new file mode 100644 index 0000000..c07e0bb --- /dev/null +++ b/build_release.ps1 @@ -0,0 +1,45 @@ +$ErrorActionPreference = "Stop" + +Write-Host "🚧 Starting XtremFlow Build Process..." -ForegroundColor Cyan + +# 1. Clean previous builds +Write-Host "Cleaning previous builds..." +if (Test-Path "dist") { Remove-Item -Recurse -Force "dist" } +if (Test-Path "build") { Remove-Item -Recurse -Force "build" } +New-Item -ItemType Directory -Force "dist" + +# 2. Build Flutter Web +Write-Host "📦 Building Flutter Web Frontend..." -ForegroundColor Yellow +flutter build web --release --no-tree-shake-icons +if ($LASTEXITCODE -ne 0) { Write-Error "Flutter build failed"; exit 1 } + +# 3. Copy Web Assets to Dist +Write-Host "📂 Copying Web Assets..." +Copy-Item -Recurse "build\web" "dist\web" + +# 4. Compile Dart Server +Write-Host "🔨 Compiling Backend Server..." -ForegroundColor Yellow +dart compile exe bin/server.dart -o dist/xtremflow_server.exe +if ($LASTEXITCODE -ne 0) { Write-Error "Dart compile failed"; exit 1 } + +# 5. Create Start Script +Write-Host "📝 Creating Launcher Script..." +$startScript = @" +@echo off +echo Starting XtremFlow Server... +echo Open your browser at http://localhost:8089 +xtremflow_server.exe --path ./web --port 8089 +pause +"@ +Set-Content "dist\start.bat" $startScript + +# 6. Setup FFmpeg Placeholder +Write-Host "🎥 Setting up FFmpeg folder..." +New-Item -ItemType Directory -Force "dist\ffmpeg\bin" +Set-Content "dist\ffmpeg\README.txt" "Please download ffmpeg.exe and place it in the 'bin' folder here." + +# 7. Final Instructions +Write-Host "✅ Build Complete!" -ForegroundColor Green +Write-Host "Output directory: dist/" +Write-Host "IMPORTANT: You must put 'ffmpeg.exe' in dist/ffmpeg/bin/ (or next to the exe)" + diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 4d68518..34c3620 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -8,7 +8,7 @@ import '../models/playlist_config.dart'; class HiveService { static const String _usersBoxName = 'users'; static const String _playlistsBoxName = 'playlists'; - + static bool _initialized = false; static List? _encryptionKey; @@ -45,27 +45,27 @@ class HiveService { // Generate new 256-bit key _encryptionKey = Hive.generateSecureKey(); - + return _encryptionKey!; } /// Seed default admin user (admin/admin) static Future _seedDefaultAdmin() async { final usersBox = Hive.box(_usersBoxName); - + if (usersBox.isEmpty) { final adminId = const Uuid().v4(); final passwordHash = _hashPassword('admin'); - + final admin = AppUser( id: adminId, username: 'admin', passwordHash: passwordHash, isAdmin: true, - assignedPlaylistIds: [], + assignedPlaylistIds: const [], createdAt: DateTime.now(), ); - + await usersBox.put(adminId, admin); } } @@ -88,12 +88,12 @@ class HiveService { final legacyHash = sha256.convert(utf8.encode(password)).toString(); return legacyHash == storedHash; } - + final salt = parts[0]; final expectedHash = parts[1]; final bytes = utf8.encode(password + salt); final actualHash = sha256.convert(bytes).toString(); - + return actualHash == expectedHash; } catch (e) { return false; @@ -103,12 +103,11 @@ class HiveService { /// Public method to hash passwords (used by auth) static String hashPassword(String password) => _hashPassword(password); - /// Get users box static Box get usersBox => Hive.box(_usersBoxName); /// Get playlists box - static Box get playlistsBox => + static Box get playlistsBox => Hive.box(_playlistsBoxName); /// Close all boxes (cleanup) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index a5c44ba..46a1b7a 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; /// XtremFlow Apple TV Style Palette -/// +/// /// Ultra minimalist, focus-driven palette. /// - Background: Pure Black (#000000) /// - Focus/Active: Pure White (#FFFFFF) @@ -13,20 +13,20 @@ class AppColors { // ============ BACKGROUNDS ============ /// Pure black for that infinite depth look (OLED friendly) static const Color background = Color(0xFF000000); - + /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey) static const Color surface = Color(0xFF1C1C1E); - + /// Slightly lighter grey for secondary surfaces or hover states static const Color surfaceVariant = Color(0xFF3A3A3C); - + /// Focused element background (often white in tvOS for text, or bright accent) static const Color focusColor = Color(0xFFFFFFFF); // ============ ACCENTS ============ /// Minimal white accent. In Apple TV, color is used sparingly. /// We keep a subtle blue only for specific indicators if needed. - static const Color primary = Color(0xFFFFFFFF); + static const Color primary = Color(0xFFFFFFFF); static const Color accent = Color(0xFFE5E5E5); /// Primary Gradient (Subtle silver/white glow) @@ -49,7 +49,7 @@ class AppColors { // ============ BORDERS ============ // Borders are usually hidden until focused - static const Color border = Color(0xFF1F1F1F); + static const Color border = Color(0xFF1F1F1F); static const Color focusBorder = Color(0xFFFFFFFF); // ============ GLASSMORPHISM ============ @@ -59,28 +59,27 @@ class AppColors { // ============ THEME SCHEMES ============ static ColorScheme get darkColorScheme => const ColorScheme.dark( - primary: primary, - secondary: accent, - surface: surface, - background: background, - error: error, - onPrimary: Colors.black, // White text on buttons -> Black text on White buttons - onSecondary: Colors.black, - onSurface: textPrimary, - onError: Colors.white, - brightness: Brightness.dark, - ); + primary: primary, + secondary: accent, + surface: surface, + error: error, + onPrimary: Colors + .black, // White text on buttons -> Black text on White buttons + onSecondary: Colors.black, + onSurface: textPrimary, + onError: Colors.white, + brightness: Brightness.dark, + ); static ColorScheme get lightColorScheme => const ColorScheme.light( - primary: Colors.black, - secondary: Colors.grey, - surface: Color(0xFFF2F2F7), - background: Colors.white, - error: error, - onPrimary: Colors.white, - onSecondary: Colors.white, - onSurface: Colors.black, - onError: Colors.white, - brightness: Brightness.light, - ); + primary: Colors.black, + secondary: Colors.grey, + surface: Color(0xFFF2F2F7), + error: error, + onPrimary: Colors.white, + onSecondary: Colors.white, + onSurface: Colors.black, + onError: Colors.white, + brightness: Brightness.light, + ); } diff --git a/lib/core/widgets/glass_container.dart b/lib/core/widgets/glass_container.dart index 331568b..5714e78 100644 --- a/lib/core/widgets/glass_container.dart +++ b/lib/core/widgets/glass_container.dart @@ -1,7 +1,6 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; -import '../theme/app_theme.dart'; class GlassContainer extends StatelessWidget { final Widget child; diff --git a/lib/core/widgets/themed_loading_screen.dart b/lib/core/widgets/themed_loading_screen.dart index e792467..e1db1dd 100644 --- a/lib/core/widgets/themed_loading_screen.dart +++ b/lib/core/widgets/themed_loading_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../theme/app_colors.dart'; class ThemedLoading extends StatelessWidget { const ThemedLoading({super.key}); diff --git a/lib/features/admin/screens/admin_panel.dart b/lib/features/admin/screens/admin_panel.dart index 020ee8b..b493095 100644 --- a/lib/features/admin/screens/admin_panel.dart +++ b/lib/features/admin/screens/admin_panel.dart @@ -87,9 +87,9 @@ class _AdminContentState extends ConsumerState Text( 'Administration', style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), const Spacer(), // Custom Tab Bar Indicator @@ -111,12 +111,13 @@ class _AdminContentState extends ConsumerState ], ), ), - + // Content Expanded( child: TabBarView( controller: _tabController, - physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref + physics: + const NeverScrollableScrollPhysics(), // Custom tabs handle ref children: const [ _PlaylistsTab(), _UsersTab(), @@ -182,7 +183,11 @@ class _PlaylistsTab extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), + Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Colors.white70)), GradientButton( label: 'Add Playlist', icon: Icons.add, @@ -194,16 +199,21 @@ class _PlaylistsTab extends ConsumerWidget { Expanded( child: playlistsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), + error: (error, stack) => Center( + child: Text('Error: $error', + style: const TextStyle(color: AppColors.error))), data: (playlists) { if (playlists.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), + Icon(Icons.playlist_remove, + size: 64, + color: AppColors.textSecondary.withOpacity(0.5)), const SizedBox(height: 16), - Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium), + Text('No playlists configured', + style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), TextButton( onPressed: () => _showPlaylistDialog(context, ref), @@ -215,7 +225,8 @@ class _PlaylistsTab extends ConsumerWidget { } return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 8), itemCount: playlists.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { @@ -231,16 +242,25 @@ class _PlaylistsTab extends ConsumerWidget { color: AppColors.surface, borderRadius: BorderRadius.circular(12), ), - child: const Icon(Icons.playlist_play, color: AppColors.textPrimary), + child: const Icon(Icons.playlist_play, + color: AppColors.textPrimary), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(playlist.name, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + Text(playlist.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.white)), const SizedBox(height: 4), - Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), + Text(playlist.dns, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Colors.white60)), ], ), ), @@ -249,13 +269,15 @@ class _PlaylistsTab extends ConsumerWidget { IconButton( icon: const Icon(Icons.edit_outlined, size: 20), color: Colors.white70, - onPressed: () => _showPlaylistDialog(context, ref, playlist), + onPressed: () => + _showPlaylistDialog(context, ref, playlist), tooltip: 'Edit', ), IconButton( icon: const Icon(Icons.delete_outline, size: 20), color: AppColors.error, - onPressed: () => _deletePlaylist(context, ref, playlist), + onPressed: () => + _deletePlaylist(context, ref, playlist), tooltip: 'Delete', ), ], @@ -272,7 +294,8 @@ class _PlaylistsTab extends ConsumerWidget { ); } - void _showPlaylistDialog(BuildContext context, WidgetRef ref, [PlaylistConfig? playlist]) { + void _showPlaylistDialog(BuildContext context, WidgetRef ref, + [PlaylistConfig? playlist]) { final nameController = TextEditingController(text: playlist?.name); final dnsController = TextEditingController(text: playlist?.dns); final usernameController = TextEditingController(text: playlist?.username); @@ -284,40 +307,79 @@ class _PlaylistsTab extends ConsumerWidget { context: context, builder: (dialogContext) => StatefulBuilder( builder: (context, setState) => AlertDialog( - title: Text(playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist'), + title: Text(playlist == null + ? 'Ajouter une playlist' + : 'Modifier la playlist'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: nameController, decoration: const InputDecoration(labelText: 'Nom')), + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Nom')), const SizedBox(height: 12), - TextField(controller: dnsController, decoration: const InputDecoration(labelText: 'URL du serveur', hintText: 'http://server.com:8080')), + TextField( + controller: dnsController, + decoration: const InputDecoration( + labelText: 'URL du serveur', + hintText: 'http://server.com:8080')), const SizedBox(height: 12), - TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Utilisateur')), + TextField( + controller: usernameController, + decoration: + const InputDecoration(labelText: 'Utilisateur')), const SizedBox(height: 12), - TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true), + TextField( + controller: passwordController, + decoration: + const InputDecoration(labelText: 'Mot de passe'), + obscureText: true), ], ), ), actions: [ - TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: + isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( - onPressed: isLoading ? null : () async { - if (nameController.text.isEmpty || dnsController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); - return; - } - setState(() => isLoading = true); - final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); - if (playlist == null) { - await service.createPlaylist(name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); - } else { - await service.updatePlaylist(id: playlist.id, name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); - } - if (dialogContext.mounted) Navigator.pop(dialogContext); - ref.invalidate(playlistsProvider); - }, - child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sauvegarder'), + onPressed: isLoading + ? null + : () async { + if (nameController.text.isEmpty || + dnsController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tous les champs sont requis'))); + return; + } + setState(() => isLoading = true); + final dns = dnsController.text + .trim() + .replaceAll(RegExp(r'/$'), ''); + if (playlist == null) { + await service.createPlaylist( + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text); + } else { + await service.updatePlaylist( + id: playlist.id, + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text); + } + if (dialogContext.mounted) Navigator.pop(dialogContext); + ref.invalidate(playlistsProvider); + }, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Sauvegarder'), ), ], ), @@ -325,14 +387,17 @@ class _PlaylistsTab extends ConsumerWidget { ); } - void _deletePlaylist(BuildContext context, WidgetRef ref, PlaylistConfig playlist) { + void _deletePlaylist( + BuildContext context, WidgetRef ref, PlaylistConfig playlist) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Supprimer la playlist'), content: Text('Supprimer "${playlist.name}" ?'), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( onPressed: () async { Navigator.pop(dialogContext); @@ -363,7 +428,11 @@ class _UsersTab extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), + Text('${usersAsync.asData?.value.length ?? 0} Users', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Colors.white70)), GradientButton( label: 'Create User', icon: Icons.person_add, @@ -375,23 +444,29 @@ class _UsersTab extends ConsumerWidget { Expanded( child: usersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), + error: (error, stack) => Center( + child: Text('Error: $error', + style: const TextStyle(color: AppColors.error))), data: (users) { if (users.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), + Icon(Icons.people_outline, + size: 64, + color: AppColors.textSecondary.withOpacity(0.5)), const SizedBox(height: 16), - Text('No users found', style: Theme.of(context).textTheme.titleMedium), + Text('No users found', + style: Theme.of(context).textTheme.titleMedium), ], ), ); } return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 8), itemCount: users.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { @@ -404,12 +479,18 @@ class _UsersTab extends ConsumerWidget { width: 48, height: 48, decoration: BoxDecoration( - color: user.isAdmin ? AppColors.focusColor : AppColors.surface, + color: user.isAdmin + ? AppColors.focusColor + : AppColors.surface, shape: BoxShape.circle, ), child: Icon( - user.isAdmin ? Icons.admin_panel_settings : Icons.person, - color: user.isAdmin ? Colors.black : AppColors.textPrimary, + user.isAdmin + ? Icons.admin_panel_settings + : Icons.person, + color: user.isAdmin + ? Colors.black + : AppColors.textPrimary, size: 24, ), ), @@ -420,38 +501,72 @@ class _UsersTab extends ConsumerWidget { children: [ Row( children: [ - Text(user.username, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + Text(user.username, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.white)), if (user.isAdmin) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: AppColors.focusColor.withOpacity(0.2), + color: AppColors.focusColor + .withOpacity(0.2), borderRadius: BorderRadius.circular(50), ), - child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)), + child: const Text('ADMIN', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: AppColors.focusColor)), ), ], ], ), const SizedBox(height: 4), - Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), + Text('ID: ${user.id}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Colors.white60)), ], ), ), PopupMenuButton( - icon: const Icon(Icons.more_vert, color: Colors.white70), + icon: const Icon(Icons.more_vert, + color: Colors.white70), color: AppColors.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), onSelected: (value) { switch (value) { - case 'password': _showChangePasswordDialog(context, ref, user); break; - case 'delete': _deleteUser(context, ref, user); break; + case 'password': + _showChangePasswordDialog(context, ref, user); + break; + case 'delete': + _deleteUser(context, ref, user); + break; } }, itemBuilder: (context) => [ - const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])), - const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])), + const PopupMenuItem( + value: 'password', + child: Row(children: [ + Icon(Icons.lock_reset, size: 18), + SizedBox(width: 8), + Text('Change Password') + ])), + const PopupMenuItem( + value: 'delete', + child: Row(children: [ + Icon(Icons.delete, + color: AppColors.error, size: 18), + SizedBox(width: 8), + Text('Delete', + style: TextStyle(color: AppColors.error)) + ])), ], ), ], @@ -480,9 +595,18 @@ class _UsersTab extends ConsumerWidget { content: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Nom d\'utilisateur', prefixIcon: Icon(Icons.person_outline))), + TextField( + controller: usernameController, + decoration: const InputDecoration( + labelText: 'Nom d\'utilisateur', + prefixIcon: Icon(Icons.person_outline))), const SizedBox(height: 12), - TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), + TextField( + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Mot de passe', + prefixIcon: Icon(Icons.lock_outline)), + obscureText: true), const SizedBox(height: 16), SwitchListTile( title: const Text('Administrateur'), @@ -493,22 +617,40 @@ class _UsersTab extends ConsumerWidget { ], ), actions: [ - TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: + isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( - onPressed: isLoading ? null : () async { - if (usernameController.text.isEmpty || passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); - return; - } - setState(() => isLoading = true); - final result = await UserApiService().createUser(username: usernameController.text.trim(), password: passwordController.text, isAdmin: isAdmin); - if (dialogContext.mounted) Navigator.pop(dialogContext); - ref.invalidate(usersProvider); - if (!result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); - } - }, - child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Créer'), + onPressed: isLoading + ? null + : () async { + if (usernameController.text.isEmpty || + passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tous les champs sont requis'))); + return; + } + setState(() => isLoading = true); + final result = await UserApiService().createUser( + username: usernameController.text.trim(), + password: passwordController.text, + isAdmin: isAdmin); + if (dialogContext.mounted) Navigator.pop(dialogContext); + ref.invalidate(usersProvider); + if (!result.success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(result.error ?? 'Erreur'), + backgroundColor: AppColors.error)); + } + }, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Créer'), ), ], ), @@ -516,7 +658,8 @@ class _UsersTab extends ConsumerWidget { ); } - void _showChangePasswordDialog(BuildContext context, WidgetRef ref, AppUser user) { + void _showChangePasswordDialog( + BuildContext context, WidgetRef ref, AppUser user) { final passwordController = TextEditingController(); bool isLoading = false; @@ -525,25 +668,49 @@ class _UsersTab extends ConsumerWidget { builder: (dialogContext) => StatefulBuilder( builder: (context, setState) => AlertDialog( title: Text('Changer le mot de passe de ${user.username}'), - content: TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Nouveau mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), + content: TextField( + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Nouveau mot de passe', + prefixIcon: Icon(Icons.lock_outline)), + obscureText: true), actions: [ - TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: + isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( - onPressed: isLoading ? null : () async { - if (passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mot de passe requis'))); - return; - } - setState(() => isLoading = true); - final result = await UserApiService().updatePassword(userId: user.id, newPassword: passwordController.text); - if (dialogContext.mounted) Navigator.pop(dialogContext); - if (result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Mot de passe modifié'), backgroundColor: AppColors.success)); - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); - } - }, - child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Changer'), + onPressed: isLoading + ? null + : () async { + if (passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mot de passe requis'))); + return; + } + setState(() => isLoading = true); + final result = await UserApiService().updatePassword( + userId: user.id, + newPassword: passwordController.text); + if (dialogContext.mounted) Navigator.pop(dialogContext); + if (result.success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mot de passe modifié'), + backgroundColor: AppColors.success)); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(result.error ?? 'Erreur'), + backgroundColor: AppColors.error)); + } + }, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Changer'), ), ], ), @@ -556,16 +723,21 @@ class _UsersTab extends ConsumerWidget { context: context, builder: (dialogContext) => AlertDialog( title: const Text('Supprimer l\'utilisateur'), - content: Text('Supprimer "${user.username}" ? Cette action est irréversible.'), + content: Text( + 'Supprimer "${user.username}" ? Cette action est irréversible.'), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( onPressed: () async { Navigator.pop(dialogContext); final result = await UserApiService().deleteUser(user.id); ref.invalidate(usersProvider); if (!result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(result.error ?? 'Erreur'), + backgroundColor: AppColors.error)); } }, style: FilledButton.styleFrom(backgroundColor: AppColors.error), diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index 9ec4371..f0f7b0e 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../providers/auth_provider.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; @@ -66,7 +65,7 @@ class _LoginScreenState extends ConsumerState { ), ), ), - + // 2. Ambient Glows (Apple TV Style) Positioned( top: -size.height * 0.2, @@ -206,7 +205,7 @@ class _LoginScreenState extends ConsumerState { }, ), ), - + // Error Message if (authState.errorMessage != null) ...[ const SizedBox(height: 24), @@ -261,7 +260,9 @@ class _LoginScreenState extends ConsumerState { ? const SizedBox( width: 24, height: 24, - child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.black), + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.black), ) : Text( 'Sign In', @@ -277,7 +278,7 @@ class _LoginScreenState extends ConsumerState { ), ), ), - + const SizedBox(height: 32), Text( 'Default: admin / admin', @@ -332,7 +333,8 @@ class _LoginScreenState extends ConsumerState { borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: AppColors.focusColor, width: 2), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + contentPadding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 20), ), validator: (value) { if (value == null || value.trim().isEmpty) { diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 7e1944f..4480dd3 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -31,7 +31,7 @@ class _DashboardScreenState extends ConsumerState { Icons.live_tv_rounded, Icons.movie_rounded, Icons.tv_rounded, - Icons.settings_rounded + Icons.settings_rounded, ]; @override diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 43189ab..99133c3 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -58,7 +58,7 @@ class _PlayerScreenState extends ConsumerState { bool _isInitialized = false; bool _isLoading = true; StreamSubscription? _messageSubscription; - String _aspectRatio = 'contain'; + final String _aspectRatio = 'contain'; bool _isSeeking = false; String _viewId = 'iptv-player'; String? _currentStreamUrl; @@ -70,7 +70,8 @@ class _PlayerScreenState extends ConsumerState { void initState() { super.initState(); if (widget.channels != null) { - _currentIndex = widget.channels!.indexWhere((c) => c.streamId == widget.streamId); + _currentIndex = + widget.channels!.indexWhere((c) => c.streamId == widget.streamId); if (_currentIndex == -1) _currentIndex = 0; } // Show controls at start, then auto-hide after timeout @@ -87,7 +88,8 @@ class _PlayerScreenState extends ConsumerState { super.dispose(); } - Future _initializePlayer({double? startTimeOverride, bool isChannelSwitch = false}) async { + Future _initializePlayer( + {double? startTimeOverride, bool isChannelSwitch = false}) async { setState(() { _isLoading = true; _statusMessage = 'Loading...'; @@ -96,12 +98,12 @@ class _PlayerScreenState extends ConsumerState { try { // Determine Stream ID (Initial or Current Index) final currentStreamId = isChannelSwitch && widget.channels != null - ? widget.channels![_currentIndex].streamId + ? widget.channels![_currentIndex].streamId : widget.streamId; - _viewId = 'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}'; - - + _viewId = + 'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}'; + final service = ref.read(xtreamServiceProvider(widget.playlist)); String streamUrl = ''; @@ -110,34 +112,37 @@ class _PlayerScreenState extends ConsumerState { if (widget.streamType == StreamType.live) { streamUrl = service.getLiveStreamUrl(currentStreamId); } else if (widget.streamType == StreamType.vod) { - streamUrl = service.getVodStreamUrl(currentStreamId, widget.containerExtension); + streamUrl = + service.getVodStreamUrl(currentStreamId, widget.containerExtension); } else if (widget.streamType == StreamType.series) { - streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension); + streamUrl = service.getSeriesStreamUrl( + currentStreamId, widget.containerExtension); } // Store URL for Lite Player _currentStreamUrl = streamUrl; final encodedUrl = Uri.encodeComponent(streamUrl); - final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; - + final streamTypeParam = + widget.streamType == StreamType.live ? 'live' : 'vod'; + // Force player choice based on stream type: // - Live TV: Player Lite (simple TS playback with mpegts.js) // - VOD/Series: Player Standard (fuller controls, HLS support) final isLiveTV = widget.streamType == StreamType.live; // Cache buster to force reload of updated player.html final cacheBuster = DateTime.now().millisecondsSinceEpoch; - - var playerSrc = isLiveTV - ? 'player_lite.html?url=$encodedUrl&type=live&v=$cacheBuster' + + var playerSrc = isLiveTV + ? 'player_lite.html?url=$encodedUrl&type=live&v=$cacheBuster' : 'player.html?url=$encodedUrl&type=vod&v=$cacheBuster'; - + if (startTimeOverride != null) { playerSrc += '&t=$startTimeOverride'; } - + if (widget.duration != null) { - playerSrc += '&duration=${widget.duration!.inSeconds}'; + playerSrc += '&duration=${widget.duration!.inSeconds}'; } // Register View Factory (Only needed for Standard Player, but safe to do always or conditionally) @@ -157,7 +162,7 @@ class _PlayerScreenState extends ConsumerState { _isInitialized = true; _isLoading = false; }); - + // Force unmute on load (after a short delay to ensure iframe is ready) Future.delayed(const Duration(milliseconds: 500), () { if (mounted) { @@ -185,24 +190,23 @@ class _PlayerScreenState extends ConsumerState { final currentTime = (data['currentTime'] as num).toDouble(); final rawDuration = (data['duration'] as num).toDouble(); final duration = rawDuration.isFinite ? rawDuration : 0.0; - + // Only update position if user is NOT dragging the slider if (!_isSeeking) { - setState(() { - _currentPosition = currentTime; - _totalDuration = duration > 0 ? duration : 1; - }); - } - - // Update watch history if relevant - if (currentTime > 0) { - ref.read(playbackPositionsProvider.notifier).savePosition( - widget.streamId, - currentTime, - duration - ); + setState(() { + _currentPosition = currentTime; + _totalDuration = duration > 0 ? duration : 1; + }); } + // Update watch history if relevant + if (currentTime > 0) { + ref.read(playbackPositionsProvider.notifier).savePosition( + widget.streamId, + currentTime, + duration, + ); + } } else if (type == 'playback_status') { setState(() => _isPlaying = data['status'] == 'playing'); } else if (type == 'user_activity') { @@ -213,7 +217,8 @@ class _PlayerScreenState extends ConsumerState { void _sendMessage(Map message) { if (_isInitialized) { - final iframe = html.document.getElementById(_viewId) as html.IFrameElement?; + final iframe = + html.document.getElementById(_viewId) as html.IFrameElement?; iframe?.contentWindow?.postMessage(message, '*'); } } @@ -272,7 +277,7 @@ class _PlayerScreenState extends ConsumerState { _initializePlayer(isChannelSwitch: true); } } - + String _formatDuration(Duration d) { String twoDigits(int n) => n.toString().padLeft(2, '0'); final hours = twoDigits(d.inHours); @@ -306,8 +311,7 @@ class _PlayerScreenState extends ConsumerState { fit: StackFit.expand, children: [ // Video Player (iframe with native controls) - if (_isInitialized) - HtmlElementView(viewType: _viewId), + if (_isInitialized) HtmlElementView(viewType: _viewId), // Top Bar: Back Button + Title (with auto-hide) AnimatedPositioned( @@ -326,21 +330,24 @@ class _PlayerScreenState extends ConsumerState { customBorder: const CircleBorder(), child: const Padding( padding: EdgeInsets.all(12), - child: Icon(Icons.arrow_back_rounded, color: Colors.white), + child: Icon(Icons.arrow_back_rounded, + color: Colors.white), ), ), ), const SizedBox(width: 16), Expanded( child: Text( - widget.channels != null - ? widget.channels![_currentIndex].name + widget.channels != null + ? widget.channels![_currentIndex].name : widget.title, style: const TextStyle( - color: Colors.white, - fontSize: 18, + color: Colors.white, + fontSize: 18, fontWeight: FontWeight.bold, - shadows: [Shadow(blurRadius: 4, color: Colors.black)], + shadows: [ + Shadow(blurRadius: 4, color: Colors.black) + ], ), overflow: TextOverflow.ellipsis, ), @@ -355,8 +362,10 @@ class _PlayerScreenState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(12), child: Icon( - _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, - color: Colors.white + _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + color: Colors.white, ), ), ), @@ -385,7 +394,8 @@ class _PlayerScreenState extends ConsumerState { customBorder: const CircleBorder(), child: const Padding( padding: EdgeInsets.all(14), - child: Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 28), + child: Icon(Icons.keyboard_arrow_up, + color: Colors.white, size: 28), ), ), ), @@ -399,7 +409,8 @@ class _PlayerScreenState extends ConsumerState { customBorder: const CircleBorder(), child: const Padding( padding: EdgeInsets.all(14), - child: Icon(Icons.keyboard_arrow_down, color: Colors.white, size: 28), + child: Icon(Icons.keyboard_arrow_down, + color: Colors.white, size: 28), ), ), ), @@ -418,8 +429,8 @@ class _PlayerScreenState extends ConsumerState { child: PointerInterceptor( child: EpgOverlay( playlist: widget.playlist, - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId + streamId: widget.channels != null + ? widget.channels![_currentIndex].streamId : widget.streamId, ), ), @@ -438,8 +449,7 @@ class _PlayerScreenState extends ConsumerState { fit: StackFit.expand, children: [ // 1. Video Player Layer - if (_isInitialized) - HtmlElementView(viewType: _viewId), + if (_isInitialized) HtmlElementView(viewType: _viewId), // 2. Interaction Layer Positioned.fill( @@ -469,184 +479,225 @@ class _PlayerScreenState extends ConsumerState { color: Colors.black.withOpacity(0.3), // Dim background child: Stack( children: [ - // Top Bar (Back + Title) - Positioned( - top: 24, - left: 24, - right: 24, - child: Row( - children: [ - TvFocusableCard( - onTap: () => Navigator.pop(context), - borderRadius: 50, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: const Icon(Icons.arrow_back_rounded, color: Colors.white), - ), - ), - const SizedBox(width: 24), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.channels != null - ? widget.channels![_currentIndex].name - : widget.title, - style: GoogleFonts.inter( - fontSize: 24, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - if (widget.streamType == StreamType.live && widget.channels != null) - Text( - 'Live TV', - style: GoogleFonts.inter(fontSize: 14, color: AppColors.live, fontWeight: FontWeight.bold) - ), - ], - ), - ], - ), - ), + // Top Bar (Back + Title) + Positioned( + top: 24, + left: 24, + right: 24, + child: Row( + children: [ + TvFocusableCard( + onTap: () => Navigator.pop(context), + borderRadius: 50, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back_rounded, + color: Colors.white), + ), + ), + const SizedBox(width: 24), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.channels != null + ? widget.channels![_currentIndex].name + : widget.title, + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + if (widget.streamType == StreamType.live && + widget.channels != null) + Text( + 'Live TV', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.live, + fontWeight: FontWeight.bold), + ), + ], + ), + ], + ), + ), - // Center Play/Pause (Animated) - Center( - child: _isLoading - ? const CircularProgressIndicator(color: Colors.white) - : TvFocusableCard( - onTap: _togglePlayPause, - borderRadius: 100, - scaleFactor: 1.2, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - border: Border.all(color: Colors.white.withOpacity(0.3)), - ), - child: Icon( - _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, - color: Colors.white, - size: 48, - ), - ), - ), - ), + // Center Play/Pause (Animated) + Center( + child: _isLoading + ? const CircularProgressIndicator( + color: Colors.white) + : TvFocusableCard( + onTap: _togglePlayPause, + borderRadius: 100, + scaleFactor: 1.2, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.3)), + ), + child: Icon( + _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: Colors.white, + size: 48, + ), + ), + ), + ), - // EPG Overlay - if (widget.streamType == StreamType.live) - Positioned( - bottom: 140, - left: 40, - child: EpgOverlay( - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId - : widget.streamId, - playlist: widget.playlist, - ), - ), + // EPG Overlay + if (widget.streamType == StreamType.live) + Positioned( + bottom: 140, + left: 40, + child: EpgOverlay( + streamId: widget.channels != null + ? widget.channels![_currentIndex].streamId + : widget.streamId, + playlist: widget.playlist, + ), + ), - // Bottom Control Bar - Positioned( - bottom: 40, - left: 40, - right: 40, - child: GlassContainer( - borderRadius: 24, - opacity: 0.8, - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Progress Bar (if not live) - if (widget.streamType != StreamType.live) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - children: [ - Text(_formatDuration(Duration(seconds: _currentPosition.toInt())), style: const TextStyle(color: Colors.white70)), - const SizedBox(width: 16), - Expanded( - child: SliderTheme( - data: SliderThemeData( - trackHeight: 4, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), - activeTrackColor: AppColors.primary, - inactiveTrackColor: Colors.white24, - thumbColor: Colors.white, - ), - child: Slider( - value: _currentPosition, - min: 0, - max: _totalDuration, - onChangeStart: (value) { - setState(() => _isSeeking = true); - }, - onChanged: (value) { - // Update UI immediately (optimistic update) - setState(() => _currentPosition = value); - }, - onChangeEnd: (value) { - _sendMessage({'type': 'seek', 'value': value}); - // Small delay to prevent jitter from incoming messages - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) setState(() => _isSeeking = false); - }); - }, - ), - ), - ), - const SizedBox(width: 16), - Text(_formatDuration(Duration(seconds: _totalDuration.toInt())), style: const TextStyle(color: Colors.white70)), - ], - ), - ), - - // Controls Row - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Channel Zapping for Live TV - if (widget.streamType == StreamType.live && widget.channels != null) ...[ - _buildTvControl(Icons.navigate_before_rounded, _previousChannel), - const SizedBox(width: 32), - _buildTvControl(Icons.navigate_next_rounded, _nextChannel), - ], - - // Controls for VOD (Keep Skip buttons, they act as Prev/Next track if playlist or maybe seek) - // The user said: "only for live tv, other buttons skip/prev must allow changing channel" - // This implies for VOD they might want seek? But existing buttons were skip_previous/skip_next. - // We keep them for VOD if channels list is present, or just hide them if not needed. - // For now, I will restore them for VOD as well if channels > 0 (playlist mode) - if (widget.streamType != StreamType.live && widget.channels != null && widget.channels!.isNotEmpty) ...[ - _buildTvControl(Icons.skip_previous_rounded, _previousChannel), - const SizedBox(width: 32), - _buildTvControl(Icons.skip_next_rounded, _nextChannel), - ], - - const Spacer(), - // Volume/Mute button - _buildTvControl( - _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, - _toggleMute - ), - const SizedBox(width: 16), - _buildTvControl(Icons.subtitles_rounded, () {}), - const SizedBox(width: 16), - _buildTvControl(Icons.aspect_ratio_rounded, _toggleFullscreen), - ], - ), - ], - ), - ), - ), + // Bottom Control Bar + Positioned( + bottom: 40, + left: 40, + right: 40, + child: GlassContainer( + borderRadius: 24, + opacity: 0.8, + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Progress Bar (if not live) + if (widget.streamType != StreamType.live) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Text( + _formatDuration(Duration( + seconds: + _currentPosition.toInt())), + style: const TextStyle( + color: Colors.white70)), + const SizedBox(width: 16), + Expanded( + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 4, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 8), + overlayShape: + RoundSliderOverlayShape( + overlayRadius: 16), + activeTrackColor: AppColors.primary, + inactiveTrackColor: Colors.white24, + thumbColor: Colors.white, + ), + child: Slider( + value: _currentPosition, + min: 0, + max: _totalDuration, + onChangeStart: (value) { + setState(() => _isSeeking = true); + }, + onChanged: (value) { + // Update UI immediately (optimistic update) + setState(() => + _currentPosition = value); + }, + onChangeEnd: (value) { + _sendMessage({ + 'type': 'seek', + 'value': value + }); + // Small delay to prevent jitter from incoming messages + Future.delayed( + const Duration( + milliseconds: 500), () { + if (mounted) + setState( + () => _isSeeking = false); + }); + }, + ), + ), + ), + const SizedBox(width: 16), + Text( + _formatDuration(Duration( + seconds: _totalDuration.toInt())), + style: const TextStyle( + color: Colors.white70)), + ], + ), + ), + + // Controls Row + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Channel Zapping for Live TV + if (widget.streamType == StreamType.live && + widget.channels != null) ...[ + _buildTvControl( + Icons.navigate_before_rounded, + _previousChannel), + const SizedBox(width: 32), + _buildTvControl(Icons.navigate_next_rounded, + _nextChannel), + ], + + // Controls for VOD (Keep Skip buttons, they act as Prev/Next track if playlist or maybe seek) + // The user said: "only for live tv, other buttons skip/prev must allow changing channel" + // This implies for VOD they might want seek? But existing buttons were skip_previous/skip_next. + // We keep them for VOD if channels list is present, or just hide them if not needed. + // For now, I will restore them for VOD as well if channels > 0 (playlist mode) + if (widget.streamType != StreamType.live && + widget.channels != null && + widget.channels!.isNotEmpty) ...[ + _buildTvControl(Icons.skip_previous_rounded, + _previousChannel), + const SizedBox(width: 32), + _buildTvControl( + Icons.skip_next_rounded, _nextChannel), + ], + + const Spacer(), + // Volume/Mute button + _buildTvControl( + _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + _toggleMute, + ), + const SizedBox(width: 16), + _buildTvControl( + Icons.subtitles_rounded, () {}), + const SizedBox(width: 16), + _buildTvControl(Icons.aspect_ratio_rounded, + _toggleFullscreen), + ], + ), + ], + ), + ), + ), ], ), ), @@ -656,7 +707,7 @@ class _PlayerScreenState extends ConsumerState { ), ); } - + Widget _buildTvControl(IconData icon, VoidCallback onTap) { return TvFocusableCard( onTap: onTap, diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 4024526..8d325eb 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -26,14 +26,16 @@ class PlaylistSelectionScreen extends ConsumerWidget { appBar: AppBar( title: Text( 'Select Playlist', - style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), + style: GoogleFonts.roboto( + fontWeight: FontWeight.w600, color: Colors.white), ), backgroundColor: Colors.transparent, elevation: 0, actions: [ if (currentUser?.isAdmin ?? false) IconButton( - icon: const Icon(Icons.admin_panel_settings, color: Colors.white70), + icon: + const Icon(Icons.admin_panel_settings, color: Colors.white70), onPressed: () => context.go('/admin'), tooltip: 'Admin Panel', ), @@ -69,7 +71,9 @@ class PlaylistSelectionScreen extends ConsumerWidget { children: [ Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), const SizedBox(height: 16), - Text('Error loading playlists', style: GoogleFonts.roboto(fontSize: 18, color: Colors.white70)), + Text('Error loading playlists', + style: GoogleFonts.roboto( + fontSize: 18, color: Colors.white70)), const SizedBox(height: 8), ElevatedButton( onPressed: () => ref.refresh(playlistsProvider), @@ -84,7 +88,7 @@ class PlaylistSelectionScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.playlist_remove, size: 64, color: Colors.white24, @@ -113,7 +117,8 @@ class PlaylistSelectionScreen extends ConsumerWidget { } return GridView.builder( - padding: const EdgeInsets.fromLTRB(24, 100, 24, 24), // Top padding for transparent AppBar + padding: const EdgeInsets.fromLTRB( + 24, 100, 24, 24), // Top padding for transparent AppBar gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 24, @@ -165,23 +170,25 @@ class _PlaylistCardState extends State<_PlaylistCard> { child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( - color: _isHovered - ? Colors.white.withOpacity(0.15) + color: _isHovered + ? Colors.white.withOpacity(0.15) : Colors.white.withOpacity(0.05), // Glass effect borderRadius: BorderRadius.circular(16), border: Border.all( - color: _isHovered - ? Colors.white.withOpacity(0.5) + color: _isHovered + ? Colors.white.withOpacity(0.5) : Colors.white.withOpacity(0.1), width: 1.5, ), - boxShadow: _isHovered ? [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ] : [], + boxShadow: _isHovered + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ] + : [], ), padding: const EdgeInsets.all(24), child: Column( @@ -204,7 +211,8 @@ class _PlaylistCardState extends State<_PlaylistCard> { const SizedBox(height: 16), Text( widget.playlist.name, - style: GoogleFonts.outfit( // Using Outfit for modern look + style: GoogleFonts.outfit( + // Using Outfit for modern look fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, @@ -229,4 +237,3 @@ class _PlaylistCardState extends State<_PlaylistCard> { ); } } - diff --git a/lib/features/iptv/widgets/category_carousel.dart b/lib/features/iptv/widgets/category_carousel.dart index feec124..fd159c2 100644 --- a/lib/features/iptv/widgets/category_carousel.dart +++ b/lib/features/iptv/widgets/category_carousel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; /// Horizontal carousel for displaying channels by category -/// +/// /// Netflix-style horizontal scrolling list with: /// - Category title with count /// - Smooth horizontal scrolling @@ -36,7 +36,7 @@ class CategoryCarousel extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - + if (items.isEmpty) { return const SizedBox.shrink(); } @@ -110,15 +110,15 @@ class CategoryCarousel extends StatelessWidget { ], ), ), - + const SizedBox(height: 12), - + // Horizontal scrolling list with fade edges SizedBox( height: itemHeight, child: ShaderMask( shaderCallback: (Rect bounds) { - return LinearGradient( + return const LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ @@ -127,7 +127,7 @@ class CategoryCarousel extends StatelessWidget { Colors.white, Colors.transparent, ], - stops: const [0.0, 0.02, 0.98, 1.0], + stops: [0.0, 0.02, 0.98, 1.0], ).createShader(bounds); }, blendMode: BlendMode.dstIn, @@ -173,7 +173,7 @@ class CategoryChipButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - + return GestureDetector( onTap: onTap, child: AnimatedContainer( @@ -181,14 +181,10 @@ class CategoryChipButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( gradient: isSelected ? AppColors.primaryGradient : null, - color: isSelected - ? null - : colorScheme.surfaceContainerHighest, + color: isSelected ? null : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), border: Border.all( - color: isSelected - ? Colors.transparent - : colorScheme.outlineVariant, + color: isSelected ? Colors.transparent : colorScheme.outlineVariant, ), ), child: Row( @@ -209,9 +205,8 @@ class CategoryChipButton extends StatelessWidget { style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, - color: isSelected - ? colorScheme.onPrimary - : colorScheme.onSurface, + color: + isSelected ? colorScheme.onPrimary : colorScheme.onSurface, ), ), ], diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart index a4a6ba1..2f0b15f 100644 --- a/lib/features/iptv/widgets/epg_overlay.dart +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/playlist_config.dart'; diff --git a/lib/features/iptv/widgets/lite_player_view.dart b/lib/features/iptv/widgets/lite_player_view.dart index 8b89f9f..afdface 100644 --- a/lib/features/iptv/widgets/lite_player_view.dart +++ b/lib/features/iptv/widgets/lite_player_view.dart @@ -122,7 +122,7 @@ class _LitePlayerViewState extends ConsumerState { _initializePlayer(); }, child: const Text('Retry'), - ) + ), ], ), ); diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index f21a986..f86eb25 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -27,9 +27,8 @@ class LiveTVTab extends ConsumerStatefulWidget { class _LiveTVTabState extends ConsumerState with AutomaticKeepAliveClientMixin { - String? _selectedCategory; - bool _isGridView = true; + final bool _isGridView = true; final TextEditingController _searchController = TextEditingController(); bool _showFavoritesOnly = false; String _searchQuery = ''; @@ -57,7 +56,8 @@ class _LiveTVTabState extends ConsumerState Widget build(BuildContext context) { super.build(context); - final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); + final channelsAsync = + ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final favorites = ref.watch(favoritesProvider); final settings = ref.watch(iptvSettingsProvider); @@ -65,30 +65,36 @@ class _LiveTVTabState extends ConsumerState backgroundColor: Colors.transparent, body: channelsAsync.when( loading: () => const ThemedLoading(), - error: (e, s) => Center(child: Text('Error: $e', style: const TextStyle(color: Colors.white))), + error: (e, s) => Center( + child: + Text('Error: $e', style: const TextStyle(color: Colors.white))), data: (groupedChannels) { - var categories = groupedChannels.keys.toList(); - if (settings.liveTvKeywords.isNotEmpty) { - categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); - } - categories.sort(); - - List displayedChannels = []; - bool showingCategoryGrid = false; - - if (_searchQuery.isNotEmpty) { - displayedChannels = groupedChannels.values.expand((l) => l) - .where((c) => c.name.toLowerCase().contains(_searchQuery)) - .toList(); - } else if (_showFavoritesOnly) { - displayedChannels = groupedChannels.values.expand((l) => l) - .where((c) => favorites.contains(c.streamId)) - .toList(); - } else if (_selectedCategory != null) { - displayedChannels = groupedChannels[_selectedCategory] ?? []; - } else { - showingCategoryGrid = true; - } + var categories = groupedChannels.keys.toList(); + if (settings.liveTvKeywords.isNotEmpty) { + categories = categories + .where((cat) => settings.matchesLiveTvFilter(cat)) + .toList(); + } + categories.sort(); + + List displayedChannels = []; + bool showingCategoryGrid = false; + + if (_searchQuery.isNotEmpty) { + displayedChannels = groupedChannels.values + .expand((l) => l) + .where((c) => c.name.toLowerCase().contains(_searchQuery)) + .toList(); + } else if (_showFavoritesOnly) { + displayedChannels = groupedChannels.values + .expand((l) => l) + .where((c) => favorites.contains(c.streamId)) + .toList(); + } else if (_selectedCategory != null) { + displayedChannels = groupedChannels[_selectedCategory] ?? []; + } else { + showingCategoryGrid = true; + } return Column( children: [ @@ -98,16 +104,19 @@ class _LiveTVTabState extends ConsumerState child: GlassContainer( borderRadius: 16, opacity: 0.6, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( children: [ - if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[ + if (_selectedCategory != null && + _searchQuery.isEmpty && + !_showFavoritesOnly) ...[ TvFocusableCard( onTap: () { - setState(() { - _selectedCategory = null; - _mainScrollController.jumpTo(0); - }); + setState(() { + _selectedCategory = null; + _mainScrollController.jumpTo(0); + }); }, borderRadius: 50, child: Container( @@ -116,7 +125,8 @@ class _LiveTVTabState extends ConsumerState color: Colors.white.withOpacity(0.1), shape: BoxShape.circle, ), - child: const Icon(Icons.arrow_back, color: Colors.white), + child: const Icon(Icons.arrow_back, + color: Colors.white), ), ), const SizedBox(width: 16), @@ -124,9 +134,11 @@ class _LiveTVTabState extends ConsumerState Expanded( child: Text( - _searchQuery.isNotEmpty ? 'Search Results' : - _showFavoritesOnly ? 'Favorites' : - _selectedCategory ?? 'Live TV', + _searchQuery.isNotEmpty + ? 'Search Results' + : _showFavoritesOnly + ? 'Favorites' + : _selectedCategory ?? 'Live TV', style: GoogleFonts.inter( fontSize: 24, fontWeight: FontWeight.w700, @@ -136,7 +148,7 @@ class _LiveTVTabState extends ConsumerState overflow: TextOverflow.ellipsis, ), ), - + // Search Bar Container( width: 250, @@ -151,37 +163,50 @@ class _LiveTVTabState extends ConsumerState decoration: InputDecoration( hintText: 'Search channels...', hintStyle: GoogleFonts.inter(color: Colors.white54), - prefixIcon: const Icon(Icons.search, color: Colors.white54), + prefixIcon: + const Icon(Icons.search, color: Colors.white54), border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton(icon: const Icon(Icons.clear, color: Colors.white), onPressed: _searchController.clear) - : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, + color: Colors.white), + onPressed: _searchController.clear) + : null, ), ), ), const SizedBox(width: 12), - + // Favorites Toggle TvFocusableCard( onTap: () { - setState(() { - _showFavoritesOnly = !_showFavoritesOnly; - if (_showFavoritesOnly) _searchController.clear(); - _selectedCategory = null; - }); + setState(() { + _showFavoritesOnly = !_showFavoritesOnly; + if (_showFavoritesOnly) _searchController.clear(); + _selectedCategory = null; + }); }, borderRadius: 12, child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: _showFavoritesOnly ? AppColors.live.withOpacity(0.2) : Colors.white.withOpacity(0.1), + color: _showFavoritesOnly + ? AppColors.live.withOpacity(0.2) + : Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(12), - border: _showFavoritesOnly ? Border.all(color: AppColors.live) : null, + border: _showFavoritesOnly + ? Border.all(color: AppColors.live) + : null, ), child: Icon( - _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, - color: _showFavoritesOnly ? AppColors.live : Colors.white, + _showFavoritesOnly + ? Icons.favorite + : Icons.favorite_border, + color: _showFavoritesOnly + ? AppColors.live + : Colors.white, ), ), ), @@ -199,11 +224,14 @@ class _LiveTVTabState extends ConsumerState child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.tv_off, size: 64, color: Colors.white.withOpacity(0.3)), + Icon(Icons.tv_off, + size: 64, + color: Colors.white.withOpacity(0.3)), const SizedBox(height: 16), Text( 'No channels found', - style: GoogleFonts.inter(color: Colors.white54, fontSize: 18), + style: GoogleFonts.inter( + color: Colors.white54, fontSize: 18), ), ], ), @@ -218,7 +246,8 @@ class _LiveTVTabState extends ConsumerState ); } - Widget _buildCategoryGrid(List categories, Map> groupedChannels) { + Widget _buildCategoryGrid( + List categories, Map> groupedChannels) { final columns = ResponsiveLayout.value( context, mobile: 2, @@ -239,7 +268,7 @@ class _LiveTVTabState extends ConsumerState itemBuilder: (context, index) { final category = categories[index]; final count = groupedChannels[category]?.length ?? 0; - + return TvFocusableCard( onTap: () { setState(() { @@ -271,7 +300,8 @@ class _LiveTVTabState extends ConsumerState child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.folder_copy_outlined, color: Colors.white.withOpacity(0.9), size: 32), + Icon(Icons.folder_copy_outlined, + color: Colors.white.withOpacity(0.9), size: 32), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -290,7 +320,8 @@ class _LiveTVTabState extends ConsumerState const SizedBox(height: 4), Text( '$count Channels', - style: GoogleFonts.inter(fontSize: 13, color: Colors.white54), + style: GoogleFonts.inter( + fontSize: 13, color: Colors.white54), ), ], ), @@ -337,74 +368,97 @@ class _LiveTVTabState extends ConsumerState // Icon Area Expanded( child: Container( - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + borderRadius: + BorderRadius.vertical(top: Radius.circular(12)), ), padding: const EdgeInsets.all(16), child: Center( - child: channel.streamIcon.isNotEmpty - ? Image.network( - _getProxiedIconUrl(channel.streamIcon)!, - errorBuilder: (_,__,___) => const Icon(Icons.tv, color: Colors.grey, size: 40), - ) - : Text( - channel.name.characters.first.toUpperCase(), - style: GoogleFonts.inter(fontSize: 40, fontWeight: FontWeight.bold, color: Colors.black12), - ), + child: channel.streamIcon.isNotEmpty + ? Image.network( + _getProxiedIconUrl(channel.streamIcon)!, + errorBuilder: (_, __, ___) => const Icon(Icons.tv, + color: Colors.grey, size: 40), + ) + : Text( + channel.name.characters.first.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 40, + fontWeight: FontWeight.bold, + color: Colors.black12), + ), ), ), ), // Footer Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), color: Colors.black54, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( channel.name, - style: GoogleFonts.inter(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500), + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Consumer( builder: (context, ref, _) { - final epgAsync = ref.watch(epgByPlaylistProvider( - EpgRequestKey(playlist: widget.playlist, streamId: channel.streamId) - )); - - return epgAsync.when( - data: (epgList) { - if (epgList.isEmpty) { - return Text('No Info', style: GoogleFonts.inter(color: Colors.white54, fontSize: 10)); - } - final now = DateTime.now(); - final currentProgram = epgList.firstWhere( - (entry) { - try { - final start = DateTime.parse(entry.start); - final end = DateTime.parse(entry.end); - return now.isAfter(start) && now.isBefore(end); - } catch (e) { - return false; - } - }, - orElse: () => epgList.first, - ); + final epgAsync = ref.watch( + epgByPlaylistProvider( + EpgRequestKey( + playlist: widget.playlist, + streamId: channel.streamId), + ), + ); - return Text( - currentProgram.title, - style: GoogleFonts.inter(color: const Color(0xFFFFD700), fontSize: 10, fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, - loading: () => Text('...', style: GoogleFonts.inter(color: Colors.white24, fontSize: 10)), - error: (_,__) => Text('Err', style: GoogleFonts.inter(color: Colors.red, fontSize: 10)), - ); - } + return epgAsync.when( + data: (epgList) { + if (epgList.isEmpty) { + return Text('No Info', + style: GoogleFonts.inter( + color: Colors.white54, fontSize: 10)); + } + final now = DateTime.now(); + final currentProgram = epgList.firstWhere( + (entry) { + try { + final start = DateTime.parse(entry.start); + final end = DateTime.parse(entry.end); + return now.isAfter(start) && + now.isBefore(end); + } catch (e) { + return false; + } + }, + orElse: () => epgList.first, + ); + + return Text( + currentProgram.title, + style: GoogleFonts.inter( + color: const Color(0xFFFFD700), + fontSize: 10, + fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + loading: () => Text('...', + style: GoogleFonts.inter( + color: Colors.white24, fontSize: 10)), + error: (_, __) => Text('Err', + style: GoogleFonts.inter( + color: Colors.red, fontSize: 10)), + ); + }, ), ], ), diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index d73cb4f..9f26a5d 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -62,7 +62,7 @@ class _MoviesTabState extends ConsumerState { void _onSearchChanged(String query) { _searchDebounce?.cancel(); - + setState(() { _searchQuery = query; if (query.isEmpty) { @@ -70,7 +70,7 @@ class _MoviesTabState extends ConsumerState { _isSearching = false; } }); - + if (query.length >= 2) { _searchDebounce = Timer(const Duration(milliseconds: 500), () { _performSearch(query); @@ -80,13 +80,13 @@ class _MoviesTabState extends ConsumerState { Future _performSearch(String query) async { if (query.isEmpty) return; - + setState(() => _isSearching = true); - + try { final service = ref.read(xtreamServiceProvider(widget.playlist)); final results = await service.searchMovies(query); - + if (mounted && _searchQuery == query) { setState(() { _searchResults = results; @@ -151,7 +151,7 @@ class _MoviesTabState extends ConsumerState { Future _playMovie(Movie movie) async { ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); - + // Fetch actual duration from API if not available in movie data Duration? movieDuration; if (movie.durationSecs != null && movie.durationSecs! > 0) { @@ -164,9 +164,9 @@ class _MoviesTabState extends ConsumerState { movieDuration = Duration(seconds: durationSecs); } } - + if (!mounted) return; - + Navigator.push( context, MaterialPageRoute( @@ -186,17 +186,19 @@ class _MoviesTabState extends ConsumerState { Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); final watchHistory = ref.watch(watchHistoryProvider); - + List displayMovies; if (_searchQuery.isNotEmpty && _searchResults != null) { displayMovies = _searchResults!; } else { displayMovies = settings.moviesKeywords.isEmpty ? _movies - : _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); + : _movies + .where((m) => settings.matchesMoviesFilter(m.categoryName)) + .toList(); } - final double gridItemRatio = 0.65; + const double gridItemRatio = 0.65; final int crossAxisCount = ResponsiveLayout.value( context, mobile: 3, @@ -216,7 +218,8 @@ class _MoviesTabState extends ConsumerState { child: GlassContainer( borderRadius: 100, opacity: 0.6, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), child: Row( children: [ Text( @@ -228,7 +231,7 @@ class _MoviesTabState extends ConsumerState { ), ), const Spacer(), - + // Search Bar Container( width: 300, @@ -240,32 +243,41 @@ class _MoviesTabState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - const Icon(Icons.search, size: 20, color: Colors.white54), + const Icon(Icons.search, + size: 20, color: Colors.white54), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, - style: GoogleFonts.inter(fontSize: 14, color: Colors.white), + style: GoogleFonts.inter( + fontSize: 14, color: Colors.white), decoration: InputDecoration( hintText: 'Search movies...', - hintStyle: GoogleFonts.inter(color: Colors.white54), + hintStyle: + GoogleFonts.inter(color: Colors.white54), border: InputBorder.none, isDense: true, - contentPadding: const EdgeInsets.only(bottom: 12), + contentPadding: + const EdgeInsets.only(bottom: 12), ), onChanged: _onSearchChanged, ), ), if (_isSearching) - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)), if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: const Icon(Icons.close, size: 16, color: Colors.white), - ), + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: const Icon(Icons.close, + size: 16, color: Colors.white), + ), ], ), ), @@ -281,21 +293,29 @@ class _MoviesTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.only(bottom: 32), child: HeroCarousel( - items: displayMovies.take(5).map((m) => HeroItem( - id: m.streamId, - title: m.name, - imageUrl: _getProxiedImageUrl(m.streamIcon), - subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, - onMoreInfo: () => _playMovie(m), - )).toList(), + items: displayMovies + .take(5) + .map( + (m) => HeroItem( + id: m.streamId, + title: m.name, + imageUrl: _getProxiedImageUrl(m.streamIcon), + subtitle: m.rating != null + ? '${_formatRating(m.rating)} ★' + : null, + onMoreInfo: () => _playMovie(m), + ), + ) + .toList(), onTap: (item) { - final movie = _movies.firstWhere((element) => element.streamId == item.id); - _playMovie(movie); + final movie = _movies + .firstWhere((element) => element.streamId == item.id); + _playMovie(movie); }, ), ), ), - + // Movies Grid SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 24), @@ -311,7 +331,7 @@ class _MoviesTabState extends ConsumerState { if (index >= displayMovies.length) return null; final movie = displayMovies[index]; final isWatched = watchHistory.isMovieWatched(movie.streamId); - + return TvFocusableCard( onTap: () => _playMovie(movie), borderRadius: 12, @@ -321,66 +341,84 @@ class _MoviesTabState extends ConsumerState { // Poster Image ClipRRect( borderRadius: BorderRadius.circular(12), - child: movie.streamIcon != null && movie.streamIcon!.isNotEmpty - ? Image.network( - _getProxiedImageUrl(movie.streamIcon), - fit: BoxFit.cover, - errorBuilder: (ctx, err, stack) => Container( + child: movie.streamIcon != null && + movie.streamIcon!.isNotEmpty + ? Image.network( + _getProxiedImageUrl(movie.streamIcon), + fit: BoxFit.cover, + errorBuilder: (ctx, err, stack) => Container( + color: AppColors.surfaceVariant, + child: const Center( + child: Icon(Icons.movie, + size: 48, color: Colors.white24)), + ), + ) + : Container( color: AppColors.surfaceVariant, - child: const Center(child: Icon(Icons.movie, size: 48, color: Colors.white24)), + child: const Center( + child: Icon(Icons.movie, + size: 48, color: Colors.white24)), ), - ) - : Container( - color: AppColors.surfaceVariant, - child: const Center(child: Icon(Icons.movie, size: 48, color: Colors.white24)), - ), ), - + // Gradient Overlay Positioned( - left: 0, right: 0, bottom: 0, + left: 0, + right: 0, + bottom: 0, height: 80, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withOpacity(0.9)], + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.9) + ], ), - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), - ), - ), - ), - - // Rating Badge - if (movie.rating != null && movie.rating!.isNotEmpty) - Positioned( - top: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.white24), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.star, size: 10, color: Colors.amber), - const SizedBox(width: 4), - Text( - _formatRating(movie.rating)!, - style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(12)), ), ), ), + // Rating Badge + if (movie.rating != null && movie.rating!.isNotEmpty) + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.white24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star, + size: 10, color: Colors.amber), + const SizedBox(width: 4), + Text( + _formatRating(movie.rating)!, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white), + ), + ], + ), + ), + ), + // Title Positioned( - left: 12, right: 12, bottom: 12, + left: 12, + right: 12, + bottom: 12, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -391,7 +429,10 @@ class _MoviesTabState extends ConsumerState { color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, - shadows: [const Shadow(color: Colors.black, blurRadius: 4)], + shadows: [ + const Shadow( + color: Colors.black, blurRadius: 4) + ], ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -401,7 +442,10 @@ class _MoviesTabState extends ConsumerState { padding: const EdgeInsets.only(top: 4), child: Text( 'WATCHED', - style: GoogleFonts.inter(color: AppColors.primary, fontSize: 10, fontWeight: FontWeight.bold), + style: GoogleFonts.inter( + color: AppColors.primary, + fontSize: 10, + fontWeight: FontWeight.bold), ), ), ], @@ -415,16 +459,17 @@ class _MoviesTabState extends ConsumerState { ), ), ), - + // Loader if (_isLoading) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator(color: Colors.white)), + child: Center( + child: CircularProgressIndicator(color: Colors.white)), ), ), - + const SliverPadding(padding: EdgeInsets.only(bottom: 50)), ], ), diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index ea89d35..c0c998a 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -62,7 +62,7 @@ class _SeriesTabState extends ConsumerState { void _onSearchChanged(String query) { _searchDebounce?.cancel(); - + setState(() { _searchQuery = query; if (query.isEmpty) { @@ -70,7 +70,7 @@ class _SeriesTabState extends ConsumerState { _isSearching = false; } }); - + if (query.length >= 2) { _searchDebounce = Timer(const Duration(milliseconds: 500), () { _performSearch(query); @@ -80,13 +80,13 @@ class _SeriesTabState extends ConsumerState { Future _performSearch(String query) async { if (query.isEmpty) return; - + setState(() => _isSearching = true); - + try { final service = ref.read(xtreamServiceProvider(widget.playlist)); final results = await service.searchSeries(query); - + if (mounted && _searchQuery == query) { setState(() { _searchResults = results; @@ -164,17 +164,19 @@ class _SeriesTabState extends ConsumerState { @override Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); - + List displaySeries; if (_searchQuery.isNotEmpty && _searchResults != null) { displaySeries = _searchResults!; } else { displaySeries = settings.seriesKeywords.isEmpty ? _series - : _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); + : _series + .where((s) => settings.matchesSeriesFilter(s.categoryName)) + .toList(); } - final double gridItemRatio = 0.65; + const double gridItemRatio = 0.65; final int crossAxisCount = ResponsiveLayout.value( context, mobile: 3, @@ -194,7 +196,8 @@ class _SeriesTabState extends ConsumerState { child: GlassContainer( borderRadius: 100, opacity: 0.6, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), child: Row( children: [ Text( @@ -216,32 +219,41 @@ class _SeriesTabState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - const Icon(Icons.search, size: 20, color: Colors.white54), + const Icon(Icons.search, + size: 20, color: Colors.white54), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, - style: GoogleFonts.inter(fontSize: 14, color: Colors.white), + style: GoogleFonts.inter( + fontSize: 14, color: Colors.white), decoration: InputDecoration( hintText: 'Search series...', - hintStyle: GoogleFonts.inter(color: Colors.white54), + hintStyle: + GoogleFonts.inter(color: Colors.white54), border: InputBorder.none, isDense: true, - contentPadding: const EdgeInsets.only(bottom: 12), + contentPadding: + const EdgeInsets.only(bottom: 12), ), onChanged: _onSearchChanged, ), ), if (_isSearching) - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)), if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: const Icon(Icons.close, size: 16, color: Colors.white), - ), + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: const Icon(Icons.close, + size: 16, color: Colors.white), + ), ], ), ), @@ -257,24 +269,37 @@ class _SeriesTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.only(bottom: 32), child: HeroCarousel( - items: displaySeries.take(5).map((s) => HeroItem( - id: s.seriesId.toString(), - title: s.name, - imageUrl: _getProxiedImageUrl(s.cover), - subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, - onMoreInfo: () { - final series = _series.firstWhere((element) => element.seriesId.toString() == s.seriesId.toString(), orElse: () => s); - _openSeries(series); - }, - )).toList(), + items: displaySeries + .take(5) + .map( + (s) => HeroItem( + id: s.seriesId.toString(), + title: s.name, + imageUrl: _getProxiedImageUrl(s.cover), + subtitle: s.rating != null + ? '${_formatRating(s.rating)} ★' + : null, + onMoreInfo: () { + final series = _series.firstWhere( + (element) => + element.seriesId.toString() == + s.seriesId.toString(), + orElse: () => s); + _openSeries(series); + }, + ), + ) + .toList(), onTap: (item) { - final series = _series.firstWhere((element) => element.seriesId.toString() == item.id, orElse: () => _series[0]); - _openSeries(series); + final series = _series.firstWhere( + (element) => element.seriesId.toString() == item.id, + orElse: () => _series[0]); + _openSeries(series); }, ), ), ), - + // Grid SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 24), @@ -299,77 +324,96 @@ class _SeriesTabState extends ConsumerState { ClipRRect( borderRadius: BorderRadius.circular(12), child: serie.cover != null && serie.cover!.isNotEmpty - ? Image.network( - _getProxiedImageUrl(serie.cover), - fit: BoxFit.cover, - errorBuilder: (ctx, err, stack) => Container( + ? Image.network( + _getProxiedImageUrl(serie.cover), + fit: BoxFit.cover, + errorBuilder: (ctx, err, stack) => Container( + color: AppColors.surfaceVariant, + child: const Center( + child: Icon(Icons.tv, + size: 48, color: Colors.white24)), + ), + ) + : Container( color: AppColors.surfaceVariant, - child: const Center(child: Icon(Icons.tv, size: 48, color: Colors.white24)), + child: const Center( + child: Icon(Icons.tv, + size: 48, color: Colors.white24)), ), - ) - : Container( - color: AppColors.surfaceVariant, - child: const Center(child: Icon(Icons.tv, size: 48, color: Colors.white24)), - ), ), - + // Gradient Overlay Positioned( - left: 0, right: 0, bottom: 0, + left: 0, + right: 0, + bottom: 0, height: 80, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withOpacity(0.9)], + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.9) + ], ), - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(12)), ), ), ), // Title Positioned( - left: 12, right: 12, bottom: 12, + left: 12, + right: 12, + bottom: 12, child: Text( serie.name, style: GoogleFonts.inter( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, - shadows: [const Shadow(color: Colors.black, blurRadius: 4)], + shadows: [ + const Shadow(color: Colors.black, blurRadius: 4) + ], ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), - + // Rating Badge if (serie.rating != null && serie.rating!.isNotEmpty) - Positioned( - top: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.white24), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.star, size: 10, color: Colors.amber), - const SizedBox(width: 4), - Text( - _formatRating(serie.rating)!, - style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.white24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star, + size: 10, color: Colors.amber), + const SizedBox(width: 4), + Text( + _formatRating(serie.rating)!, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white), + ), + ], + ), ), ), - ), ], ), ); @@ -378,17 +422,18 @@ class _SeriesTabState extends ConsumerState { ), ), ), - + // Loader if (_isLoading) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator(color: Colors.white)), + child: Center( + child: CircularProgressIndicator(color: Colors.white)), ), ), - - const SliverPadding(padding: EdgeInsets.only(bottom: 50)), + + const SliverPadding(padding: EdgeInsets.only(bottom: 50)), ], ), ); diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart index d24beb2..977e60b 100644 --- a/lib/features/iptv/widgets/settings_tab.dart +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -18,7 +18,8 @@ class SettingsTab extends ConsumerStatefulWidget { ConsumerState createState() => _SettingsTabState(); } -class _SettingsTabState extends ConsumerState with SingleTickerProviderStateMixin { +class _SettingsTabState extends ConsumerState + with SingleTickerProviderStateMixin { late TabController _tabController; late TextEditingController _liveTvController; late TextEditingController _moviesController; @@ -45,13 +46,16 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv } void _syncControllers(IptvSettings settings) { - if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) { + if (!_initialized || + _liveTvController.text != settings.liveTvCategoryFilter) { _liveTvController.text = settings.liveTvCategoryFilter; } - if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) { + if (!_initialized || + _moviesController.text != settings.moviesCategoryFilter) { _moviesController.text = settings.moviesCategoryFilter; } - if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) { + if (!_initialized || + _seriesController.text != settings.seriesCategoryFilter) { _seriesController.text = settings.seriesCategoryFilter; } _initialized = true; @@ -61,7 +65,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv Widget build(BuildContext context) { final currentUser = ref.watch(authProvider).currentUser; final settings = ref.watch(iptvSettingsProvider); - + // Sync controllers with persisted state (only on first load) WidgetsBinding.instance.addPostFrameCallback((_) { if (!_initialized) _syncControllers(settings); @@ -85,17 +89,19 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv labelColor: Colors.white, unselectedLabelColor: Colors.white54, dividerColor: Colors.transparent, - overlayColor: MaterialStateProperty.all(Colors.transparent), + overlayColor: WidgetStateProperty.all(Colors.transparent), tabs: [ const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'), const Tab(icon: Icon(Icons.stream), text: 'Streaming'), const Tab(icon: Icon(Icons.palette), text: 'Apparence'), if (currentUser?.isAdmin ?? false) - const Tab(icon: Icon(Icons.admin_panel_settings), text: 'Administration'), + const Tab( + icon: Icon(Icons.admin_panel_settings), + text: 'Administration'), ], ), ), - + // TabBarView Expanded( child: TabBarView( @@ -103,16 +109,15 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv children: [ // Filters Tab _buildFiltersTab(context, currentUser, settings), - + // Streaming Tab const StreamingSettingsTab(), - + // Appearance Tab _buildAppearanceTab(context), // Admin Tab - if (currentUser?.isAdmin ?? false) - const AdminContent(), + if (currentUser?.isAdmin ?? false) const AdminContent(), ], ), ), @@ -125,7 +130,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv final themeState = ref.watch(themeProvider); final themeNotifier = ref.read(themeProvider.notifier); final isDark = Theme.of(context).brightness == Brightness.dark; - + return ListView( padding: const EdgeInsets.all(16), children: [ @@ -142,7 +147,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv children: [ Row( children: [ - Icon( + const Icon( Icons.dark_mode, color: Colors.white, ), @@ -158,7 +163,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ], ), const SizedBox(height: 16), - + // Theme options _buildThemeOption( context: context, @@ -189,9 +194,9 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ], ), ), - + const SizedBox(height: 16), - + // Quick toggle card Container( decoration: BoxDecoration( @@ -206,20 +211,21 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), title: Text( 'Mode actuel', - style: GoogleFonts.inter(fontWeight: FontWeight.w500, color: Colors.white), + style: GoogleFonts.inter( + fontWeight: FontWeight.w500, color: Colors.white), ), subtitle: Text( isDark ? 'Thème sombre actif' : 'Thème clair actif', - style: TextStyle(color: Colors.white70), + style: const TextStyle(color: Colors.white70), ), trailing: Switch( value: isDark, onChanged: (_) => themeNotifier.toggleTheme(), - activeColor: AppColors.primary, - trackColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? AppColors.primary.withOpacity(0.5) - : Colors.grey.shade800, + activeThumbColor: AppColors.primary, + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? AppColors.primary.withOpacity(0.5) + : Colors.grey.shade800, ), ), ), @@ -238,7 +244,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - + return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), @@ -298,8 +304,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ); } - - Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) { + Widget _buildFiltersTab( + BuildContext context, dynamic currentUser, IptvSettings settings) { return ListView( padding: const EdgeInsets.all(16), children: [ @@ -319,18 +325,21 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), subtitle: Text( currentUser?.username ?? 'Unknown', - style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), + style: GoogleFonts.roboto( + fontWeight: FontWeight.w600, color: Colors.white), ), trailing: currentUser?.isAdmin ?? false ? Chip( - label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11, color: Colors.black)), + label: Text('Admin', + style: GoogleFonts.roboto( + fontSize: 11, color: Colors.black)), backgroundColor: Colors.white, ) : null, ), ), const SizedBox(height: 16), - + // Category filters section Text( 'Filtres de Catégories', @@ -349,7 +358,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), ), const SizedBox(height: 12), - + // Live TV Filter _buildFilterCard( title: 'Filtre TV Live', @@ -365,7 +374,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }, ), const SizedBox(height: 8), - + // Movies Filter _buildFilterCard( title: 'Filtre Films', @@ -381,7 +390,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }, ), const SizedBox(height: 8), - + // Series Filter _buildFilterCard( title: 'Filtre Séries', @@ -397,9 +406,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }, ), const SizedBox(height: 16), - - // Change playlist Container( decoration: BoxDecoration( @@ -409,13 +416,14 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), child: ListTile( leading: const Icon(Icons.playlist_play, color: Colors.white), - title: Text('Changer de Playlist', style: GoogleFonts.roboto(color: Colors.white)), + title: Text('Changer de Playlist', + style: GoogleFonts.roboto(color: Colors.white)), trailing: const Icon(Icons.chevron_right, color: Colors.white54), onTap: () => context.go('/playlists'), ), ), const SizedBox(height: 8), - + // About Container( decoration: BoxDecoration( @@ -425,7 +433,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), child: ListTile( leading: const Icon(Icons.info_outline, color: Colors.white), - title: Text('À propos', style: GoogleFonts.roboto(color: Colors.white)), + title: Text('À propos', + style: GoogleFonts.roboto(color: Colors.white)), subtitle: Text( 'XtremFlow IPTV v1.0.0', style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), @@ -433,7 +442,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), ), const SizedBox(height: 16), - + // Maintenance Section Container( decoration: BoxDecoration( @@ -443,50 +452,53 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), child: ListTile( leading: const Icon(Icons.cleaning_services, color: Colors.orange), - title: Text('Maintenance', style: GoogleFonts.roboto(color: Colors.white)), + title: Text('Maintenance', + style: GoogleFonts.roboto(color: Colors.white)), subtitle: Text( 'En cas de problèmes d\'affichage ou de mise à jour', style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), ), trailing: FilledButton.icon( onPressed: () { - // Show confirmation dialog - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), - title: const Text('Vider le cache ?', style: TextStyle(color: Colors.white)), - content: const Text( - 'Cette action va recharger l\'application et effacer les données temporaires.\n' - 'Vos réglages et favoris sont sauvegardés sur le serveur.', - style: TextStyle(color: Colors.white70), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: Colors.orange), - onPressed: () { - Navigator.pop(context); - - // Clear Flutter Image Cache - PaintingBinding.instance.imageCache.clear(); - PaintingBinding.instance.imageCache.clearLiveImages(); - - // Clear Browser Local Storage (SharedPreferences backend for Web) - html.window.localStorage.clear(); - html.window.sessionStorage.clear(); - - // Reload application - html.window.location.reload(); - }, - child: const Text('Vider et Recharger'), - ), - ], - ) - ); + // Show confirmation dialog + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: const Text('Vider le cache ?', + style: TextStyle(color: Colors.white)), + content: const Text( + 'Cette action va recharger l\'application et effacer les données temporaires.\n' + 'Vos réglages et favoris sont sauvegardés sur le serveur.', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.orange), + onPressed: () { + Navigator.pop(context); + + // Clear Flutter Image Cache + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + + // Clear Browser Local Storage (SharedPreferences backend for Web) + html.window.localStorage.clear(); + html.window.sessionStorage.clear(); + + // Reload application + html.window.location.reload(); + }, + child: const Text('Vider et Recharger'), + ), + ], + ), + ); }, icon: const Icon(Icons.refresh, size: 16), label: const Text('Vider le Cache'), @@ -498,7 +510,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), ), const SizedBox(height: 24), - + // Logout button FilledButton.icon( onPressed: () { @@ -553,7 +565,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv controller: controller, decoration: InputDecoration( hintText: 'Ex: FR,FRANCE,HD,SPORT', - hintStyle: GoogleFonts.roboto(fontSize: 12, color: Colors.white30), + hintStyle: + GoogleFonts.roboto(fontSize: 12, color: Colors.white30), filled: true, fillColor: Colors.black26, border: OutlineInputBorder( @@ -567,7 +580,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), suffixIcon: controller.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear, size: 16, color: Colors.white54), + icon: const Icon(Icons.clear, + size: 16, color: Colors.white54), onPressed: onClear, padding: EdgeInsets.zero, constraints: const BoxConstraints(), @@ -584,15 +598,18 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv runSpacing: 6, children: keywords.map((keyword) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: Colors.blueAccent.withOpacity(0.2), borderRadius: BorderRadius.circular(50), - border: Border.all(color: Colors.blueAccent.withOpacity(0.5)), + border: + Border.all(color: Colors.blueAccent.withOpacity(0.5)), ), child: Text( keyword, - style: GoogleFonts.roboto(fontSize: 11, color: Colors.white), + style: + GoogleFonts.roboto(fontSize: 11, color: Colors.white), ), ); }).toList(), diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart index 0d4eaf4..314e604 100644 --- a/lib/features/iptv/widgets/streaming_settings_tab.dart +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -111,7 +111,7 @@ class StreamingSettingsTab extends ConsumerWidget { style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), ), value: settings.autoReconnect, - activeColor: Colors.blueAccent, + activeThumbColor: Colors.blueAccent, onChanged: (v) => notifier.setAutoReconnect(v), ), ], @@ -147,10 +147,9 @@ class StreamingSettingsTab extends ConsumerWidget { style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), ), value: settings.preferDirectPlay, - activeColor: Colors.blueAccent, + activeThumbColor: Colors.blueAccent, onChanged: (v) => notifier.setPreferDirectPlay(v), ), - ], ), const SizedBox(height: 16), @@ -253,8 +252,10 @@ class StreamingSettingsTab extends ConsumerWidget { required String Function(T) labelBuilder, }) { return ListTile( - title: Text(title, style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), - subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), + title: Text(title, + style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), + subtitle: Text(subtitle, + style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), trailing: DropdownButton( value: value, underline: const SizedBox(), diff --git a/lib/mobile/features/auth/screens/mobile_login_screen.dart b/lib/mobile/features/auth/screens/mobile_login_screen.dart index d87c3e6..904fb24 100644 --- a/lib/mobile/features/auth/screens/mobile_login_screen.dart +++ b/lib/mobile/features/auth/screens/mobile_login_screen.dart @@ -193,7 +193,7 @@ class _MobileLoginScreenState extends ConsumerState { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 0, ).copyWith( - overlayColor: MaterialStateProperty.all(Colors.black12), + overlayColor: WidgetStateProperty.all(Colors.black12), ), child: authState.isLoading ? const SizedBox( diff --git a/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart index 269a368..8b4e737 100644 --- a/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart @@ -6,7 +6,7 @@ import '../../../../core/models/playlist_config.dart'; import '../../../../features/iptv/models/xtream_models.dart'; import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../../features/iptv/providers/watch_history_provider.dart'; -import '../../../../features/iptv/screens/player_screen.dart'; // Explicit import for StreamType +// Explicit import for StreamType import 'mobile_player_screen.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/glass_container.dart'; @@ -22,10 +22,12 @@ class MobileSeriesDetailScreen extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _MobileSeriesDetailScreenState(); + ConsumerState createState() => + _MobileSeriesDetailScreenState(); } -class _MobileSeriesDetailScreenState extends ConsumerState { +class _MobileSeriesDetailScreenState + extends ConsumerState { SeriesInfo? _seriesInfo; bool _isLoading = true; String? _error; @@ -83,7 +85,7 @@ class _MobileSeriesDetailScreenState extends ConsumerState Container(color: Colors.grey.shade900), + errorWidget: (_, __, ___) => + Container(color: Colors.grey.shade900), ), // Gradient Overlay Container( @@ -164,7 +170,9 @@ class _MobileSeriesDetailScreenState extends ConsumerState 0) + if (episode.durationSecs != null && + episode.durationSecs! > 0) Text( _formatDuration(episode.durationSecs!), - style: GoogleFonts.inter(color: Colors.white54, fontSize: 12), + style: GoogleFonts.inter( + color: Colors.white54, fontSize: 12), ), ], ), diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index ac36a30..2cdf420 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -34,7 +34,8 @@ class _MobileLiveTVTabState extends ConsumerState { @override Widget build(BuildContext context) { - final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); + final channelsAsync = + ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final favorites = ref.watch(favoritesProvider); final settings = ref.watch(iptvSettingsProvider); @@ -42,26 +43,32 @@ class _MobileLiveTVTabState extends ConsumerState { backgroundColor: Colors.transparent, // Handled by MobileScaffold body: channelsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, s) => Center(child: Text('Error: $e', style: const TextStyle(color: Colors.white))), + error: (e, s) => Center( + child: + Text('Error: $e', style: const TextStyle(color: Colors.white))), data: (groupedChannels) { var categories = groupedChannels.keys.toList(); if (settings.liveTvKeywords.isNotEmpty) { - categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); + categories = categories + .where((cat) => settings.matchesLiveTvFilter(cat)) + .toList(); } categories.sort(); List displayedChannels = []; if (_searchQuery.isNotEmpty) { - displayedChannels = groupedChannels.values.expand((l) => l) + displayedChannels = groupedChannels.values + .expand((l) => l) .where((c) => c.name.toLowerCase().contains(_searchQuery)) .toList(); } else if (_showFavoritesOnly) { - displayedChannels = groupedChannels.values.expand((l) => l) + displayedChannels = groupedChannels.values + .expand((l) => l) .where((c) => favorites.contains(c.streamId)) .toList(); } else { if (_selectedCategory == null && categories.isNotEmpty) { - _selectedCategory = categories.first; + _selectedCategory = categories.first; } if (_selectedCategory != null) { displayedChannels = groupedChannels[_selectedCategory] ?? []; @@ -78,27 +85,31 @@ class _MobileLiveTVTabState extends ConsumerState { child: GlassContainer( borderRadius: 16, opacity: 0.1, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ - Expanded( - child: TextField( - controller: _searchController, - style: GoogleFonts.inter(color: Colors.white), - decoration: InputDecoration( - hintText: 'Search channels...', - hintStyle: GoogleFonts.inter(color: Colors.white54), - border: InputBorder.none, - prefixIcon: const Icon(Icons.search, color: Colors.white54), - isDense: true, - ), - ), - ), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () => _searchController.clear(), - child: const Icon(Icons.close, color: Colors.white54), - ), + Expanded( + child: TextField( + controller: _searchController, + style: GoogleFonts.inter(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search channels...', + hintStyle: + GoogleFonts.inter(color: Colors.white54), + border: InputBorder.none, + prefixIcon: const Icon(Icons.search, + color: Colors.white54), + isDense: true, + ), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () => _searchController.clear(), + child: + const Icon(Icons.close, color: Colors.white54), + ), ], ), ), @@ -117,19 +128,26 @@ class _MobileLiveTVTabState extends ConsumerState { final category = categories[index]; final isSelected = category == _selectedCategory; return GestureDetector( - onTap: () => setState(() => _selectedCategory = category), + onTap: () => + setState(() => _selectedCategory = category), child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), decoration: BoxDecoration( - color: isSelected ? Colors.white : Colors.black.withOpacity(0.4), + color: isSelected + ? Colors.white + : Colors.black.withOpacity(0.4), borderRadius: BorderRadius.circular(20), ), child: Text( category, style: GoogleFonts.inter( - color: isSelected ? Colors.black : Colors.white70, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: + isSelected ? Colors.black : Colors.white70, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, fontSize: 13, ), ), @@ -145,28 +163,39 @@ class _MobileLiveTVTabState extends ConsumerState { child: Row( children: [ Text( - _searchQuery.isNotEmpty ? 'Results' : - _showFavoritesOnly ? 'Favorites' : - _selectedCategory ?? 'Channels', + _searchQuery.isNotEmpty + ? 'Results' + : _showFavoritesOnly + ? 'Favorites' + : _selectedCategory ?? 'Channels', style: GoogleFonts.inter( - color: Colors.white, + color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18, ), ), const Spacer(), GestureDetector( - onTap: () => setState(() => _showFavoritesOnly = !_showFavoritesOnly), + onTap: () => setState( + () => _showFavoritesOnly = !_showFavoritesOnly), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: _showFavoritesOnly ? AppColors.live.withOpacity(0.2) : Colors.white.withOpacity(0.1), + color: _showFavoritesOnly + ? AppColors.live.withOpacity(0.2) + : Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: _showFavoritesOnly ? Border.all(color: AppColors.live) : null, + border: _showFavoritesOnly + ? Border.all(color: AppColors.live) + : null, ), child: Icon( - _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, - color: _showFavoritesOnly ? AppColors.live : Colors.white, + _showFavoritesOnly + ? Icons.favorite + : Icons.favorite_border, + color: _showFavoritesOnly + ? AppColors.live + : Colors.white, size: 20, ), ), @@ -182,7 +211,8 @@ class _MobileLiveTVTabState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.tv_off, color: Colors.white24, size: 48), + const Icon(Icons.tv_off, + color: Colors.white24, size: 48), const SizedBox(height: 16), Text( 'No channels found', @@ -194,7 +224,8 @@ class _MobileLiveTVTabState extends ConsumerState { : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), itemCount: displayedChannels.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), + separatorBuilder: (_, __) => + const SizedBox(height: 8), itemBuilder: (context, index) { final channel = displayedChannels[index]; return _MobileChannelTile( @@ -240,14 +271,17 @@ class _MobileChannelTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http') - ? '/api/xtream/${channel.streamIcon}' - : null; + final iconUrl = + channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http') + ? '/api/xtream/${channel.streamIcon}' + : null; // Fetch EPG - final epgAsync = ref.watch(epgByPlaylistProvider( - EpgRequestKey(playlist: playlist, streamId: channel.streamId) - )); + final epgAsync = ref.watch( + epgByPlaylistProvider( + EpgRequestKey(playlist: playlist, streamId: channel.streamId), + ), + ); return GlassContainer( borderRadius: 12, @@ -270,9 +304,10 @@ class _MobileChannelTile extends ConsumerWidget { padding: const EdgeInsets.all(4), child: iconUrl != null ? Image.network( - iconUrl, + iconUrl, fit: BoxFit.contain, - errorBuilder: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24), + errorBuilder: (_, __, ___) => + const Icon(Icons.tv, color: Colors.white24), ) : const Icon(Icons.tv, color: Colors.white24), ), @@ -291,7 +326,7 @@ class _MobileChannelTile extends ConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - + // EPG Only (Replaces Channel Number) epgAsync.when( data: (epgList) { @@ -308,7 +343,8 @@ class _MobileChannelTile extends ConsumerWidget { return Text( current.title, style: GoogleFonts.inter( - color: const Color(0xFFFFD700), // Gold/Amber for visibility check + color: const Color( + 0xFFFFD700), // Gold/Amber for visibility check fontSize: 12, fontWeight: FontWeight.w500, ), @@ -316,8 +352,11 @@ class _MobileChannelTile extends ConsumerWidget { overflow: TextOverflow.ellipsis, ); }, - loading: () => Text('...', style: TextStyle(color: Colors.white38, fontSize: 12)), - error: (err, stack) => Text('Err', style: TextStyle(color: Colors.red, fontSize: 10)), + loading: () => const Text('...', + style: + TextStyle(color: Colors.white38, fontSize: 12)), + error: (err, stack) => const Text('Err', + style: TextStyle(color: Colors.red, fontSize: 10)), ), ], ), @@ -328,7 +367,8 @@ class _MobileChannelTile extends ConsumerWidget { color: Colors.white.withOpacity(0.1), shape: BoxShape.circle, ), - child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 16), + child: const Icon(Icons.play_arrow_rounded, + color: Colors.white, size: 16), ), ], ), diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index 8236235..c2f44f2 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -49,9 +49,10 @@ class _MobileMoviesTabState extends ConsumerState { } void _onScroll() { - if (!_isLoading && _hasMore && + if (!_isLoading && + _hasMore && _scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8) { + _scrollController.position.maxScrollExtent * 0.8) { _loadMoreMovies(); } } @@ -65,7 +66,7 @@ class _MobileMoviesTabState extends ConsumerState { _isSearching = false; } }); - + if (query.length >= 2) { _searchDebounce = Timer(const Duration(milliseconds: 500), () { _performSearch(query); @@ -76,11 +77,11 @@ class _MobileMoviesTabState extends ConsumerState { Future _performSearch(String query) async { if (query.isEmpty) return; setState(() => _isSearching = true); - + try { final service = ref.read(xtreamServiceProvider(widget.playlist)); final results = await service.searchMovies(query); - + if (mounted && _searchQuery == query) { setState(() { _searchResults = results; @@ -138,7 +139,7 @@ class _MobileMoviesTabState extends ConsumerState { void _playMovie(Movie movie) { ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); - + Navigator.push( context, MaterialPageRoute( @@ -157,23 +158,30 @@ class _MobileMoviesTabState extends ConsumerState { Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); final watchHistory = ref.watch(watchHistoryProvider); - + List displayMovies; if (_searchQuery.isNotEmpty && _searchResults != null) { displayMovies = _searchResults!; } else { displayMovies = settings.moviesKeywords.isEmpty ? _movies - : _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); + : _movies + .where((m) => settings.matchesMoviesFilter(m.categoryName)) + .toList(); } - final heroItems = displayMovies.take(3).map((m) => HeroItem( - id: m.streamId, - title: m.name, - imageUrl: _getProxiedImageUrl(m.streamIcon), - subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, - onMoreInfo: () => _playMovie(m), - )).toList(); + final heroItems = displayMovies + .take(3) + .map( + (m) => HeroItem( + id: m.streamId, + title: m.name, + imageUrl: _getProxiedImageUrl(m.streamIcon), + subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, + onMoreInfo: () => _playMovie(m), + ), + ) + .toList(); return SafeArea( bottom: false, @@ -187,7 +195,8 @@ class _MobileMoviesTabState extends ConsumerState { child: GlassContainer( borderRadius: 100, opacity: 0.1, // Glass - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ const Icon(Icons.search, size: 20, color: Colors.white54), @@ -195,7 +204,8 @@ class _MobileMoviesTabState extends ConsumerState { Expanded( child: TextField( controller: _searchController, - style: GoogleFonts.inter(fontSize: 14, color: Colors.white), + style: GoogleFonts.inter( + fontSize: 14, color: Colors.white), decoration: InputDecoration( hintText: 'Search Movies...', hintStyle: GoogleFonts.inter(color: Colors.white54), @@ -207,14 +217,19 @@ class _MobileMoviesTabState extends ConsumerState { ), ), if (_isSearching) - const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)), if (_searchQuery.isNotEmpty) GestureDetector( onTap: () { - _searchController.clear(); - _onSearchChanged(''); + _searchController.clear(); + _onSearchChanged(''); }, - child: const Icon(Icons.close, size: 16, color: Colors.white), + child: const Icon(Icons.close, + size: 16, color: Colors.white), ), ], ), @@ -228,18 +243,19 @@ class _MobileMoviesTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.only(bottom: 24), child: SizedBox( - height: 250, - child: HeroCarousel( - items: heroItems, - onTap: (item) { - final movie = _movies.firstWhere((element) => element.streamId == item.id); - _playMovie(movie); - }, - ), + height: 250, + child: HeroCarousel( + items: heroItems, + onTap: (item) { + final movie = _movies + .firstWhere((element) => element.streamId == item.id); + _playMovie(movie); + }, + ), ), ), ), - + // Grid SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), @@ -255,11 +271,13 @@ class _MobileMoviesTabState extends ConsumerState { if (index >= displayMovies.length) return null; final movie = displayMovies[index]; final isWatched = watchHistory.isMovieWatched(movie.streamId); - + return GestureDetector( onTap: () => _playMovie(movie), onLongPress: () { - ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); + ref + .read(watchHistoryProvider.notifier) + .toggleMovieWatched(movie.streamId); }, child: Stack( fit: StackFit.expand, @@ -267,46 +285,66 @@ class _MobileMoviesTabState extends ConsumerState { // Poster ClipRRect( borderRadius: BorderRadius.circular(16), - child: movie.streamIcon != null && movie.streamIcon!.isNotEmpty - ? Image.network( - _getProxiedImageUrl(movie.streamIcon), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container(color: Colors.white10), - ) - : Container(color: Colors.white10, child: const Icon(Icons.movie, color: Colors.white24)), + child: movie.streamIcon != null && + movie.streamIcon!.isNotEmpty + ? Image.network( + _getProxiedImageUrl(movie.streamIcon), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Container(color: Colors.white10), + ) + : Container( + color: Colors.white10, + child: const Icon(Icons.movie, + color: Colors.white24)), ), - + // Gradient Overlay Positioned( - bottom: 0, left: 0, right: 0, + bottom: 0, + left: 0, + right: 0, height: 80, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withOpacity(0.9)], + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.9) + ], ), - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)), + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(16)), ), ), ), // Title Positioned( - bottom: 12, left: 12, right: 12, + bottom: 12, + left: 12, + right: 12, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( movie.name, - style: GoogleFonts.inter(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (isWatched) - Text('WATCHED', style: GoogleFonts.inter(color: AppColors.primary, fontSize: 10, fontWeight: FontWeight.bold)), + Text('WATCHED', + style: GoogleFonts.inter( + color: AppColors.primary, + fontSize: 10, + fontWeight: FontWeight.bold)), ], ), ), @@ -314,19 +352,25 @@ class _MobileMoviesTabState extends ConsumerState { // Rating if (movie.rating != null && movie.rating!.isNotEmpty) Positioned( - top: 8, right: 8, + top: 8, + right: 8, child: GlassContainer( borderRadius: 4, opacity: 0.6, - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.star, size: 10, color: Colors.amber), + const Icon(Icons.star, + size: 10, color: Colors.amber), const SizedBox(width: 4), Text( _formatRating(movie.rating)!, - style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white), ), ], ), @@ -340,12 +384,13 @@ class _MobileMoviesTabState extends ConsumerState { ), ), ), - + if (_isLoading) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator(color: Colors.white)), + child: Center( + child: CircularProgressIndicator(color: Colors.white)), ), ), ],