From a1a8810ddcf464f469d0988e3a7a20dbd7165248 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:12:45 +0100 Subject: [PATCH] feat: introduce streaming handlers for live TV proxy and VOD HLS transcoding using FFmpeg. --- lib/core/theme/app_colors.dart | 55 +- lib/core/theme/app_theme.dart | 92 ++- lib/core/widgets/glass_container.dart | 18 +- lib/core/widgets/hero_carousel.dart | 308 ++++++++++ lib/core/widgets/tv_focusable_card.dart | 104 ++-- .../iptv/providers/settings_provider.dart | 182 +++--- .../iptv/screens/dashboard_screen.dart | 243 ++++---- lib/features/iptv/screens/player_screen.dart | 544 ++++++++---------- lib/features/iptv/widgets/movies_tab.dart | 12 +- lib/features/iptv/widgets/series_tab.dart | 22 +- 10 files changed, 968 insertions(+), 612 deletions(-) create mode 100644 lib/core/widgets/hero_carousel.dart 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..221057b 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,57 @@ 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 +87,19 @@ 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 +114,7 @@ class AppTheme { ), hintStyle: GoogleFonts.inter(color: AppColors.textTertiary), ), - + // Buttons: White pill or minimal text filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( @@ -91,17 +122,21 @@ 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 +159,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..fe64a84 --- /dev/null +++ b/lib/core/widgets/hero_carousel.dart @@ -0,0 +1,308 @@ +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/tv_focusable_card.dart b/lib/core/widgets/tv_focusable_card.dart index 9a23ce5..ee5b856 100644 --- a/lib/core/widgets/tv_focusable_card.dart +++ b/lib/core/widgets/tv_focusable_card.dart @@ -64,50 +64,72 @@ 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( - 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, - ), - ] - : [], + 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: InkWell( + onTap: widget.onTap, + onFocusChange: _handleFocusChange, + autofocus: widget.autofocus, + borderRadius: BorderRadius.circular(widget.borderRadius), + 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..8199797 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'; @@ -79,12 +80,11 @@ class IptvSettings { final EpgCacheDuration epgCacheDuration; final TranscodingMode transcodingMode; final bool preferDirectPlay; - + // Player Display Settings final bool showClock; final String preferredAspectRatio; final PlayerType playerType; - const IptvSettings({ // Filters @@ -142,63 +142,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 +303,53 @@ 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, ); } _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 +357,32 @@ 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, + }; + + print('[SettingsProvider] _saveToApi: Saving settings...'); + final success = await _apiService.saveSettings(settingsMap); + print('[SettingsProvider] _saveToApi: Save result: $success'); + }); } // ===== Setters with Auto-Save ===== diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 4480dd3..6f1237f 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,147 @@ 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 +189,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..d81c3c5 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -289,15 +289,12 @@ 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 if (isLiveTV) { return Scaffold( - backgroundColor: Colors.black, + backgroundColor: Colors.black, // Video background body: MouseRegion( onHover: (_) => _onHover(), child: GestureDetector( @@ -311,130 +308,110 @@ class _PlayerScreenState extends ConsumerState { child: Stack( fit: StackFit.expand, children: [ - // Video Player (iframe with native controls) if (_isInitialized) HtmlElementView(viewType: _viewId), - // Top Bar: Back Button + Title (with auto-hide) + // Top Bar AnimatedPositioned( duration: const Duration(milliseconds: 200), - top: _showControls ? 24 : -80, + top: _showControls ? 24 : -100, left: 24, right: 24, child: PointerInterceptor( - child: Row( - children: [ - Material( - color: Colors.black54, - shape: const CircleBorder(), - child: InkWell( + child: GlassContainer( + height: 72, + borderRadius: 24, + opacity: 0.15, + border: true, + borderColor: Colors.white.withOpacity(0.1), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _buildGlassIconButton( + icon: Icons.arrow_back_rounded, 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), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + 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, + ), + if (widget.channels != null) + Text( + 'Live TV', + style: GoogleFonts.inter( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), ], ), - overflow: TextOverflow.ellipsis, ), - ), - // Mute button for Live TV - Material( - color: Colors.black54, - shape: const CircleBorder(), - child: InkWell( + const SizedBox(width: 16), + _buildGlassIconButton( + icon: _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, 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, - 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, - ), - ), - ), - ), - 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, - ), - ), - ), ), ], ), ), ), + ), - // EPG Overlay (for Live TV, with auto-hide, higher position) + // Channel Zapping Controls + if (widget.channels != null && widget.channels!.length > 1) + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + bottom: 160, + right: _showControls ? 24 : -100, + child: PointerInterceptor( + child: GlassContainer( + width: 60, + borderRadius: 30, + opacity: 0.15, + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildGlassIconButton( + icon: Icons.keyboard_arrow_up_rounded, + onTap: _previousChannel, + transparent: true, + ), + const SizedBox(height: 8), + _buildGlassIconButton( + icon: Icons.keyboard_arrow_down_rounded, + onTap: _nextChannel, + transparent: true, + ), + ], + ), + ), + ), + ), + + // EPG Overlay if (widget.streamType == StreamType.live) AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 60 : -120, - left: 0, - right: 0, + bottom: _showControls ? 40 : -140, + left: 24, + right: 24, child: PointerInterceptor( child: EpgOverlay( playlist: widget.playlist, @@ -451,16 +428,15 @@ class _PlayerScreenState extends ConsumerState { ); } - // 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 +446,7 @@ class _PlayerScreenState extends ConsumerState { if (_showControls) { setState(() => _showControls = false); } else { - _onHover(); // Shows controls and starts timer + _onHover(); } }, behavior: HitTestBehavior.translucent, @@ -480,257 +456,197 @@ 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 Play/Pause Center( child: _isLoading ? const CircularProgressIndicator( - color: Colors.white, - ) + color: AppColors.primary) : TvFocusableCard( onTap: _togglePlayPause, borderRadius: 100, scaleFactor: 1.2, + focusColor: AppColors.primary, child: Container( width: 80, height: 80, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + gradient: AppColors.primaryGradient, shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withOpacity(0.3), - ), + boxShadow: [ + BoxShadow( + color: + AppColors.primary.withOpacity(0.5), + blurRadius: 30, + spreadRadius: 5, + ), + ], ), child: Icon( _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, color: Colors.white, - size: 48, + size: 40, ), ), ), ), - // EPG Overlay - if (widget.streamType == StreamType.live) - Positioned( - bottom: 140, - left: 40, - child: EpgOverlay( - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId - : widget.streamId, - playlist: widget.playlist, - ), - ), - - // Bottom Control Bar + // 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, - ), - ), - 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, - ), - ), - ], + // Progress + Row( + children: [ + Text( + _formatDuration(Duration( + seconds: _currentPosition.toInt())), + 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,17 +664,29 @@ class _PlayerScreenState extends ConsumerState { ); } - Widget _buildTvControl(IconData icon, VoidCallback onTap) { + Widget _buildGlassIconButton({ + required IconData icon, + required VoidCallback onTap, + bool transparent = false, + double size = 48, + double iconSize = 24, + }) { return TvFocusableCard( onTap: onTap, - borderRadius: 12, + borderRadius: size / 2, + scaleFactor: 1.1, child: Container( - padding: const EdgeInsets.all(12), + width: size, + height: size, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + color: + transparent ? Colors.transparent : Colors.white.withOpacity(0.1), + shape: BoxShape.circle, + border: transparent + ? null + : Border.all(color: Colors.white.withOpacity(0.1)), ), - child: Icon(icon, color: Colors.white, size: 24), + child: Icon(icon, color: Colors.white, size: iconSize), ), ); } 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..20fdf2b 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -6,7 +6,7 @@ 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/hero_carousel.dart'; import '../../../core/widgets/themed_loading_screen.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; @@ -283,14 +283,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 +311,6 @@ class _SeriesTabState extends ConsumerState { ), ) .toList(), - onTap: (item) { - final series = _series.firstWhere( - (element) => element.seriesId.toString() == item.id, - orElse: () => _series[0], - ); - _openSeries(series); - }, ), ), ),