diff --git a/Dockerfile b/Dockerfile index f3c0477..495d736 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,28 +53,32 @@ COPY lib/ ./lib/ RUN dart pub get # Compile server to native executable -# This drastically reduces startup time and memory footprint (vs JIT) RUN dart compile exe bin/server.dart -o bin/server # ============================================ -# Stage 3: Production Runtime +# Stage 3: Production Runtime (with NVENC support) # ============================================ FROM debian:stable-slim -# Install runtime dependencies -# - ffmpeg: For transcoding -# - ca-certificates: For HTTPS requests -# - sqlite3: For database -# - libsqlite3-0: Shared library for sqlite -# - curl: For healthchecks (optional but good practice) +# Install runtime dependencies and tools for fetching FFmpeg RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ ca-certificates \ sqlite3 \ libsqlite3-0 \ curl \ + wget \ + xz-utils \ && rm -rf /var/lib/apt/lists/* +# Install FFmpeg with NVIDIA NVENC support from BtbN static builds +# Falls back gracefully to CPU if GPU not available +RUN wget -q https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz \ + && tar xf ffmpeg-master-latest-linux64-gpl.tar.xz \ + && mv ffmpeg-master-latest-linux64-gpl/bin/ffmpeg /usr/local/bin/ \ + && mv ffmpeg-master-latest-linux64-gpl/bin/ffprobe /usr/local/bin/ \ + && rm -rf ffmpeg-master-latest-linux64-gpl* \ + && chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe + # Create non-root user for security RUN groupadd -r xtremuser && useradd -r -g xtremuser -G audio,video xtremuser @@ -99,7 +103,7 @@ EXPOSE 8089 # Healthcheck HEALTHCHECK --interval=30s --timeout=3s \ - CMD curl -f http://localhost:8089/index.html || exit 1 + CMD curl -f http://localhost:8089/index.html || exit 1 # Start server CMD ["/app/server", "--port", "8089", "--path", "/app/web"] diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 4db2ab6..26b2dbb 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -19,12 +19,36 @@ 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()) + if (File('ffmpeg/bin/ffmpeg.exe').existsSync()) { return 'ffmpeg/bin/ffmpeg.exe'; + } } return 'ffmpeg'; // Default to PATH } +/// Check if NVIDIA GPU acceleration is enabled via environment variable +bool _isNvidiaGpuEnabled() { + final envValue = Platform.environment['NVIDIA_GPU'] ?? + Platform.environment['nvidia_gpu'] ?? + 'false'; + return envValue.toLowerCase() == 'true' || envValue == '1'; +} + +/// Log GPU status on first use +bool _gpuStatusLogged = false; +void _logGpuStatus() { + if (!_gpuStatusLogged) { + if (_isNvidiaGpuEnabled()) { + print('[FFmpeg] NVIDIA GPU acceleration ENABLED (NVDEC/NVENC)'); + } else { + print( + '[FFmpeg] Using CPU processing (set NVIDIA_GPU=true to enable GPU)', + ); + } + _gpuStatusLogged = true; + } +} + /// Initialize streaming subsystem Future initStreaming() async { if (!_hlsTempDir.existsSync()) { @@ -94,7 +118,9 @@ PlaylistConfig? _getCurrentPlaylist(Request request) { // ========================================== Handler createLiveStreamHandler( - Future Function(Request) getPlaylist) { + Future Function(Request) getPlaylist, { + bool Function()? isGpuEnabled, +}) { final router = Router(); // Route: /api/live/{streamId} (Catch all pattern to debug) @@ -128,29 +154,93 @@ Handler createLiveStreamHandler( '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; print('[Live] Streaming via FFmpeg: $targetUrl'); - // Start FFmpeg to proxy and stabilize the stream - // We use "-c copy" to avoid transcoding (Low CPU) but gain FFmpeg's network robustness - final ffmpegArgs = [ - '-hide_banner', '-loglevel', 'error', - // Full headers to mimic a real video player + // Check if NVIDIA GPU is enabled (from database setting or env var fallback) + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); + + // Log GPU status + if (useNvidiaGpu) { + print('[Live] Using NVIDIA GPU acceleration (NVDEC)'); + } + + // Build FFmpeg arguments + final ffmpegArgs = [ + '-hide_banner', + '-loglevel', + 'warning', // Changed from error to see more info + ]; + + // NVIDIA GPU Hardware Acceleration (NVDEC for decoding) + if (useNvidiaGpu) { + ffmpegArgs.addAll([ + '-hwaccel', 'cuda', // Use NVIDIA CUDA for decoding + // Note: Don't use hwaccel_output_format cuda - it can cause issues with some codecs + ]); + } + + // Input headers and robustness flags + ffmpegArgs.addAll([ '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\nAccept: */*\r\nConnection: keep-alive\r\nReferer: ${playlist.dns}/\r\n', + '-reconnect', + '1', + '-reconnect_at_eof', + '1', + '-reconnect_streamed', + '1', + '-reconnect_delay_max', + '10', // Increased from 5 + '-rw_timeout', + '30000000', // Increased from 15s to 30s + '-timeout', + '30000000', // Add explicit timeout + '-fflags', + '+nobuffer+genpts+discardcorrupt', // Added discardcorrupt for robustness + '-analyzeduration', + '5000000', // Increased for GPU decode + '-probesize', + '10000000', // Increased for GPU decode + '-i', + targetUrl, + ]); - // HTTP redirect handling and robustness flags - '-reconnect', '1', - '-reconnect_at_eof', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '5', - '-rw_timeout', '15000000', - '-analyzeduration', '5000000', // Faster probe for live - '-probesize', '5000000', + // Output encoding options + if (useNvidiaGpu) { + // With GPU: Transcode using NVIDIA NVENC for hardware acceleration + print('[Live] Transcoding video with h264_nvenc (GPU)'); + ffmpegArgs.addAll([ + // Video encoding + '-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder + '-preset', 'p1', // Fastest preset for live (p1=fastest) + '-tune', 'll', // Low latency tuning for live streams + '-rc', 'cbr', // Constant bitrate for stable streaming + '-b:v', '4000k', // Fixed video bitrate + '-maxrate', '4500k', // Max bitrate + '-bufsize', '8000k', // Buffer for stability + '-g', '50', // Keyframe every 2 seconds (at 25fps) + '-bf', '0', // No B-frames for lower latency + // Audio encoding with resampling to fix timestamp gaps + '-af', + 'aresample=async=1000:first_pts=0', // Auto-correct audio timestamps + '-c:a', 'aac', // Audio to AAC (browser compatible) + '-b:a', '128k', // Audio bitrate + '-ac', '2', + '-ar', '48000', // 48kHz sample rate + // Timestamp handling + '-copyts', // Copy timestamps + '-start_at_zero', // Start at 0 + '-max_muxing_queue_size', '4096', // Large muxing queue + ]); + } else { + // Without GPU: Standard CPU processing (copy video) + ffmpegArgs.addAll([ + '-c:v', 'copy', // Video: Direct copy (Very low CPU) + '-c:a', 'aac', // Audio: Transcode to AAC + '-b:a', '192k', + '-ac', '2', + ]); + } - '-i', targetUrl, - - '-c', 'copy', // Direct stream copy (Very low CPU) - '-f', 'mpegts', - 'pipe:1', // Output to stdout - ]; + ffmpegArgs.addAll(['-f', 'mpegts', 'pipe:1']); try { final ffmpegPath = _getFFmpegPath(); @@ -210,15 +300,18 @@ Handler createLiveStreamHandler( final _lastLogTime = {}; Handler createVodStreamHandler( - Future Function(Request) getPlaylist) { + Future Function(Request) getPlaylist, { + bool Function()? isGpuEnabled, +}) { final router = Router(); // Route: /api/vod/{streamId}/playlist.m3u8 router.get('//playlist.m3u8', (Request request, String streamId) async { final playlist = await getPlaylist(request); - if (playlist == null) + if (playlist == null) { return Response.internalServerError(body: 'No playlist'); + } // Validate streamId to prevent Path Traversal if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(streamId)) { @@ -292,58 +385,123 @@ Handler createVodStreamHandler( '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv'; print('[VOD] Starting Transcode ($contentType): $targetUrl'); - // Start FFmpeg - final ffmpegArgs = [ - '-hide_banner', '-loglevel', 'error', - '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', + // Check if NVIDIA GPU is enabled (from database setting or env var fallback) + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); - // Robustness for network streams - '-reconnect', '1', - '-reconnect_at_eof', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '5', - '-rw_timeout', '15000000', + if (useNvidiaGpu) { + print('[VOD] Using NVIDIA GPU acceleration (NVENC)'); + } + + // Build FFmpeg arguments + final ffmpegArgs = [ + '-hide_banner', + '-loglevel', + 'warning', + ]; + + // NVIDIA GPU Hardware Acceleration + if (useNvidiaGpu) { + ffmpegArgs.addAll([ + '-hwaccel', 'cuda', + // Note: Don't use hwaccel_output_format cuda - it can cause issues + ]); + } + + // Input and robustness flags + ffmpegArgs.addAll([ + '-headers', + 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', + '-reconnect', + '1', + '-reconnect_at_eof', + '1', + '-reconnect_streamed', + '1', + '-reconnect_delay_max', + '10', + '-rw_timeout', + '30000000', + '-timeout', + '30000000', '-analyzeduration', - '2000000', // Reduced from 10M to 2M for faster start - '-probesize', '2000000', // Reduced from 10M to 2M for faster start + '5000000', + '-probesize', + '10000000', + '-i', + targetUrl, + ]); - '-i', targetUrl, + // Video encoding (GPU or CPU) + if (useNvidiaGpu) { + // NVIDIA NVENC - Hardware encoding (50x+ faster, much lower CPU) + ffmpegArgs.addAll([ + '-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder + '-preset', 'p4', // Good quality/speed balance + '-tune', 'hq', // High quality mode + '-rc', 'cbr', // Constant bitrate for HLS + '-b:v', '4000k', // Target bitrate + '-maxrate', '4500k', + '-bufsize', '8000k', + '-g', '48', // Keyframe every 2 seconds + '-bf', '2', // B-frames for quality + '-pix_fmt', 'yuv420p', + ]); + } else { + // CPU encoding (libx264) + ffmpegArgs.addAll([ + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + '-tune', + 'zerolatency', + '-tune', + 'fastdecode', + '-crf', + '23', + '-maxrate', + '3000k', + '-bufsize', + '6000k', + '-pix_fmt', + 'yuv420p', + '-threads', + '0', + ]); + } - // Video: H.264 Ultrafast (Low CPU) - '-c:v', 'libx264', - '-preset', 'ultrafast', - '-tune', 'zerolatency', // Optimize for low latency startup - '-tune', 'fastdecode', - '-crf', '23', - '-maxrate', '3000k', - '-bufsize', '6000k', - '-pix_fmt', 'yuv420p', - '-threads', '0', - - // Audio: AAC (Browser compatible, improved quality) - '-c:a', 'aac', - '-b:a', '192k', - '-ar', '48000', - - // Advanced Audio Filtering: - // 1. "pan": Downmix 5.1/7.1 to Stereo with center mix (dialogue) boosted. - // 2. "dynaudnorm": Dynamic Audio Normalizer to boost quiet dialogue and limit loud explosions (Night Mode effect). + // Audio encoding (same for both) + ffmpegArgs.addAll([ + '-c:a', + 'aac', + '-b:a', + '192k', + '-ar', + '48000', '-af', 'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15', + ]); - '-f', 'hls', - '-hls_time', '4', - '-hls_list_size', '0', + // HLS output settings + ffmpegArgs.addAll([ + '-f', + 'hls', + '-hls_time', + '4', + '-hls_list_size', + '0', '-hls_playlist_type', - 'event', // Event type allows immediate playback while transcoding - '-hls_allow_cache', '1', - '-hls_segment_type', 'mpegts', - '-hls_segment_filename', '${streamDir.path}/segment_%03d.ts', - '-start_number', '0', - - // Write playlist to stdout or file? File is easier for static serving + 'event', + '-hls_allow_cache', + '1', + '-hls_segment_type', + 'mpegts', + '-hls_segment_filename', + '${streamDir.path}/segment_%03d.ts', + '-start_number', + '0', '${streamDir.path}/playlist.m3u8', - ]; + ]); final ffmpegPath = _getFFmpegPath(); Process.start(ffmpegPath, ffmpegArgs).then((process) { @@ -372,7 +530,8 @@ Handler createVodStreamHandler( if (!playlistFile.existsSync()) { return Response.internalServerError( - body: 'Timeout waiting for transcoder'); + body: 'Timeout waiting for transcoder', + ); } return Response.ok( diff --git a/bin/database/database.dart b/bin/database/database.dart index c65dd19..add0391 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -373,6 +373,37 @@ class AppDatabase { ); } + /// Check if NVIDIA GPU is enabled in any user's settings + /// This is a server-wide setting that applies to all streaming + bool isNvidiaGpuEnabled() { + try { + // Check all user settings for enable_nvidia_gpu + final result = _db.select( + 'SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1', + ); + + if (result.isEmpty) { + print('[GPU] No user settings found in database'); + return false; + } + + final settingsJson = result.first['settings_json'] as String; + print('[GPU] Settings found: $settingsJson'); + + // Parse JSON properly to check the setting + // Handle both "enable_nvidia_gpu":true and "enable_nvidia_gpu": true + final RegExp gpuRegex = + RegExp(r'"enable_nvidia_gpu"\s*:\s*true', caseSensitive: false); + final isEnabled = gpuRegex.hasMatch(settingsJson); + + print('[GPU] NVIDIA GPU enabled: $isEnabled'); + return isEnabled; + } catch (e) { + print('[GPU] Error checking GPU setting: $e'); + } + return false; + } + /// Close database connection void close() { _db.dispose(); diff --git a/bin/server.dart b/bin/server.dart index 4c168d3..76f5b8f 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -181,7 +181,8 @@ void main(List args) async { final user = req.context['user'] as User?; if (user == null || !user.isAdmin) { return Response.forbidden( - jsonEncode({'error': 'Admin access required'})); + jsonEncode({'error': 'Admin access required'}), + ); } final result = await cleanupService.runCleanup(); @@ -231,8 +232,20 @@ 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, + isGpuEnabled: db.isNvidiaGpuEnabled, + ), + ) + ..mount( + '/api/vod', + createVodStreamHandler( + getPlaylist, + isGpuEnabled: db.isNvidiaGpuEnabled, + ), + ); // Secured Proxy Pipeline // We handle it outside the main router to preserve the '/api/xtream' prefix diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 46a1b7a..c402f00 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -12,45 +12,58 @@ class AppColors { // ============ BACKGROUNDS ============ /// Pure black for that infinite depth look (OLED friendly) - static const Color background = Color(0xFF000000); + static const Color background = Color(0xFF0F1014); // Deepest Blue-Black /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey) - static const Color surface = Color(0xFF1C1C1E); + static const Color surface = Color(0xFF181920); /// Slightly lighter grey for secondary surfaces or hover states - static const Color surfaceVariant = Color(0xFF3A3A3C); + static const Color surfaceVariant = Color(0xFF1E2028); + + static const Color surfaceGlass = + Color(0x1AFFFFFF); // 10% White for glass effect /// Focused element background (often white in tvOS for text, or bright accent) static const Color focusColor = Color(0xFFFFFFFF); + static const Color border = Color(0xFF1F1F1F); + static const Color focusBorder = 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 accent = Color(0xFFE5E5E5); + static const Color primary = Color(0xFF6C63FF); + static const Color secondary = Color(0xFF00B4D8); + static const Color accent = Color(0xFF00B4D8); - /// Primary Gradient (Subtle silver/white glow) + /// Primary Gradient (Electric Violet to Cyan) static const LinearGradient primaryGradient = LinearGradient( - colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)], + colors: [Color(0xFF6C63FF), Color(0xFF00B4D8)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); - // ============ TEXT ============ + static const LinearGradient backgroundGradient = LinearGradient( + colors: [Color(0xFF0F1014), Color(0xFF131418)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + // Status Colors + static const Color success = Color(0xFF00E676); + static const Color warning = Color(0xFFFFAB00); + static const Color error = Color(0xFFFF5252); + static const Color info = Color(0xFF2979FF); + + // Content Colors static const Color textPrimary = Color(0xFFFFFFFF); - static const Color textSecondary = Color(0xFF9E9E9E); // Medium Grey - static const Color textTertiary = Color(0xFF616161); // Darker Grey + static const Color textSecondary = Color(0xFFAAAAAA); + static const Color textTertiary = Color(0xFF666666); - // ============ FUNCTIONAL COLORS ============ - static const Color success = Color(0xFF4DB6AC); // Teal-ish - static const Color warning = Color(0xFFFFB74D); - static const Color error = Color(0xFFEF5350); - static const Color live = Color(0xFFFF3B30); // Apple Red + static const Color ratingGold = Color(0xFFFFD700); + static const Color premiumGold = Color(0xFFFFA000); - // ============ BORDERS ============ - // Borders are usually hidden until focused - static const Color border = Color(0xFF1F1F1F); - static const Color focusBorder = Color(0xFFFFFFFF); + // Category Colors + static const Color live = Color(0xFFFF5252); // Red + static const Color movies = Color(0xFF448AFF); // Blue + static const Color series = Color(0xFF69F0AE); // Green // ============ GLASSMORPHISM ============ // Apple TV uses this heavily for headers/overlays diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 2de9198..56646e8 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'app_colors.dart'; /// XtremFlow Apple TV Theme -/// +/// /// Focus-driven, immersive, minimal. class AppTheme { AppTheme._(); @@ -27,27 +27,66 @@ class AppTheme { // ============ ANIMATION ============ static const Duration durationFast = Duration(milliseconds: 200); static const Duration durationNormal = Duration(milliseconds: 300); - static const Curve curveDefault = Curves.fastOutSlowIn; // Apple-like snappy curve + static const Curve curveDefault = + Curves.fastOutSlowIn; // Apple-like snappy curve // ============ DARK THEME (TV Main) ============ static ThemeData get darkTheme { final baseTheme = ThemeData.dark(useMaterial3: true); - + return baseTheme.copyWith( colorScheme: AppColors.darkColorScheme, scaffoldBackgroundColor: AppColors.background, - - // Typography: San Francisco style (using Inter as proxy) + + // Typography: "Cinematic Modern" (Outfit for Headings, Inter for UI) textTheme: TextTheme( - displayLarge: GoogleFonts.inter(fontSize: 56, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: AppColors.textPrimary), - displayMedium: GoogleFonts.inter(fontSize: 48, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: AppColors.textPrimary), - displaySmall: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.textPrimary), - headlineMedium: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: AppColors.textPrimary), - titleLarge: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary), - titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), - bodyLarge: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w400, color: AppColors.textSecondary), - bodyMedium: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.textSecondary), - labelLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + displayLarge: GoogleFonts.outfit( + fontSize: 56, + fontWeight: FontWeight.w700, + letterSpacing: -1.0, + color: AppColors.textPrimary, + ), + displayMedium: GoogleFonts.outfit( + fontSize: 48, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: AppColors.textPrimary, + ), + displaySmall: GoogleFonts.outfit( + fontSize: 32, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + headlineMedium: GoogleFonts.outfit( + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleLarge: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleMedium: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + bodyLarge: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + bodyMedium: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + labelLarge: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), ), // AppBar: Transparent / Glass @@ -57,18 +96,21 @@ class AppTheme { elevation: 0, centerTitle: true, scrolledUnderElevation: 0, - titleTextStyle: GoogleFonts.inter( - fontSize: 17, + titleTextStyle: GoogleFonts.outfit( + fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), - + // Inputs: Dark, minimal, no borders unless focused inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: AppColors.surface, - contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16), + contentPadding: const EdgeInsets.symmetric( + horizontal: spacing24, + vertical: spacing16, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none, @@ -83,7 +125,7 @@ class AppTheme { ), hintStyle: GoogleFonts.inter(color: AppColors.textTertiary), ), - + // Buttons: White pill or minimal text filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( @@ -91,17 +133,24 @@ class AppTheme { foregroundColor: Colors.black, // Black text elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)), - textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + textStyle: + GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16), ).copyWith( overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.hovered)) return Colors.black.withOpacity(0.1); - if (states.contains(WidgetState.pressed)) return Colors.black.withOpacity(0.2); + if (states.contains(WidgetState.hovered)) { + return Colors.black.withOpacity(0.1); + } + if (states.contains(WidgetState.pressed)) { + return Colors.black.withOpacity(0.2); + } return null; }), ), ), - + // Cards: Transparent by default (content defines look) // cardTheme: const CardTheme( // color: AppColors.surface, @@ -124,8 +173,9 @@ class AppTheme { static const double radiusFull = 999.0; // Light theme stub - static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith( - colorScheme: AppColors.lightColorScheme, - textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), - ); + static ThemeData get lightTheme => + ThemeData.light(useMaterial3: true).copyWith( + colorScheme: AppColors.lightColorScheme, + textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), + ); } diff --git a/lib/core/widgets/glass_container.dart b/lib/core/widgets/glass_container.dart index 5714e78..e4bf1da 100644 --- a/lib/core/widgets/glass_container.dart +++ b/lib/core/widgets/glass_container.dart @@ -35,11 +35,10 @@ class GlassContainer extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: Container( padding: padding, decoration: BoxDecoration( - color: AppColors.surface.withOpacity(opacity), borderRadius: BorderRadius.circular(borderRadius), border: border ? Border.all( @@ -47,6 +46,21 @@ class GlassContainer extends StatelessWidget { width: 1, ) : null, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surface.withOpacity(opacity), + AppColors.surface.withOpacity(opacity * 0.5), + ], + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 20, + spreadRadius: -5, + ), + ], ), child: child, ), diff --git a/lib/core/widgets/hero_carousel.dart b/lib/core/widgets/hero_carousel.dart new file mode 100644 index 0000000..cd480a5 --- /dev/null +++ b/lib/core/widgets/hero_carousel.dart @@ -0,0 +1,310 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; +import 'glass_container.dart'; +import 'tv_focusable_card.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class HeroCarouselItem { + final String id; + final String title; + final String? subtitle; + final String? imageUrl; + final VoidCallback? onTap; + final VoidCallback? onPlay; + + HeroCarouselItem({ + required this.id, + required this.title, + this.subtitle, + this.imageUrl, + this.onTap, + this.onPlay, + }); +} + +class HeroCarousel extends StatefulWidget { + final List items; + final bool autoPlay; + + const HeroCarousel({ + super.key, + required this.items, + this.autoPlay = true, + }); + + @override + State createState() => _HeroCarouselState(); +} + +class _HeroCarouselState extends State { + late PageController _pageController; + int _currentIndex = 0; + Timer? _timer; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + if (widget.autoPlay) { + _startAutoPlay(); + } + } + + void _startAutoPlay() { + _timer = Timer.periodic(const Duration(seconds: 8), (timer) { + if (_currentIndex < widget.items.length - 1) { + _currentIndex++; + } else { + _currentIndex = 0; + } + if (_pageController.hasClients) { + _pageController.animateToPage( + _currentIndex, + duration: const Duration(milliseconds: 800), + curve: Curves.fastOutSlowIn, + ); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + _pageController.dispose(); + super.dispose(); + } + + String? _getProxiedUrl(String? url) { + if (url == null || url.isEmpty) return null; + if (url.startsWith('http')) { + return '/api/xtream/$url'; + } + return url; + } + + @override + Widget build(BuildContext context) { + if (widget.items.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 500, // Cinematic height + child: Stack( + children: [ + // Carousel + PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() => _currentIndex = index); + }, + itemCount: widget.items.length, + itemBuilder: (context, index) { + final item = widget.items[index]; + return _buildHeroSlide(item); + }, + ), + + // Indicators + Positioned( + bottom: 24, + right: 48, + child: Row( + children: List.generate( + widget.items.length, + (index) => AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: _currentIndex == index ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: _currentIndex == index + ? AppColors.primary + : Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildHeroSlide(HeroCarouselItem item) { + return Stack( + fit: StackFit.expand, + children: [ + // Background Image with Ken Burns Effect (Simulated via Scale in future, static for now) + if (item.imageUrl != null && item.imageUrl!.isNotEmpty) + Image.network( + _getProxiedUrl(item.imageUrl)!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: AppColors.surface), + ), + + // Gradient Overlays + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + AppColors.background.withOpacity(0.2), + AppColors.background.withOpacity(0.9), + AppColors.background, + ], + stops: const [0.0, 0.5, 0.8, 1.0], + ), + ), + ), + + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [ + Colors.transparent, + AppColors.background.withOpacity(0.8), + ], + ), + ), + ), + + // Content Info + Padding( + padding: const EdgeInsets.all(48.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category Tag + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.primary.withOpacity(0.5)), + ), + child: Text( + 'FEATURED', + style: GoogleFonts.inter( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 1.5, + ), + ), + ), + const SizedBox(height: 16), + + // Title + SizedBox( + width: 600, + child: Text( + item.title, + style: GoogleFonts.outfit( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.1, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (item.subtitle != null) ...[ + const SizedBox(height: 8), + Text( + item.subtitle!, + style: GoogleFonts.inter( + color: AppColors.textSecondary, + fontSize: 18, + ), + ), + ], + const SizedBox(height: 24), + + // Actions + Row( + children: [ + TvFocusableCard( + onTap: item.onPlay, + borderRadius: 50, + scaleFactor: 1.1, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(50), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.4), + blurRadius: 20, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + ), + const SizedBox(width: 8), + Text( + 'WATCH NOW', + style: GoogleFonts.inter( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 20), + TvFocusableCard( + onTap: item.onTap, // Add to favorites or details + borderRadius: 50, + child: GlassContainer( + borderRadius: 50, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.info_outline, color: Colors.white), + const SizedBox(width: 8), + Text( + 'DETAILS', + style: GoogleFonts.inter( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 48), // Bottom padding + ], + ), + ), + ], + ); + } +} diff --git a/lib/core/widgets/themed_loading_screen.dart b/lib/core/widgets/themed_loading_screen.dart index e1db1dd..8665788 100644 --- a/lib/core/widgets/themed_loading_screen.dart +++ b/lib/core/widgets/themed_loading_screen.dart @@ -7,20 +7,33 @@ class ThemedLoading extends StatelessWidget { Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( - gradient: RadialGradient( - center: Alignment(0, -0.2), - radius: 1.5, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, colors: [ - Color(0xFF2C2C2E), - Color(0xFF000000), + Color(0xFF0F1014), // Deep Space Dark + Color(0xFF181920), // Soft Eerie Black ], - stops: [0.0, 1.0], ), ), - child: const Center( - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 3, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + color: Color(0xFF007AFF), // Electric Blue (AppColors.primary) + strokeWidth: 3, + ), + const SizedBox(height: 16), + Text( + 'Loading...', + style: TextStyle( + fontFamily: 'Outfit', // Match AppTheme + color: Colors.white.withOpacity(0.7), + fontSize: 14, + ), + ), + ], ), ), ); diff --git a/lib/core/widgets/tv_focusable_card.dart b/lib/core/widgets/tv_focusable_card.dart index 9a23ce5..5af0447 100644 --- a/lib/core/widgets/tv_focusable_card.dart +++ b/lib/core/widgets/tv_focusable_card.dart @@ -64,50 +64,75 @@ class _TvFocusableCardState extends State } } + bool _isHovered = false; + + void _handleHover(bool isHovered) { + setState(() { + _isHovered = isHovered; + }); + if (isHovered || _isFocused) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) { - return Transform.scale( - scale: _scaleAnimation.value, - child: child, - ); - }, - child: InkWell( - onTap: widget.onTap, - onFocusChange: _handleFocusChange, - autofocus: widget.autofocus, - borderRadius: BorderRadius.circular(widget.borderRadius), - focusColor: Colors.transparent, // We handle visual focus manually - hoverColor: Colors.transparent, - highlightColor: Colors.transparent, - splashColor: Colors.transparent, - child: Container( - decoration: BoxDecoration( + final isActive = _isFocused || _isHovered; + + return MouseRegion( + onEnter: (_) => _handleHover(true), + onExit: (_) => _handleHover(false), + cursor: SystemMouseCursors.click, + child: AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: child, + ); + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap, + onFocusChange: _handleFocusChange, + autofocus: widget.autofocus, borderRadius: BorderRadius.circular(widget.borderRadius), - border: _isFocused - ? Border.all( - color: widget.focusColor ?? AppColors.focusBorder, - width: 3, - ) - : Border.all(color: Colors.transparent, width: 3), - boxShadow: _isFocused - ? [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - BoxShadow( - color: (widget.focusColor ?? AppColors.focusBorder).withOpacity(0.2), - blurRadius: 10, - spreadRadius: 1, - ), - ] - : [], + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + border: isActive + ? Border.all( + color: widget.focusColor ?? AppColors.focusBorder, + width: 2, // Slightly thinner for cleaner look + ) + : Border.all(color: Colors.transparent, width: 2), + boxShadow: isActive + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 25, + offset: const Offset(0, 10), + spreadRadius: 2, + ), + BoxShadow( + color: (widget.focusColor ?? AppColors.focusBorder) + .withOpacity(0.3), + blurRadius: 15, + spreadRadius: 1, + ), + ] + : [], + ), + child: widget.child, + ), ), - child: widget.child, ), ), ); diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index d055984..d35f15d 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -1,46 +1,47 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/services/settings_api_service.dart'; import '../../auth/providers/auth_provider.dart'; /// Streaming quality presets for Live TV enum StreamQuality { - low, // 1.5 Mbps, CRF 26 + low, // 1.5 Mbps, CRF 26 medium, // 3 Mbps, CRF 23 - high, // 5 Mbps, CRF 20 + high, // 5 Mbps, CRF 20 } /// Buffer size presets for Live TV enum BufferSize { - low, // 2s segments, 4MB buffer + low, // 2s segments, 4MB buffer medium, // 4s segments, 8MB buffer - high, // 6s segments, 12MB buffer + high, // 6s segments, 12MB buffer } /// Connection timeout presets enum ConnectionTimeout { - short, // 15 seconds + short, // 15 seconds medium, // 30 seconds - long, // 60 seconds + long, // 60 seconds } /// EPG cache duration presets enum EpgCacheDuration { - short, // 5 minutes + short, // 5 minutes medium, // 15 minutes - long, // 60 minutes + long, // 60 minutes } /// Transcoding mode for Live TV enum TranscodingMode { - auto, // Auto-detect best mode - forced, // Always transcode + auto, // Auto-detect best mode + forced, // Always transcode disabled, // Direct stream (no transcoding) } /// Player Type Preference enum PlayerType { standard, // Heavily customized HTML5 player (default) - lite, // Lightweight native/video_player based + lite, // Lightweight native/video_player based } /// Keys for API JSON storage @@ -49,7 +50,7 @@ class _SettingsKeys { static const String liveTvFilter = 'filter_live_tv'; static const String moviesFilter = 'filter_movies'; static const String seriesFilter = 'filter_series'; - + // Streaming settings static const String streamQuality = 'stream_quality'; static const String bufferSize = 'buffer_size'; @@ -62,6 +63,8 @@ class _SettingsKeys { static const String showClock = 'show_clock'; static const String preferredAspectRatio = 'aspect_ratio'; static const String playerType = 'player_type'; + // Server/GPU Settings + static const String enableNvidiaGpu = 'enable_nvidia_gpu'; } /// Settings state for IPTV preferences with persistence @@ -79,12 +82,14 @@ class IptvSettings { final EpgCacheDuration epgCacheDuration; final TranscodingMode transcodingMode; final bool preferDirectPlay; - + // Player Display Settings final bool showClock; final String preferredAspectRatio; final PlayerType playerType; - + + // Server/GPU Settings + final bool enableNvidiaGpu; const IptvSettings({ // Filters @@ -103,6 +108,8 @@ class IptvSettings { this.showClock = false, this.preferredAspectRatio = 'contain', this.playerType = PlayerType.standard, + // Server/GPU defaults + this.enableNvidiaGpu = false, }); IptvSettings copyWith({ @@ -119,6 +126,7 @@ class IptvSettings { bool? showClock, String? preferredAspectRatio, PlayerType? playerType, + bool? enableNvidiaGpu, }) { return IptvSettings( liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, @@ -134,6 +142,7 @@ class IptvSettings { showClock: showClock ?? this.showClock, preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio, playerType: playerType ?? this.playerType, + enableNvidiaGpu: enableNvidiaGpu ?? this.enableNvidiaGpu, ); } @@ -142,63 +151,84 @@ class IptvSettings { /// Get bitrate in kbps based on quality setting int get bitrateKbps { switch (streamQuality) { - case StreamQuality.low: return 1500; - case StreamQuality.medium: return 3000; - case StreamQuality.high: return 5000; + case StreamQuality.low: + return 1500; + case StreamQuality.medium: + return 3000; + case StreamQuality.high: + return 5000; } } /// Get CRF value based on quality setting int get crfValue { switch (streamQuality) { - case StreamQuality.low: return 26; - case StreamQuality.medium: return 23; - case StreamQuality.high: return 20; + case StreamQuality.low: + return 26; + case StreamQuality.medium: + return 23; + case StreamQuality.high: + return 20; } } /// Get HLS segment duration in seconds int get hlsSegmentDuration { switch (bufferSize) { - case BufferSize.low: return 2; - case BufferSize.medium: return 4; - case BufferSize.high: return 6; + case BufferSize.low: + return 2; + case BufferSize.medium: + return 4; + case BufferSize.high: + return 6; } } /// Get buffer size in KB int get bufferSizeKb { switch (bufferSize) { - case BufferSize.low: return 4000; - case BufferSize.medium: return 8000; - case BufferSize.high: return 12000; + case BufferSize.low: + return 4000; + case BufferSize.medium: + return 8000; + case BufferSize.high: + return 12000; } } /// Get connection timeout in seconds int get timeoutSeconds { switch (connectionTimeout) { - case ConnectionTimeout.short: return 15; - case ConnectionTimeout.medium: return 30; - case ConnectionTimeout.long: return 60; + case ConnectionTimeout.short: + return 15; + case ConnectionTimeout.medium: + return 30; + case ConnectionTimeout.long: + return 60; } } /// Get EPG cache duration in minutes int get epgCacheMinutes { switch (epgCacheDuration) { - case EpgCacheDuration.short: return 5; - case EpgCacheDuration.medium: return 15; - case EpgCacheDuration.long: return 60; + case EpgCacheDuration.short: + return 5; + case EpgCacheDuration.medium: + return 15; + case EpgCacheDuration.long: + return 60; } } /// Get transcoding mode string for FFmpeg URL parameter String get modeString { switch (transcodingMode) { - case TranscodingMode.disabled: return 'direct'; // Passthrough, 0% CPU - case TranscodingMode.forced: return 'transcode'; - case TranscodingMode.auto: return 'auto'; + case TranscodingMode.disabled: + return 'direct'; // Passthrough, 0% CPU + case TranscodingMode.forced: + return 'transcode'; + case TranscodingMode.auto: + return 'auto'; } } @@ -282,31 +312,54 @@ class IptvSettingsNotifier extends StateNotifier { liveTvCategoryFilter: getValue(_SettingsKeys.liveTvFilter), moviesCategoryFilter: getValue(_SettingsKeys.moviesFilter), seriesCategoryFilter: getValue(_SettingsKeys.seriesFilter), - - streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null - ? StreamQuality.values[remoteSettings[_SettingsKeys.streamQuality] as int] : null, + streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null + ? StreamQuality + .values[remoteSettings[_SettingsKeys.streamQuality] as int] + : null, bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null - ? BufferSize.values[remoteSettings[_SettingsKeys.bufferSize] as int] : null, - connectionTimeout: remoteSettings[_SettingsKeys.connectionTimeout] != null - ? ConnectionTimeout.values[remoteSettings[_SettingsKeys.connectionTimeout] as int] : null, + ? BufferSize + .values[remoteSettings[_SettingsKeys.bufferSize] as int] + : null, + connectionTimeout: + remoteSettings[_SettingsKeys.connectionTimeout] != null + ? ConnectionTimeout.values[ + remoteSettings[_SettingsKeys.connectionTimeout] as int] + : null, autoReconnect: getValue(_SettingsKeys.autoReconnect), - epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != null - ? EpgCacheDuration.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] : null, + epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != + null + ? EpgCacheDuration + .values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] + : null, transcodingMode: remoteSettings[_SettingsKeys.transcodingMode] != null - ? TranscodingMode.values[remoteSettings[_SettingsKeys.transcodingMode] as int] : null, + ? TranscodingMode + .values[remoteSettings[_SettingsKeys.transcodingMode] as int] + : null, preferDirectPlay: getValue(_SettingsKeys.preferDirectPlay), showClock: getValue(_SettingsKeys.showClock), - preferredAspectRatio: getValue(_SettingsKeys.preferredAspectRatio), + preferredAspectRatio: + getValue(_SettingsKeys.preferredAspectRatio), playerType: remoteSettings[_SettingsKeys.playerType] != null - ? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null, + ? PlayerType + .values[remoteSettings[_SettingsKeys.playerType] as int] + : null, + enableNvidiaGpu: getValue(_SettingsKeys.enableNvidiaGpu), ); } _initialized = true; } catch (e) { - print('Error syncing settings from API: $e'); + print('Error syncing settings from API: $e'); } } + Timer? _saveDebounceTimer; + + @override + void dispose() { + _saveDebounceTimer?.cancel(); + super.dispose(); + } + Future _saveToApi() async { final authState = _ref.read(authProvider); if (!authState.isAuthenticated) { @@ -314,27 +367,33 @@ class IptvSettingsNotifier extends StateNotifier { return; } - // Create map from state - final settingsMap = { - _SettingsKeys.liveTvFilter: state.liveTvCategoryFilter, - _SettingsKeys.moviesFilter: state.moviesCategoryFilter, - _SettingsKeys.seriesFilter: state.seriesCategoryFilter, - _SettingsKeys.streamQuality: state.streamQuality.index, - _SettingsKeys.bufferSize: state.bufferSize.index, - _SettingsKeys.connectionTimeout: state.connectionTimeout.index, - _SettingsKeys.autoReconnect: state.autoReconnect, - _SettingsKeys.epgCacheDuration: state.epgCacheDuration.index, - _SettingsKeys.transcodingMode: state.transcodingMode.index, - _SettingsKeys.preferDirectPlay: state.preferDirectPlay, - _SettingsKeys.showClock: state.showClock, - _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, - _SettingsKeys.playerType: state.playerType.index, - _SettingsKeys.playerType: state.playerType.index, - }; + // Cancel pending save + _saveDebounceTimer?.cancel(); - print('[SettingsProvider] _saveToApi: Saving settings...'); - final success = await _apiService.saveSettings(settingsMap); - print('[SettingsProvider] _saveToApi: Save result: $success'); + // Debounce for 1 second to avoid rapid API calls + _saveDebounceTimer = Timer(const Duration(seconds: 1), () async { + // Create map from state + final settingsMap = { + _SettingsKeys.liveTvFilter: state.liveTvCategoryFilter, + _SettingsKeys.moviesFilter: state.moviesCategoryFilter, + _SettingsKeys.seriesFilter: state.seriesCategoryFilter, + _SettingsKeys.streamQuality: state.streamQuality.index, + _SettingsKeys.bufferSize: state.bufferSize.index, + _SettingsKeys.connectionTimeout: state.connectionTimeout.index, + _SettingsKeys.autoReconnect: state.autoReconnect, + _SettingsKeys.epgCacheDuration: state.epgCacheDuration.index, + _SettingsKeys.transcodingMode: state.transcodingMode.index, + _SettingsKeys.preferDirectPlay: state.preferDirectPlay, + _SettingsKeys.showClock: state.showClock, + _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, + _SettingsKeys.playerType: state.playerType.index, + _SettingsKeys.enableNvidiaGpu: state.enableNvidiaGpu, + }; + + print('[SettingsProvider] _saveToApi: Saving settings...'); + final success = await _apiService.saveSettings(settingsMap); + print('[SettingsProvider] _saveToApi: Save result: $success'); + }); } // ===== Setters with Auto-Save ===== @@ -421,6 +480,13 @@ class IptvSettingsNotifier extends StateNotifier { _saveToApi(); } + // ===== Server/GPU Setters ===== + + void setEnableNvidiaGpu(bool value) { + state = state.copyWith(enableNvidiaGpu: value); + _saveToApi(); + } + // Legacy methods void setCategoryFilter(String filter) => setLiveTvFilter(filter); void clearCategoryFilter() => clearLiveTvFilter(); diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 4480dd3..371cf81 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -1,12 +1,10 @@ +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; -import '../../../core/widgets/digital_clock.dart'; import '../widgets/live_tv_tab.dart'; import '../widgets/movies_tab.dart'; import '../widgets/series_tab.dart'; @@ -41,113 +39,154 @@ class _DashboardScreenState extends ConsumerState { body: Stack( fit: StackFit.expand, children: [ - // 1. Global Atmospheric Background + // 1. Cinematic Deep Space Background Positioned.fill( child: Container( decoration: const BoxDecoration( - gradient: RadialGradient( - center: Alignment(0, -0.4), - radius: 1.3, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, colors: [ - Color(0xFF252525), // Center light - Color(0xFF000000), // Vignet + Color(0xFF0F1014), // Deep Space Blue-Grey + Color(0xFF181920), // Darker shade ], - stops: [0.0, 1.0], ), ), ), ), - + + // Ambient Glow (Subtle accent) + Positioned( + top: -100, + left: -100, + child: Container( + width: 500, + height: 500, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withOpacity(0.2), + Colors.transparent, + ], + ), + ), + ), + ), + // 2. Main Content Area Positioned.fill( - top: 0, // Content goes behind header + left: 100, // Leave space for sidebar child: IndexedStack( index: _selectedIndex, children: [ - // Add padding top internally in tabs or here - Padding(padding: const EdgeInsets.only(top: 100), child: LiveTVTab(playlist: widget.playlist)), - Padding(padding: const EdgeInsets.only(top: 100), child: MoviesTab(playlist: widget.playlist)), - Padding(padding: const EdgeInsets.only(top: 100), child: SeriesTab(playlist: widget.playlist)), - const Padding(padding: EdgeInsets.only(top: 100), child: SettingsTab()), + LiveTVTab(playlist: widget.playlist), + MoviesTab(playlist: widget.playlist), + SeriesTab(playlist: widget.playlist), + const SettingsTab(), ], ), ), - // 3. Floating Glass Header + // 3. Vertical Glass Sidebar Positioned( - top: 24, // Floating margin left: 24, - right: 24, + top: 24, + bottom: 24, + width: 80, child: GlassContainer( - height: 72, - borderRadius: 100, // Pill shape - opacity: 0.85, - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( + borderRadius: 24, + opacity: 0.1, + // blur is hardcoded in GlassContainer (15), so we don't pass it + // border is bool, borderColor allows customization + border: true, + borderColor: Colors.white.withOpacity(0.1), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Logo - _buildLogo(), - + // Logo Icon + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.4), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 28, + ), + ), + ), + const Spacer(), - - // Navigation Pills - Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(_tabs.length, (index) { - final isSelected = _selectedIndex == index; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: TvFocusableCard( - onTap: () => setState(() => _selectedIndex = index), - scaleFactor: 1.1, - borderRadius: 100, - focusColor: Colors.white, - child: AnimatedContainer( - duration: AppTheme.durationFast, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - decoration: BoxDecoration( - color: isSelected ? Colors.white : Colors.transparent, - borderRadius: BorderRadius.circular(100), - ), - child: Row( - children: [ - Icon( - _icons[index], - size: 18, - color: isSelected ? Colors.black : AppColors.textSecondary, - ), - const SizedBox(width: 8), - Text( - _tabs[index], - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, - color: isSelected ? Colors.black : AppColors.textSecondary, - ), - ), - ], - ), + + // Navigation Icons + ...List.generate(_tabs.length, (index) { + final isSelected = _selectedIndex == index; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: TvFocusableCard( + onTap: () => setState(() => _selectedIndex = index), + scaleFactor: 1.2, + borderRadius: 16, + focusColor: AppColors.primary, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary.withOpacity(0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: isSelected + ? Border.all( + color: AppColors.primary.withOpacity(0.5), + ) + : null, + ), + child: Icon( + _icons[index], + color: isSelected + ? Colors.white + : AppColors.textSecondary, + size: 24, ), ), - ); - }), - ), - - const Spacer(), - - // Tools - Row( - children: [ - _buildIconButton(Icons.search_rounded, () {}), - const SizedBox(width: 16), - const DigitalClock(), // Kept existing widget - const SizedBox(width: 16), - CircleAvatar( - radius: 18, - backgroundColor: Colors.white.withOpacity(0.1), - child: const Icon(Icons.person_rounded, size: 20, color: Colors.white), ), - ], + ); + }), + + const Spacer(), + + // Settings / Profile + Padding( + padding: const EdgeInsets.only(bottom: 24), + child: TvFocusableCard( + onTap: () {}, + borderRadius: 50, + scaleFactor: 1.1, + child: CircleAvatar( + radius: 20, + backgroundColor: Colors.white.withOpacity(0.1), + child: const Icon( + Icons.person, + color: Colors.white70, + size: 20, + ), + ), + ), ), ], ), @@ -157,47 +196,4 @@ class _DashboardScreenState extends ConsumerState { ), ); } - - Widget _buildIconButton(IconData icon, VoidCallback onTap) { - return TvFocusableCard( - onTap: onTap, - borderRadius: 50, - scaleFactor: 1.15, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon(icon, color: Colors.white, size: 20), - ), - ); - } - - Widget _buildLogo() { - return Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Colors.white, Color(0xFFAAAAAA)]), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.play_arrow_rounded, color: Colors.black, size: 24), - ), - const SizedBox(width: 12), - Text( - 'XtremFlow', - style: GoogleFonts.inter( - fontWeight: FontWeight.w700, - fontSize: 18, - color: Colors.white, - letterSpacing: -0.5, - ), - ), - ], - ); - } } diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 5c42948..bc997c6 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -62,6 +62,7 @@ class _PlayerScreenState extends ConsumerState { String _statusMessage = 'Loading...'; String? _errorMessage; bool _isMuted = false; + bool _ignoreStatusUpdates = false; @override void initState() { @@ -209,7 +210,9 @@ class _PlayerScreenState extends ConsumerState { ); } } else if (type == 'playback_status') { - setState(() => _isPlaying = data['status'] == 'playing'); + if (!_ignoreStatusUpdates) { + setState(() => _isPlaying = data['status'] == 'playing'); + } } else if (type == 'user_activity') { _onHover(); } @@ -217,10 +220,31 @@ class _PlayerScreenState extends ConsumerState { } void _sendMessage(Map message) { - if (_isInitialized) { - final iframe = - html.document.getElementById(_viewId) as html.IFrameElement?; - iframe?.contentWindow?.postMessage(message, '*'); + if (!_isInitialized) return; + + // Try finding by ID first + var iframe = html.document.getElementById(_viewId) as html.IFrameElement?; + + // If not found, try finding by src pattern (fallback for Shadow DOM or ID issues) + if (iframe == null) { + final iframes = html.document.getElementsByTagName('iframe'); + for (final frame in iframes) { + if (frame is html.IFrameElement && + (frame.src?.contains('player_lite.html') == true || + frame.src?.contains('player.html') == true)) { + iframe = frame; + break; + } + } + } + + if (iframe != null) { + print('[PlayerScreen] Sending message to iframe: $message'); + iframe.contentWindow?.postMessage(message, '*'); + } else { + print( + '[PlayerScreen] Error: Could not find iframe to send message: $message', + ); } } @@ -233,10 +257,19 @@ class _PlayerScreenState extends ConsumerState { } void _togglePlayPause() { + // Optimistic update to make UI responsive immediately + setState(() => _isPlaying = !_isPlaying); + + // Ignore incoming status updates for 2 seconds to prevent fighting + _ignoreStatusUpdates = true; + Future.delayed(const Duration(seconds: 2), () { + if (mounted) _ignoreStatusUpdates = false; + }); + if (_isPlaying) { - _sendMessage({'type': 'pause'}); - } else { _sendMessage({'type': 'play'}); + } else { + _sendMessage({'type': 'pause'}); } } @@ -289,178 +322,208 @@ class _PlayerScreenState extends ConsumerState { @override Widget build(BuildContext context) { - // Use stream type to determine player UI: - // - Live TV: Lite player UI (minimal overlay) - // - VOD/Series: Standard player UI (full controls) final isLiveTV = widget.streamType == StreamType.live; - // LITE PLAYER MODE for Live TV: Simple iframe with native HTML5 controls, minimal Flutter overlay + // LITE PLAYER MODE for Live TV - Simplified UI (no BackdropFilter) if (isLiveTV) { return Scaffold( backgroundColor: Colors.black, - body: MouseRegion( - onHover: (_) => _onHover(), - child: GestureDetector( - onTap: () { - if (_showControls) { - setState(() => _showControls = false); - } else { - _onHover(); - } - }, - child: Stack( - fit: StackFit.expand, - children: [ - // Video Player (iframe with native controls) - if (_isInitialized) HtmlElementView(viewType: _viewId), + body: Stack( + fit: StackFit.expand, + children: [ + // Video Player + if (_isInitialized) HtmlElementView(viewType: _viewId), - // Top Bar: Back Button + Title (with auto-hide) - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - top: _showControls ? 24 : -80, - left: 24, - right: 24, - child: PointerInterceptor( - child: Row( - children: [ - Material( - color: Colors.black54, - shape: const CircleBorder(), - child: InkWell( - onTap: () => Navigator.pop(context), - customBorder: const CircleBorder(), - child: const Padding( - padding: EdgeInsets.all(12), - child: Icon( - Icons.arrow_back_rounded, - color: Colors.white, - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - widget.channels != null - ? widget.channels![_currentIndex].name - : widget.title, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - shadows: [ - Shadow(blurRadius: 4, color: Colors.black), - ], - ), - overflow: TextOverflow.ellipsis, - ), - ), - // Mute button for Live TV - Material( - color: Colors.black54, - shape: const CircleBorder(), - child: InkWell( - onTap: _toggleMute, - customBorder: const CircleBorder(), - child: Padding( - padding: const EdgeInsets.all(12), - child: Icon( - _isMuted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ), - - // Channel Zapping Controls (with auto-hide) - if (widget.channels != null && widget.channels!.length > 1) - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - bottom: 160, - right: _showControls ? 24 : -80, - child: PointerInterceptor( - child: Column( - mainAxisSize: MainAxisSize.min, + // Control Overlay (sits on top of video, catches all pointer events) + Positioned.fill( + child: PointerInterceptor( + child: MouseRegion( + onHover: (_) => _onHover(), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + if (_showControls) { + setState(() => _showControls = false); + } else { + _onHover(); + } + }, + child: Container( + color: Colors.transparent, + child: Stack( children: [ - // Previous Channel - Material( - color: Colors.black54, - shape: const CircleBorder(), - child: InkWell( - onTap: _previousChannel, - customBorder: const CircleBorder(), - child: const Padding( - padding: EdgeInsets.all(14), - child: Icon( - Icons.keyboard_arrow_up, - color: Colors.white, - size: 28, + // Top Bar + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + top: _showControls ? 0 : -100, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + child: SafeArea( + bottom: false, + child: Row( + children: [ + _buildSimpleIconButton( + icon: Icons.arrow_back_rounded, + onTap: () => Navigator.pop(context), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.channels != null + ? widget + .channels![_currentIndex] + .name + : widget.title, + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + 'Live TV', + style: GoogleFonts.inter( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + // Top right mute button removed + ], ), ), ), ), - const SizedBox(height: 12), - // Next Channel - Material( - color: Colors.black54, - shape: const CircleBorder(), - child: InkWell( - onTap: _nextChannel, - customBorder: const CircleBorder(), - child: const Padding( - padding: EdgeInsets.all(14), - child: Icon( - Icons.keyboard_arrow_down, - color: Colors.white, - size: 28, + + // Unified Bottom Bar - EPG + Controls in one bar + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + bottom: _showControls ? 24 : -100, + left: 24, + right: 24, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + decoration: BoxDecoration( + color: const Color(0xFF1A1A2E).withOpacity(0.9), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withOpacity(0.1), ), ), + child: Row( + children: [ + // EPG Info (left side) + Expanded( + child: _buildInlineEpg(), + ), + + const SizedBox(width: 24), + + // Control Buttons (right side) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Previous Channel + if (widget.channels != null && + widget.channels!.length > 1) + _buildSimpleIconButton( + icon: Icons.skip_previous_rounded, + onTap: _previousChannel, + size: 44, + ), + + if (widget.channels != null && + widget.channels!.length > 1) + const SizedBox(width: 16), + + // Play/Pause (Large) + _buildSimpleIconButton( + icon: _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlayPause, + size: 52, + iconSize: 28, + highlighted: true, + ), + + if (widget.channels != null && + widget.channels!.length > 1) + const SizedBox(width: 16), + + // Next Channel + if (widget.channels != null && + widget.channels!.length > 1) + _buildSimpleIconButton( + icon: Icons.skip_next_rounded, + onTap: _nextChannel, + size: 44, + ), + + const SizedBox(width: 16), + + // Mute button + _buildSimpleIconButton( + icon: _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + onTap: _toggleMute, + size: 44, + ), + ], + ), + ], + ), ), ), ], ), ), ), - - // EPG Overlay (for Live TV, with auto-hide, higher position) - if (widget.streamType == StreamType.live) - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - bottom: _showControls ? 60 : -120, - left: 0, - right: 0, - child: PointerInterceptor( - child: EpgOverlay( - playlist: widget.playlist, - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId - : widget.streamId, - ), - ), - ), - ], + ), + ), ), - ), + ], ), ); } - // STANDARD PLAYER MODE (Custom Flutter Overlay) + // STANDARD PLAYER MODE (VOD/Series) return Scaffold( backgroundColor: Colors.black, body: Stack( fit: StackFit.expand, children: [ - // 1. Video Player Layer if (_isInitialized) HtmlElementView(viewType: _viewId), - // 2. Interaction Layer + // User interaction area Positioned.fill( child: PointerInterceptor( child: MouseRegion( @@ -470,7 +533,7 @@ class _PlayerScreenState extends ConsumerState { if (_showControls) { setState(() => _showControls = false); } else { - _onHover(); // Shows controls and starts timer + _onHover(); } }, behavior: HitTestBehavior.translucent, @@ -480,257 +543,182 @@ class _PlayerScreenState extends ConsumerState { ), ), - // 3. UI Overlay (Glassmorphism) + // Controls Overlay if (_showControls || _isLoading) Positioned.fill( child: PointerInterceptor( child: Container( - color: Colors.black.withOpacity(0.3), // Dim background + color: Colors.black.withOpacity(0.4), child: Stack( children: [ - // Top Bar (Back + Title) + // Top Bar 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, - ), + child: GlassContainer( + height: 72, + borderRadius: 24, + opacity: 0.1, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _buildGlassIconButton( + icon: Icons.arrow_back_rounded, + onTap: () => Navigator.pop(context), ), - ), - 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, + const SizedBox(width: 16), + Expanded( + child: Text( + widget.title, + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w600, 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, - ), - ), - ), - ), - - // 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, + // Center Loading Indicator (only show when loading) + if (_isLoading) + const Center( + child: CircularProgressIndicator( + color: AppColors.primary, ), ), - // Bottom Control Bar + // Bottom Controls Positioned( bottom: 40, left: 40, right: 40, child: GlassContainer( borderRadius: 24, - opacity: 0.8, + opacity: 0.2, // Slightly more opaque for controls + border: true, + borderColor: Colors.white.withOpacity(0.1), 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, - ), + // Progress + Row( + children: [ + Text( + _formatDuration( + Duration( + seconds: _currentPosition.toInt(), ), - 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, - ), - ), - ], + ), + style: GoogleFonts.inter( + color: Colors.white70, + ), ), - ), + const SizedBox(width: 16), + Expanded( + child: SliderTheme( + data: SliderThemeData( + trackHeight: 4, + activeTrackColor: AppColors.primary, + inactiveTrackColor: + Colors.white.withOpacity(0.2), + thumbColor: Colors.white, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + ), + overlayColor: + AppColors.primary.withOpacity(0.2), + ), + child: Slider( + value: _currentPosition, + min: 0, + max: _totalDuration, + onChanged: (val) => setState( + () => _currentPosition = val, + ), + onChangeStart: (_) => + setState(() => _isSeeking = true), + onChangeEnd: (val) { + _sendMessage( + {'type': 'seek', 'value': val}, + ); + Future.delayed( + const Duration(milliseconds: 500), + () { + if (mounted) { + setState( + () => _isSeeking = false, + ); + } + }); + }, + ), + ), + ), + const SizedBox(width: 16), + Text( + _formatDuration( + Duration( + seconds: _totalDuration.toInt(), + ), + ), + style: GoogleFonts.inter( + color: Colors.white70, + ), + ), + ], + ), + const SizedBox(height: 12), - // Controls Row + // Buttons 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, - ), - ], - + _buildGlassIconButton( + icon: Icons.replay_10_rounded, + onTap: () => _sendMessage({ + 'type': 'seek', + 'value': (_currentPosition - 10) + .clamp(0, _totalDuration), + }), + transparent: true, + ), + const SizedBox(width: 24), + _buildGlassIconButton( + icon: _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlayPause, + size: 56, + iconSize: 32, + ), + const SizedBox(width: 24), + _buildGlassIconButton( + icon: Icons.forward_10_rounded, + onTap: () => _sendMessage({ + 'type': 'seek', + 'value': (_currentPosition + 10) + .clamp(0, _totalDuration), + }), + transparent: true, + ), const Spacer(), - // Volume/Mute button - _buildTvControl( - _isMuted + _buildGlassIconButton( + icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, - _toggleMute, + onTap: _toggleMute, + transparent: true, ), const SizedBox(width: 16), - _buildTvControl( - Icons.subtitles_rounded, - () {}, - ), - const SizedBox(width: 16), - _buildTvControl( - Icons.aspect_ratio_rounded, - _toggleFullscreen, + _buildGlassIconButton( + icon: Icons.aspect_ratio_rounded, + onTap: _toggleFullscreen, + transparent: true, ), ], ), @@ -748,18 +736,241 @@ class _PlayerScreenState extends ConsumerState { ); } - Widget _buildTvControl(IconData icon, VoidCallback onTap) { - return TvFocusableCard( - onTap: onTap, - borderRadius: 12, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + Widget _buildGlassIconButton({ + required IconData icon, + required VoidCallback onTap, + bool transparent = false, + double size = 48, + double iconSize = 24, + }) { + // Button with direct Material/InkWell - PointerInterceptor is on outer container + return Material( + color: transparent ? Colors.transparent : Colors.white.withOpacity(0.1), + shape: const CircleBorder(), + child: InkWell( + onTap: () { + print('[PlayerScreen] Button tapped: $icon'); + onTap(); + }, + customBorder: const CircleBorder(), + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: transparent + ? null + : Border.all(color: Colors.white.withOpacity(0.1)), + ), + child: Icon(icon, color: Colors.white, size: iconSize), ), - child: Icon(icon, color: Colors.white, size: 24), ), ); } + + // Simple Icon Button for Live TV player (no BackdropFilter issues) + Widget _buildSimpleIconButton({ + required IconData icon, + required VoidCallback onTap, + double size = 48, + double iconSize = 24, + bool highlighted = false, + }) { + return Material( + color: highlighted + ? AppColors.primary.withOpacity(0.2) + : Colors.white.withOpacity(0.1), + shape: const CircleBorder(), + child: InkWell( + onTap: () { + print('[PlayerScreen] Simple button tapped: $icon'); + onTap(); + }, + customBorder: const CircleBorder(), + splashColor: AppColors.primary.withOpacity(0.3), + highlightColor: AppColors.primary.withOpacity(0.1), + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: highlighted + ? AppColors.primary.withOpacity(0.5) + : Colors.white.withOpacity(0.2), + width: highlighted ? 2 : 1, + ), + ), + child: Icon( + icon, + color: highlighted ? AppColors.primary : Colors.white, + size: iconSize, + ), + ), + ), + ); + } + + /// Build inline EPG info for the unified control bar + Widget _buildInlineEpg() { + final currentStreamId = widget.channels != null + ? widget.channels![_currentIndex].streamId + : widget.streamId; + + return Consumer( + builder: (context, ref, child) { + final epgAsync = ref.watch( + epgByPlaylistProvider( + EpgRequestKey(playlist: widget.playlist, streamId: currentStreamId), + ), + ); + + return epgAsync.when( + data: (epgEntries) { + if (epgEntries.isEmpty) { + // No EPG data - show channel name only + return Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.channels != null + ? widget.channels![_currentIndex].name + : widget.title, + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + // Get current program + final now = DateTime.now(); + final currentProgram = epgEntries.firstWhere( + (entry) { + final start = DateTime.parse(entry.start); + final end = DateTime.parse(entry.end); + return now.isAfter(start) && now.isBefore(end); + }, + orElse: () => epgEntries.first, + ); + + return Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + currentProgram.title, + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (currentProgram.description.isNotEmpty) + Text( + currentProgram.description, + style: GoogleFonts.inter( + color: Colors.white60, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + }, + loading: () => Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Text( + widget.channels != null + ? widget.channels![_currentIndex].name + : widget.title, + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + error: (_, __) => Text( + widget.channels != null + ? widget.channels![_currentIndex].name + : widget.title, + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ); + } } diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 608d76d..eea75b3 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../core/services/playlist_api_service.dart'; import '../../../core/models/playlist_config.dart'; +import '../../../core/theme/app_colors.dart'; import '../../auth/providers/auth_provider.dart'; /// Provider for fetching playlists from API @@ -55,27 +56,29 @@ class PlaylistSelectionScreen extends ConsumerWidget { ), body: Container( decoration: const BoxDecoration( - gradient: RadialGradient( - center: Alignment.center, - radius: 1.5, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, colors: [ - Color(0xFF2C2C2E), // Dark Grey (Apple TV Surface) - Color(0xFF000000), // Pure Black + Color(0xFF0F1014), // Deep Space Dark + Color(0xFF181920), // Soft Eerie Black ], - stops: [0.0, 1.0], ), ), child: playlistsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ), error: (error, stack) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), + const Icon(Icons.error_outline, + size: 64, color: AppColors.error), const SizedBox(height: 16), Text( 'Error loading playlists', - style: GoogleFonts.roboto( + style: GoogleFonts.outfit( fontSize: 18, color: Colors.white70, ), @@ -83,7 +86,11 @@ class PlaylistSelectionScreen extends ConsumerWidget { const SizedBox(height: 8), ElevatedButton( onPressed: () => ref.refresh(playlistsProvider), - child: const Text('Retry'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text('Retry', style: GoogleFonts.inter()), ), ], ), @@ -102,7 +109,7 @@ class PlaylistSelectionScreen extends ConsumerWidget { const SizedBox(height: 16), Text( 'No playlists available', - style: GoogleFonts.roboto( + style: GoogleFonts.outfit( fontSize: 18, color: Colors.white60, ), @@ -112,7 +119,7 @@ class PlaylistSelectionScreen extends ConsumerWidget { currentUser?.isAdmin ?? false ? 'Add playlists in Admin Panel' : 'Contact administrator', - style: GoogleFonts.roboto( + style: GoogleFonts.inter( fontSize: 14, color: Colors.white38, ), @@ -124,16 +131,17 @@ class PlaylistSelectionScreen extends ConsumerWidget { return GridView.builder( padding: const EdgeInsets.fromLTRB( - 24, + 48, 100, - 24, - 24, - ), // Top padding for transparent AppBar - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 24, - mainAxisSpacing: 24, - childAspectRatio: 1.5, + 48, + 48, + ), // More padding for cinematic feel + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + MediaQuery.of(context).size.width > 1200 ? 4 : 3, + crossAxisSpacing: 32, + mainAxisSpacing: 32, + childAspectRatio: 1.4, ), itemCount: playlists.length, itemBuilder: (context, index) { @@ -181,20 +189,21 @@ class _PlaylistCardState extends State<_PlaylistCard> { duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: _isHovered - ? Colors.white.withOpacity(0.15) - : Colors.white.withOpacity(0.05), // Glass effect - borderRadius: BorderRadius.circular(16), + ? Colors.white.withOpacity(0.1) + : Colors.white.withOpacity(0.03), // Subtle glass + borderRadius: BorderRadius.circular(24), border: Border.all( color: _isHovered - ? Colors.white.withOpacity(0.5) - : Colors.white.withOpacity(0.1), + ? AppColors.primary.withOpacity(0.5) + : Colors.white.withOpacity(0.08), width: 1.5, ), boxShadow: _isHovered ? [ BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 20, + color: AppColors.primary.withOpacity(0.2), + blurRadius: 30, + spreadRadius: -5, offset: const Offset(0, 10), ), ] @@ -205,37 +214,57 @@ class _PlaylistCardState extends State<_PlaylistCard> { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( + AnimatedContainer( + duration: const Duration(milliseconds: 200), width: 56, height: 56, decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.2), // Accent color tint + gradient: _isHovered + ? AppColors.primaryGradient + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withOpacity(0.1), + Colors.white.withOpacity(0.05), + ], + ), shape: BoxShape.circle, + boxShadow: _isHovered + ? [ + BoxShadow( + color: AppColors.primary.withOpacity(0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ] + : [], ), - child: const Icon( - Icons.playlist_play, + child: Icon( + Icons.playlist_play_rounded, size: 32, - color: Colors.white, // White icon + color: _isHovered ? Colors.white : Colors.white70, ), ), - const SizedBox(height: 16), + const SizedBox(height: 20), Text( widget.playlist.name, style: GoogleFonts.outfit( - // Using Outfit for modern look - fontSize: 20, + fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white, + height: 1.2, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 6), + const SizedBox(height: 8), Text( Uri.tryParse(widget.playlist.dns)?.host ?? widget.playlist.dns, style: GoogleFonts.inter( fontSize: 13, - color: Colors.white54, + color: Colors.white38, + letterSpacing: 0.5, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart index 2f0b15f..d84aaa8 100644 --- a/lib/features/iptv/widgets/epg_overlay.dart +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/playlist_config.dart'; import '../providers/xtream_provider.dart'; - /// Minimal EPG overlay - shows program title and info class EpgOverlay extends ConsumerWidget { final String streamId; @@ -17,9 +16,11 @@ class EpgOverlay extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final epgAsync = ref.watch(epgByPlaylistProvider( - EpgRequestKey(playlist: playlist, streamId: streamId), - ),); + final epgAsync = ref.watch( + epgByPlaylistProvider( + EpgRequestKey(playlist: playlist, streamId: streamId), + ), + ); return epgAsync.when( data: (epgEntries) { @@ -40,67 +41,69 @@ class EpgOverlay extends ConsumerWidget { // Enhanced info display return Align( - alignment: Alignment.bottomLeft, // Align to bottom left near controls - child: Container( - margin: const EdgeInsets.fromLTRB(24, 0, 24, 24), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), // Semi-transparent backing for readability - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withOpacity(0.1)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LIVE', - style: TextStyle( - color: Colors.white, - fontSize: 12, // Larger - fontWeight: FontWeight.bold, - ), + alignment: Alignment.bottomLeft, // Align to bottom left near controls + child: Container( + margin: const EdgeInsets.fromLTRB(0, 0, 24, 0), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A2E) + .withOpacity(0.85), // Match Native player controls + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withOpacity(0.1)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 12, // Larger + fontWeight: FontWeight.bold, ), ), - const SizedBox(width: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), // Wider - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + ), + const SizedBox(width: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), // Wider + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + currentProgram.title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, // Much Larger + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black, blurRadius: 4)], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (currentProgram.description.isNotEmpty) Text( - currentProgram.title, + currentProgram.description, style: const TextStyle( - color: Colors.white, - fontSize: 18, // Much Larger - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black, blurRadius: 4)], + color: Colors.white70, + fontSize: 13, // Larger desc ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (currentProgram.description.isNotEmpty) - Text( - currentProgram.description, - style: const TextStyle( - color: Colors.white70, - fontSize: 13, // Larger desc - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), + ], ), - ], - ), + ), + ], ), + ), ); }, loading: () => const SizedBox.shrink(), diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 365e858..0c3c7b7 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -261,8 +261,8 @@ class _LiveTVTabState extends ConsumerState final columns = ResponsiveLayout.value( context, mobile: 2, - tablet: 3, - desktop: 4, + tablet: 4, + desktop: 6, ); return GridView.builder( @@ -353,8 +353,8 @@ class _LiveTVTabState extends ConsumerState final columns = ResponsiveLayout.value( context, mobile: 2, - tablet: 4, - desktop: 5, + tablet: 5, + desktop: 8, ); return GridView.builder( @@ -383,28 +383,29 @@ class _LiveTVTabState extends ConsumerState // Icon Area Expanded( child: Container( - decoration: const BoxDecoration( - color: Colors.white, + decoration: BoxDecoration( + color: Colors.white + .withOpacity(0.05), // Subtle transparent background borderRadius: - BorderRadius.vertical(top: Radius.circular(12)), + const 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( + errorBuilder: (_, __, ___) => Icon( Icons.tv, - color: Colors.grey, + color: Colors.white.withOpacity(0.3), size: 40, ), ) : Text( channel.name.characters.first.toUpperCase(), - style: GoogleFonts.inter( + style: GoogleFonts.outfit( fontSize: 40, fontWeight: FontWeight.bold, - color: Colors.black12, + color: Colors.white24, ), ), ), diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 213225c..f3fba21 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -5,7 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/responsive_layout.dart'; -import '../../../core/widgets/components/hero_carousel.dart'; +import '../../../core/widgets/hero_carousel.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; import '../providers/watch_history_provider.dart'; @@ -305,22 +305,18 @@ class _MoviesTabState extends ConsumerState { items: displayMovies .take(5) .map( - (m) => HeroItem( + (m) => HeroCarouselItem( id: m.streamId, title: m.name, imageUrl: _getProxiedImageUrl(m.streamIcon), subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, - onMoreInfo: () => _playMovie(m), + onPlay: () => _playMovie(m), + onTap: () => _playMovie(m), ), ) .toList(), - onTap: (item) { - final movie = _movies - .firstWhere((element) => element.streamId == item.id); - _playMovie(movie); - }, ), ), ), diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index c357746..71ef558 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -4,10 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/utils/responsive_layout.dart'; -import '../../../core/widgets/components/hero_carousel.dart'; -import '../../../core/widgets/themed_loading_screen.dart'; +import '../../../core/widgets/hero_carousel.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; @@ -283,14 +281,23 @@ class _SeriesTabState extends ConsumerState { items: displaySeries .take(5) .map( - (s) => HeroItem( + (s) => HeroCarouselItem( id: s.seriesId.toString(), title: s.name, imageUrl: _getProxiedImageUrl(s.cover), subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, - onMoreInfo: () { + onPlay: () { + final series = _series.firstWhere( + (element) => + element.seriesId.toString() == + s.seriesId.toString(), + orElse: () => s, + ); + _openSeries(series); + }, + onTap: () { final series = _series.firstWhere( (element) => element.seriesId.toString() == @@ -302,13 +309,6 @@ class _SeriesTabState extends ConsumerState { ), ) .toList(), - onTap: (item) { - final series = _series.firstWhere( - (element) => element.seriesId.toString() == item.id, - orElse: () => _series[0], - ); - _openSeries(series); - }, ), ), ), diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart index 0036ed3..46700b7 100644 --- a/lib/features/iptv/widgets/streaming_settings_tab.dart +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -174,6 +174,29 @@ class StreamingSettingsTab extends ConsumerWidget { ), ], ), + const SizedBox(height: 16), + + // Server/GPU Section + _buildSectionCard( + context: context, + title: 'Serveur / GPU', + icon: Icons.memory, + children: [ + SwitchListTile( + title: Text( + 'Accélération NVIDIA GPU', + style: GoogleFonts.roboto(fontSize: 14, color: Colors.white), + ), + subtitle: Text( + 'Utiliser NVENC/NVDEC pour le transcodage (requiert GPU NVIDIA)', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), + ), + value: settings.enableNvidiaGpu, + activeThumbColor: Colors.green, + onChanged: (v) => notifier.setEnableNvidiaGpu(v), + ), + ], + ), const SizedBox(height: 32), // Info card diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index c00b015..71be95b 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../core/models/playlist_config.dart'; -import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/components/hero_carousel.dart'; import '../../../../core/widgets/glass_container.dart'; import '../../../../features/iptv/models/xtream_models.dart'; diff --git a/web/index.html b/web/index.html index 17b1719..951d7cd 100644 --- a/web/index.html +++ b/web/index.html @@ -27,7 +27,7 @@ } body { - background: linear-gradient(135deg, #0A0E1A 0%, #121829 100%); + background: linear-gradient(135deg, #0F1014 0%, #181920 100%); display: flex; justify-content: center; align-items: center; @@ -55,7 +55,7 @@ .logo { font-size: 48px; font-weight: 700; - background: linear-gradient(135deg, #00BCD4 0%, #26C6DA 50%, #00ACC1 100%); + background: linear-gradient(135deg, #007AFF 0%, #00C6FF 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -67,16 +67,17 @@ width: 40px; height: 40px; margin: 0 auto 16px; - border: 3px solid rgba(0, 188, 212, 0.2); - border-top-color: #00BCD4; + border: 3px solid rgba(0, 122, 255, 0.2); + border-top-color: #007AFF; border-radius: 50%; animation: spin 1s linear infinite; } .loading-text { - color: #94A3B8; + color: rgba(255, 255, 255, 0.7); font-size: 14px; font-weight: 500; + font-family: 'Outfit', sans-serif; } @keyframes spin { diff --git a/web/player_lite.html b/web/player_lite.html index 86c4e73..bfb8838 100644 --- a/web/player_lite.html +++ b/web/player_lite.html @@ -157,12 +157,22 @@ // Report on pause and end video.addEventListener('pause', () => { window.parent.postMessage({ - type: 'playback_position', - currentTime: video.currentTime, - duration: isFinite(video.duration) ? video.duration : 0 + type: 'playback_status', + status: 'paused' }, '*'); }); + video.addEventListener('playing', () => { + window.parent.postMessage({ + type: 'playback_status', + status: 'playing' + }, '*'); + + // Also reset reconnect attempts on successful playing + reconnectAttempts = 0; + hideLoading(); + }); + video.addEventListener('ended', () => { window.parent.postMessage({ type: 'playback_ended', @@ -471,6 +481,27 @@ } } + // Listen for messages from Flutter + window.addEventListener('message', (event) => { + const data = event.data; + if (!data) return; + + log('Received message from parent: ' + JSON.stringify(data)); + + if (data.type === 'play') { + video.play().catch(e => log('Play command failed: ' + e)); + } else if (data.type === 'pause') { + video.pause(); + } else if (data.type === 'set_volume') { + video.volume = Math.max(0, Math.min(1, parseFloat(data.value))); + video.muted = video.volume === 0; + } else if (data.type === 'seek') { + if (isFinite(video.duration)) { + video.currentTime = parseFloat(data.value); + } + } + }); + // Start player initPlayer();