From f5093e8d0e1b69fab94930227704060f4e9b233f Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 15:44:57 +0100 Subject: [PATCH 01/35] feat: Add streaming handlers for live TV (MPEG-TS proxy) and VOD (HLS transcoding) using FFmpeg. --- bin/api/streaming_handler.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index fd0710c..2c1548f 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -136,8 +136,9 @@ Handler createLiveStreamHandler( '-reconnect_streamed', '1', '-reconnect_delay_max', '5', '-rw_timeout', '15000000', - '-analyzeduration', '5000000', // Faster probe for live - '-probesize', '5000000', + '-fflags', '+nobuffer+fastseek', // Disable buffering for instant start + '-analyzeduration', '1000000', // 1s probe (was 5s) + '-probesize', '1000000', // 1MB probe (was 5MB) '-i', targetUrl, From 9da60f214144531a61d139dd46f9e13d035c8010 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 15:48:01 +0100 Subject: [PATCH 02/35] fix(live): optimize FFmpeg streaming for faster start with reliable audio - Add -fflags +nobuffer+fastseek+genpts for instant stream start - Keep probesize at 5MB for reliable audio detection - Set analyzeduration to 3s for balanced speed/reliability --- bin/api/streaming_handler.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 2c1548f..b65239b 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -136,9 +136,9 @@ Handler createLiveStreamHandler( '-reconnect_streamed', '1', '-reconnect_delay_max', '5', '-rw_timeout', '15000000', - '-fflags', '+nobuffer+fastseek', // Disable buffering for instant start - '-analyzeduration', '1000000', // 1s probe (was 5s) - '-probesize', '1000000', // 1MB probe (was 5MB) + '-fflags', '+nobuffer+fastseek+genpts', // Fast start + fix timestamps + '-analyzeduration', '3000000', // 3s probe (reliable detection) + '-probesize', '5000000', // 5MB probe (original - ensures audio) '-i', targetUrl, From d955ab9523a573b51f56c3808de374d4a82d61bb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 15:48:49 +0100 Subject: [PATCH 03/35] fix(live): transcode audio to AAC for browser compatibility - Keep video as direct copy (low CPU) - Transcode audio to AAC (supports AC3/EAC3/DTS sources) - Downmix to stereo for 5.1 surround streams --- bin/api/streaming_handler.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index b65239b..38344b5 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -142,7 +142,10 @@ Handler createLiveStreamHandler( '-i', targetUrl, - '-c', 'copy', // Direct stream copy (Very low CPU) + '-c:v', 'copy', // Video: Direct copy (Very low CPU) + '-c:a', 'aac', // Audio: Transcode to AAC (browser compatible) + '-b:a', '192k', // Good audio quality + '-ac', '2', // Stereo downmix (handles 5.1 surround) '-f', 'mpegts', 'pipe:1', // Output to stdout ]; From d22350fb89168e685dfd66f81cdfa877adef92cb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 15:52:55 +0100 Subject: [PATCH 04/35] feat(ui): increase Live TV grid density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Categories: 4 → 6 columns on desktop - Channels: 5 → 8 columns on desktop - Also bumped tablet values for better density --- lib/features/iptv/widgets/live_tv_tab.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 365e858..b9f57de 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( From a1a8810ddcf464f469d0988e3a7a20dbd7165248 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:12:45 +0100 Subject: [PATCH 05/35] 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); - }, ), ), ), From be51118dc2d3dd30851a01bfac6eb5d2e2c97957 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:17:35 +0100 Subject: [PATCH 06/35] feat: add LiveTVTab for browsing live channels with search and favorites, and PlayerScreen for channel playback. --- lib/features/iptv/screens/player_screen.dart | 73 +++++++++++++------- lib/features/iptv/widgets/live_tv_tab.dart | 15 ++-- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index d81c3c5..d97b3ab 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -373,33 +373,58 @@ class _PlayerScreenState extends ConsumerState { ), ), - // Channel Zapping Controls - if (widget.channels != null && widget.channels!.length > 1) + // Bottom Controls for Live TV (Channel Zapping + Play/Pause) + if (_showControls) AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: 160, - right: _showControls ? 24 : -100, + bottom: 40, + left: 0, + right: 0, 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, - ), - ], + child: Center( + child: GlassContainer( + borderRadius: 30, + opacity: 0.15, + border: true, + borderColor: Colors.white.withOpacity(0.1), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Previous Channel + if (widget.channels != null && + widget.channels!.length > 1) ...[ + _buildGlassIconButton( + icon: Icons.skip_previous_rounded, + onTap: _previousChannel, + transparent: true, + ), + const SizedBox(width: 24), + ], + + // Play/Pause + _buildGlassIconButton( + icon: _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlayPause, + size: 56, + iconSize: 32, + ), + + // Next Channel + if (widget.channels != null && + widget.channels!.length > 1) ...[ + const SizedBox(width: 24), + _buildGlassIconButton( + icon: Icons.skip_next_rounded, + onTap: _nextChannel, + transparent: true, + ), + ], + ], + ), ), ), ), diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index b9f57de..0c3c7b7 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -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, ), ), ), From 79ad1243fa61164bd0979fd34a213451b4c32d71 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:21:54 +0100 Subject: [PATCH 07/35] feat: Add a themed loading screen widget and a web-based HLS/MPEG-TS video player. --- lib/core/widgets/themed_loading_screen.dart | 33 ++++++++++++++------- web/player_lite.html | 19 ++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) 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/web/player_lite.html b/web/player_lite.html index 86c4e73..c7866fc 100644 --- a/web/player_lite.html +++ b/web/player_lite.html @@ -471,6 +471,25 @@ } } + // Listen for messages from Flutter + window.addEventListener('message', (event) => { + const data = event.data; + if (!data) return; + + 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(); From c165ff5d40afab15d9da6d24cbd8e0f8ae5c53da Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:26:05 +0100 Subject: [PATCH 08/35] feat: Implement IPTV player screen with web iframe integration for live and VOD streams, utilizing `player_lite.html` for live TV. --- lib/features/iptv/screens/player_screen.dart | 28 +++++++++++++++++--- web/player_lite.html | 2 ++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index d97b3ab..e57f5eb 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -217,10 +217,30 @@ 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'); } } diff --git a/web/player_lite.html b/web/player_lite.html index c7866fc..7183120 100644 --- a/web/player_lite.html +++ b/web/player_lite.html @@ -476,6 +476,8 @@ 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') { From 2d418e4e0ca77af9b5527ca6e29c4292fdefd1a3 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:32:39 +0100 Subject: [PATCH 09/35] feat: Add playlist selection screen and basic web IPTV player. --- .../screens/playlist_selection_screen.dart | 104 +++++++++++------- web/index.html | 11 +- web/player_lite.html | 16 ++- 3 files changed, 85 insertions(+), 46 deletions(-) diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 608d76d..505d1a7 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,28 @@ 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), + 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 +85,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 +108,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 +118,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 +130,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 +188,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 +213,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/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 7183120..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', From 30218cb1d08bd6c8ddbaffed7b8b494bebf53df0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:37:42 +0100 Subject: [PATCH 10/35] feat: add TvFocusableCard widget for interactive focus and hover effects. --- lib/core/widgets/tv_focusable_card.dart | 73 +++++++++++++------------ 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/lib/core/widgets/tv_focusable_card.dart b/lib/core/widgets/tv_focusable_card.dart index ee5b856..5af0447 100644 --- a/lib/core/widgets/tv_focusable_card.dart +++ b/lib/core/widgets/tv_focusable_card.dart @@ -93,42 +93,45 @@ class _TvFocusableCardState extends State 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: Material( + color: Colors.transparent, + 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, ), ), ), From 83e43b9da80d4e8601af37415b2aebd45e85e274 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:41:02 +0100 Subject: [PATCH 11/35] feat: Implement IPTV player screen with playback controls and stream type handling. --- lib/features/iptv/screens/player_screen.dart | 38 ++++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index e57f5eb..7799693 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -716,22 +716,30 @@ class _PlayerScreenState extends ConsumerState { double size = 48, double iconSize = 24, }) { - return TvFocusableCard( - onTap: onTap, - borderRadius: size / 2, - scaleFactor: 1.1, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: - transparent ? Colors.transparent : Colors.white.withOpacity(0.1), - shape: BoxShape.circle, - border: transparent - ? null - : Border.all(color: Colors.white.withOpacity(0.1)), + // Simplified button with direct Material/InkWell and PointerInterceptor + return PointerInterceptor( + child: 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: iconSize), ), ); } From e19f0638f71fab5302e1628a48ac04694011c3d8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:44:29 +0100 Subject: [PATCH 12/35] feat: add PlayerScreen for IPTV playback with web player integration and controls. --- lib/features/iptv/screens/player_screen.dart | 42 ++++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 7799693..647f2b2 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -717,28 +717,28 @@ class _PlayerScreenState extends ConsumerState { double iconSize = 24, }) { // Simplified button with direct Material/InkWell and PointerInterceptor - return PointerInterceptor( - child: 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), + // Simplified button with direct Material/InkWell + // Note: PointerInterceptor is handled by the parent 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), ), ), ); From 4ecfd4f48c3df073f76c462db1c4f524b90fc931 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 18:48:26 +0100 Subject: [PATCH 13/35] feat: Implement IPTV player screen supporting live and VOD streams via iframe. --- lib/features/iptv/screens/player_screen.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 647f2b2..bfe4ae9 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -253,10 +253,13 @@ class _PlayerScreenState extends ConsumerState { } void _togglePlayPause() { + // Optimistic update to make UI responsive immediately + setState(() => _isPlaying = !_isPlaying); + if (_isPlaying) { - _sendMessage({'type': 'pause'}); - } else { _sendMessage({'type': 'play'}); + } else { + _sendMessage({'type': 'pause'}); } } From 1547b376e0e200e0a7be4e6010e568f4986b86ab Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 19:04:11 +0100 Subject: [PATCH 14/35] feat: Implement web-based video player screen with playback controls and stream type handling. --- lib/features/iptv/screens/player_screen.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index bfe4ae9..e4bcf17 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -61,7 +61,9 @@ class _PlayerScreenState extends ConsumerState { String? _currentStreamUrl; String _statusMessage = 'Loading...'; String? _errorMessage; + String? _errorMessage; bool _isMuted = false; + bool _ignoreStatusUpdates = false; @override void initState() { @@ -209,7 +211,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(); } @@ -256,6 +260,12 @@ class _PlayerScreenState extends ConsumerState { // 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': 'play'}); } else { From e74aa822ad768bf524f653fd5cffc2f1fa324994 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 19:16:51 +0100 Subject: [PATCH 15/35] feat: add IPTV player screen supporting live, VOD, and series stream playback via embedded web players. --- lib/features/iptv/screens/player_screen.dart | 46 ++++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index e4bcf17..3ebcf27 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -59,8 +59,6 @@ class _PlayerScreenState extends ConsumerState { bool _isSeeking = false; String _viewId = 'iptv-player'; String? _currentStreamUrl; - String _statusMessage = 'Loading...'; - String? _errorMessage; String? _errorMessage; bool _isMuted = false; bool _ignoreStatusUpdates = false; @@ -730,28 +728,30 @@ class _PlayerScreenState extends ConsumerState { double iconSize = 24, }) { // Simplified button with direct Material/InkWell and PointerInterceptor - // Simplified button with direct Material/InkWell - // Note: PointerInterceptor is handled by the parent 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)), + // Simplified button with direct Material/InkWell and PointerInterceptor + // We wrap the specific button to ensure it catches events over the iframe + return PointerInterceptor( + child: 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: iconSize), ), ), ); From b6a0016c00dbd43015800453389482079aa9681d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 19:19:52 +0100 Subject: [PATCH 16/35] feat: implement IPTV PlayerScreen with embedded web player for live, VOD, and series streams. --- lib/features/iptv/screens/player_screen.dart | 181 +++++++++---------- 1 file changed, 89 insertions(+), 92 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 3ebcf27..db161cd 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -59,6 +59,7 @@ class _PlayerScreenState extends ConsumerState { bool _isSeeking = false; String _viewId = 'iptv-player'; String? _currentStreamUrl; + String _statusMessage = 'Loading...'; String? _errorMessage; bool _isMuted = false; bool _ignoreStatusUpdates = false; @@ -347,59 +348,57 @@ class _PlayerScreenState extends ConsumerState { top: _showControls ? 24 : -100, left: 24, right: 24, - child: PointerInterceptor( - 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), - ), - 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, + 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), + ), + 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, ), - if (widget.channels != null) - Text( - 'Live TV', - style: GoogleFonts.inter( - color: AppColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - letterSpacing: 1.0, - ), + overflow: TextOverflow.ellipsis, + ), + if (widget.channels != null) + Text( + 'Live TV', + style: GoogleFonts.inter( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, ), - ], - ), + ), + ], ), - const SizedBox(width: 16), - _buildGlassIconButton( - icon: _isMuted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - onTap: _toggleMute, - ), - ], - ), + ), + const SizedBox(width: 16), + _buildGlassIconButton( + icon: _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + onTap: _toggleMute, + ), + ], ), ), ), @@ -411,51 +410,49 @@ class _PlayerScreenState extends ConsumerState { bottom: 40, left: 0, right: 0, - child: PointerInterceptor( - child: Center( - child: GlassContainer( - borderRadius: 30, - opacity: 0.15, - border: true, - borderColor: Colors.white.withOpacity(0.1), - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Previous Channel - if (widget.channels != null && - widget.channels!.length > 1) ...[ - _buildGlassIconButton( - icon: Icons.skip_previous_rounded, - onTap: _previousChannel, - transparent: true, - ), - const SizedBox(width: 24), - ], - - // Play/Pause + child: Center( + child: GlassContainer( + borderRadius: 30, + opacity: 0.15, + border: true, + borderColor: Colors.white.withOpacity(0.1), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Previous Channel + if (widget.channels != null && + widget.channels!.length > 1) ...[ _buildGlassIconButton( - icon: _isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - onTap: _togglePlayPause, - size: 56, - iconSize: 32, + icon: Icons.skip_previous_rounded, + onTap: _previousChannel, + transparent: true, ), - - // Next Channel - if (widget.channels != null && - widget.channels!.length > 1) ...[ - const SizedBox(width: 24), - _buildGlassIconButton( - icon: Icons.skip_next_rounded, - onTap: _nextChannel, - transparent: true, - ), - ], + const SizedBox(width: 24), ], - ), + + // Play/Pause + _buildGlassIconButton( + icon: _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlayPause, + size: 56, + iconSize: 32, + ), + + // Next Channel + if (widget.channels != null && + widget.channels!.length > 1) ...[ + const SizedBox(width: 24), + _buildGlassIconButton( + icon: Icons.skip_next_rounded, + onTap: _nextChannel, + transparent: true, + ), + ], + ], ), ), ), From 440f15faa6fe1372f6f58914fdaec5f69c17b8a0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 19:27:32 +0100 Subject: [PATCH 17/35] feat: add IPTV player screen with support for live, VOD, and series streams using iframe-based playback. --- lib/features/iptv/screens/player_screen.dart | 226 +++++++++---------- 1 file changed, 113 insertions(+), 113 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index db161cd..9a08e70 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -348,57 +348,59 @@ class _PlayerScreenState extends ConsumerState { top: _showControls ? 24 : -100, left: 24, right: 24, - 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), - ), - 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, - ), - ), - ], + child: PointerInterceptor( + 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), ), - ), - const SizedBox(width: 16), - _buildGlassIconButton( - icon: _isMuted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - onTap: _toggleMute, - ), - ], + 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, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + _buildGlassIconButton( + icon: _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + onTap: _toggleMute, + ), + ], + ), ), ), ), @@ -410,49 +412,51 @@ class _PlayerScreenState extends ConsumerState { bottom: 40, left: 0, right: 0, - child: Center( - child: GlassContainer( - borderRadius: 30, - opacity: 0.15, - border: true, - borderColor: Colors.white.withOpacity(0.1), - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Previous Channel - if (widget.channels != null && - widget.channels!.length > 1) ...[ - _buildGlassIconButton( - icon: Icons.skip_previous_rounded, - onTap: _previousChannel, - transparent: true, - ), - const SizedBox(width: 24), - ], + child: PointerInterceptor( + child: Center( + child: GlassContainer( + borderRadius: 30, + opacity: 0.15, + border: true, + borderColor: Colors.white.withOpacity(0.1), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Previous Channel + if (widget.channels != null && + widget.channels!.length > 1) ...[ + _buildGlassIconButton( + icon: Icons.skip_previous_rounded, + onTap: _previousChannel, + transparent: true, + ), + const SizedBox(width: 24), + ], - // Play/Pause - _buildGlassIconButton( - icon: _isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - onTap: _togglePlayPause, - size: 56, - iconSize: 32, - ), - - // Next Channel - if (widget.channels != null && - widget.channels!.length > 1) ...[ - const SizedBox(width: 24), + // Play/Pause _buildGlassIconButton( - icon: Icons.skip_next_rounded, - onTap: _nextChannel, - transparent: true, + icon: _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlayPause, + size: 56, + iconSize: 32, ), + + // Next Channel + if (widget.channels != null && + widget.channels!.length > 1) ...[ + const SizedBox(width: 24), + _buildGlassIconButton( + icon: Icons.skip_next_rounded, + onTap: _nextChannel, + transparent: true, + ), + ], ], - ], + ), ), ), ), @@ -724,31 +728,27 @@ class _PlayerScreenState extends ConsumerState { double size = 48, double iconSize = 24, }) { - // Simplified button with direct Material/InkWell and PointerInterceptor - // Simplified button with direct Material/InkWell and PointerInterceptor - // We wrap the specific button to ensure it catches events over the iframe - return PointerInterceptor( - child: 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), + // 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), ), ), ); From aa75347d0abbdfbfec3de009b0aaa49185b7128b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 19:43:14 +0100 Subject: [PATCH 18/35] feat: implement IPTV player screen with web-based video playback, controls, and EPG overlay. --- lib/features/iptv/screens/player_screen.dart | 356 +++++++++++-------- 1 file changed, 213 insertions(+), 143 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 9a08e70..4228d00 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -323,164 +323,190 @@ class _PlayerScreenState extends ConsumerState { Widget build(BuildContext context) { final isLiveTV = widget.streamType == StreamType.live; - // LITE PLAYER MODE for Live TV + // LITE PLAYER MODE for Live TV - Simplified UI (no BackdropFilter) if (isLiveTV) { return Scaffold( - backgroundColor: Colors.black, // Video background - body: MouseRegion( - onHover: (_) => _onHover(), - child: GestureDetector( - onTap: () { - if (_showControls) { - setState(() => _showControls = false); - } else { - _onHover(); - } - }, - child: Stack( - fit: StackFit.expand, - children: [ - if (_isInitialized) HtmlElementView(viewType: _viewId), + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + // Video Player + if (_isInitialized) HtmlElementView(viewType: _viewId), - // Top Bar - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - top: _showControls ? 24 : -100, - left: 24, - right: 24, - child: PointerInterceptor( - 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( + // 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: [ - _buildGlassIconButton( - icon: Icons.arrow_back_rounded, - onTap: () => Navigator.pop(context), - ), - 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, + // 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, + ], ), - if (widget.channels != null) - Text( - 'Live TV', - style: GoogleFonts.inter( - color: AppColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - letterSpacing: 1.0, + ), + 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, + ), + ), + ], + ), + ), + _buildSimpleIconButton( + icon: _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + onTap: _toggleMute, + ), + ], + ), + ), ), ), - const SizedBox(width: 16), - _buildGlassIconButton( - icon: _isMuted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - onTap: _toggleMute, + + // Bottom Controls + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + bottom: _showControls ? 0 : -150, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 24), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + child: SafeArea( + top: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Previous Channel + if (widget.channels != null && + widget.channels!.length > 1) + _buildSimpleIconButton( + icon: Icons.skip_previous_rounded, + onTap: _previousChannel, + size: 48, + ), + + const SizedBox(width: 32), + + // Play/Pause (Large) + _buildSimpleIconButton( + icon: _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlayPause, + size: 64, + iconSize: 36, + highlighted: true, + ), + + const SizedBox(width: 32), + + // Next Channel + if (widget.channels != null && + widget.channels!.length > 1) + _buildSimpleIconButton( + icon: Icons.skip_next_rounded, + onTap: _nextChannel, + size: 48, + ), + ], + ), + ), + ), ), ], ), ), ), ), - - // Bottom Controls for Live TV (Channel Zapping + Play/Pause) - if (_showControls) - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - bottom: 40, - left: 0, - right: 0, - child: PointerInterceptor( - child: Center( - child: GlassContainer( - borderRadius: 30, - opacity: 0.15, - border: true, - borderColor: Colors.white.withOpacity(0.1), - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Previous Channel - if (widget.channels != null && - widget.channels!.length > 1) ...[ - _buildGlassIconButton( - icon: Icons.skip_previous_rounded, - onTap: _previousChannel, - transparent: true, - ), - const SizedBox(width: 24), - ], - - // Play/Pause - _buildGlassIconButton( - icon: _isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - onTap: _togglePlayPause, - size: 56, - iconSize: 32, - ), - - // Next Channel - if (widget.channels != null && - widget.channels!.length > 1) ...[ - const SizedBox(width: 24), - _buildGlassIconButton( - icon: Icons.skip_next_rounded, - onTap: _nextChannel, - transparent: true, - ), - ], - ], - ), - ), - ), - ), - ), - - // EPG Overlay - if (widget.streamType == StreamType.live) - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - bottom: _showControls ? 40 : -140, - left: 24, - right: 24, - child: PointerInterceptor( - child: EpgOverlay( - playlist: widget.playlist, - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId - : widget.streamId, - ), - ), - ), - ], + ), ), - ), + + // EPG Overlay (outside the main PointerInterceptor) + if (widget.streamType == StreamType.live) + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + bottom: _showControls ? 120 : -140, + left: 24, + right: 24, + child: PointerInterceptor( + child: EpgOverlay( + playlist: widget.playlist, + streamId: widget.channels != null + ? widget.channels![_currentIndex].streamId + : widget.streamId, + ), + ), + ), + ], ), ); } @@ -753,4 +779,48 @@ class _PlayerScreenState extends ConsumerState { ), ); } + + // 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, + ), + ), + ), + ); + } } From e27fa6fec355cc21c077d9e53882f945ac0cfc7d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 19:56:31 +0100 Subject: [PATCH 19/35] feat: Add IPTV player screen supporting live, VOD, and series playback with interactive controls. --- lib/features/iptv/screens/player_screen.dart | 43 ++++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 4228d00..8bda8fd 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -421,29 +421,24 @@ class _PlayerScreenState extends ConsumerState { ), ), - // Bottom Controls + // Bottom Controls - styled like EPG AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 0 : -150, + bottom: _showControls ? 130 : -100, left: 0, right: 0, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 24), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black.withOpacity(0.8), - Colors.transparent, - ], + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withOpacity(0.1)), ), - ), - child: SafeArea( - top: false, child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ // Previous Channel if (widget.channels != null && @@ -454,7 +449,9 @@ class _PlayerScreenState extends ConsumerState { size: 48, ), - const SizedBox(width: 32), + if (widget.channels != null && + widget.channels!.length > 1) + const SizedBox(width: 24), // Play/Pause (Large) _buildSimpleIconButton( @@ -462,12 +459,14 @@ class _PlayerScreenState extends ConsumerState { ? Icons.pause_rounded : Icons.play_arrow_rounded, onTap: _togglePlayPause, - size: 64, - iconSize: 36, + size: 56, + iconSize: 32, highlighted: true, ), - const SizedBox(width: 32), + if (widget.channels != null && + widget.channels!.length > 1) + const SizedBox(width: 24), // Next Channel if (widget.channels != null && @@ -494,7 +493,7 @@ class _PlayerScreenState extends ConsumerState { if (widget.streamType == StreamType.live) AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 120 : -140, + bottom: _showControls ? 24 : -140, left: 24, right: 24, child: PointerInterceptor( From 478107f0a4eb2b5b427b40488d5d2085eb5367fb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 20:05:40 +0100 Subject: [PATCH 20/35] feat: Add streaming handlers for live and VOD content using FFmpeg. --- bin/api/streaming_handler.dart | 252 +++++++++++++----- .../iptv/providers/settings_provider.dart | 18 ++ .../iptv/widgets/streaming_settings_tab.dart | 23 ++ 3 files changed, 227 insertions(+), 66 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 38344b5..e61641d 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -25,6 +25,28 @@ String _getFFmpegPath() { 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()) { @@ -122,33 +144,71 @@ 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 + // Log GPU status once + _logGpuStatus(); + + // Check if NVIDIA GPU is enabled + final useNvidiaGpu = _isNvidiaGpuEnabled(); + + // Build FFmpeg arguments + final ffmpegArgs = [ + '-hide_banner', + '-loglevel', + 'error', + ]; + + // NVIDIA GPU Hardware Acceleration (NVDEC for decoding) + if (useNvidiaGpu) { + ffmpegArgs.addAll([ + '-hwaccel', 'cuda', // Use NVIDIA CUDA for decoding + '-hwaccel_output_format', 'cuda', // Keep frames on GPU + ]); + } + + // 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', + '5', + '-rw_timeout', + '15000000', + '-fflags', + '+nobuffer+fastseek+genpts', + '-analyzeduration', + '3000000', + '-probesize', + '5000000', + '-i', + targetUrl, + ]); - // HTTP redirect handling and robustness flags - '-reconnect', '1', - '-reconnect_at_eof', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '5', - '-rw_timeout', '15000000', - '-fflags', '+nobuffer+fastseek+genpts', // Fast start + fix timestamps - '-analyzeduration', '3000000', // 3s probe (reliable detection) - '-probesize', '5000000', // 5MB probe (original - ensures audio) + // Output encoding options + if (useNvidiaGpu) { + // With GPU: Use NVENC for any re-encoding needed + ffmpegArgs.addAll([ + '-c:v', 'copy', // Still copy video (no transcode needed for live) + '-c:a', 'aac', // Audio to AAC (browser compatible) + '-b:a', '192k', + '-ac', '2', + ]); + } else { + // Without GPU: Standard CPU processing + 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:v', 'copy', // Video: Direct copy (Very low CPU) - '-c:a', 'aac', // Audio: Transcode to AAC (browser compatible) - '-b:a', '192k', // Good audio quality - '-ac', '2', // Stereo downmix (handles 5.1 surround) - '-f', 'mpegts', - 'pipe:1', // Output to stdout - ]; + ffmpegArgs.addAll(['-f', 'mpegts', 'pipe:1']); try { final ffmpegPath = _getFFmpegPath(); @@ -285,58 +345,118 @@ 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', + // Log GPU status and check if enabled + _logGpuStatus(); + final useNvidiaGpu = _isNvidiaGpuEnabled(); - // Robustness for network streams - '-reconnect', '1', - '-reconnect_at_eof', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '5', - '-rw_timeout', '15000000', + // Build FFmpeg arguments + final ffmpegArgs = [ + '-hide_banner', + '-loglevel', + 'error', + ]; + + // NVIDIA GPU Hardware Acceleration + if (useNvidiaGpu) { + ffmpegArgs.addAll([ + '-hwaccel', + 'cuda', + '-hwaccel_output_format', + 'cuda', + ]); + } + + // 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', + '5', + '-rw_timeout', + '15000000', '-analyzeduration', - '2000000', // Reduced from 10M to 2M for faster start - '-probesize', '2000000', // Reduced from 10M to 2M for faster start + '2000000', + '-probesize', + '2000000', + '-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 (p1=fastest, p7=best) + '-tune', 'hq', // High quality mode + '-rc', 'vbr', // Variable bitrate + '-cq', '23', // Quality level (similar to CRF) + '-maxrate', '4000k', + '-bufsize', '8000k', + '-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) { diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index 8199797..d35f15d 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -63,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 @@ -86,6 +88,9 @@ class IptvSettings { final String preferredAspectRatio; final PlayerType playerType; + // Server/GPU Settings + final bool enableNvidiaGpu; + const IptvSettings({ // Filters this.liveTvCategoryFilter = '', @@ -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, ); } @@ -334,6 +343,7 @@ class IptvSettingsNotifier extends StateNotifier { ? PlayerType .values[remoteSettings[_SettingsKeys.playerType] as int] : null, + enableNvidiaGpu: getValue(_SettingsKeys.enableNvidiaGpu), ); } _initialized = true; @@ -377,6 +387,7 @@ class IptvSettingsNotifier extends StateNotifier { _SettingsKeys.showClock: state.showClock, _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, _SettingsKeys.playerType: state.playerType.index, + _SettingsKeys.enableNvidiaGpu: state.enableNvidiaGpu, }; print('[SettingsProvider] _saveToApi: Saving settings...'); @@ -469,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/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 From 72e28a398ac3086c19a63ca63386efd0c4e6b0ba Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 20:10:14 +0100 Subject: [PATCH 21/35] feat: add IPTV player screen with HTML iframe integration for live, VOD, and series streams --- lib/features/iptv/screens/player_screen.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 8bda8fd..a7df62b 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -424,9 +424,9 @@ class _PlayerScreenState extends ConsumerState { // Bottom Controls - styled like EPG AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 130 : -100, - left: 0, - right: 0, + bottom: _showControls ? 100 : -100, + left: 24, + right: 24, child: Center( child: Container( padding: const EdgeInsets.symmetric( @@ -493,7 +493,7 @@ class _PlayerScreenState extends ConsumerState { if (widget.streamType == StreamType.live) AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 24 : -140, + bottom: _showControls ? 16 : -140, left: 24, right: 24, child: PointerInterceptor( From ce82b9e8246aaee84ad5bdf5a4f72b41d799b68e Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 20:13:56 +0100 Subject: [PATCH 22/35] feat: Implement API handlers for live and VOD streaming and create database module. --- bin/api/streaming_handler.dart | 25 ++++++++++++++++--------- bin/database/database.dart | 23 +++++++++++++++++++++++ bin/server.dart | 10 ++++++++-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index e61641d..5154e4c 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -116,7 +116,8 @@ 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) @@ -144,11 +145,13 @@ Handler createLiveStreamHandler( '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; print('[Live] Streaming via FFmpeg: $targetUrl'); - // Log GPU status once - _logGpuStatus(); + // Check if NVIDIA GPU is enabled (from database setting or env var fallback) + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); - // Check if NVIDIA GPU is enabled - final useNvidiaGpu = _isNvidiaGpuEnabled(); + // Log GPU status + if (useNvidiaGpu) { + print('[Live] Using NVIDIA GPU acceleration (NVDEC)'); + } // Build FFmpeg arguments final ffmpegArgs = [ @@ -268,7 +271,8 @@ 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 @@ -345,9 +349,12 @@ Handler createVodStreamHandler( '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv'; print('[VOD] Starting Transcode ($contentType): $targetUrl'); - // Log GPU status and check if enabled - _logGpuStatus(); - final useNvidiaGpu = _isNvidiaGpuEnabled(); + // Check if NVIDIA GPU is enabled (from database setting or env var fallback) + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); + + if (useNvidiaGpu) { + print('[VOD] Using NVIDIA GPU acceleration (NVENC)'); + } // Build FFmpeg arguments final ffmpegArgs = [ diff --git a/bin/database/database.dart b/bin/database/database.dart index c65dd19..53507e9 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -373,6 +373,29 @@ 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) return false; + + final settingsJson = result.first['settings_json'] as String; + // Parse JSON to check the setting + // Settings key: enable_nvidia_gpu + if (settingsJson.contains('"enable_nvidia_gpu":true')) { + return true; + } + } catch (e) { + print('[Database] 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 26b6890..938b0b0 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -172,8 +172,14 @@ 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)); // Main handler with API proxy and NEW Streaming Handlers final handler = Cascade() From c77b100a4e7880d6674c9804ef093b28e70a8c30 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 20:17:52 +0100 Subject: [PATCH 23/35] feat: Switch Dockerfile base image to NVIDIA CUDA, install Dart SDK, and add NVENC-enabled FFmpeg. --- Dockerfile | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index af954ea..b9fadb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,12 +44,34 @@ RUN flutter build web --release --base-href="/" --no-wasm-dry-run --no-tree-shak # ============================================ # Stage 2: Serve with custom Dart server (with API proxy) # ============================================ -FROM dart:stable +# Use NVIDIA CUDA base image for GPU support +FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 + +# Install Dart SDK +RUN apt-get update && apt-get install -y \ + apt-transport-https \ + wget \ + gnupg \ + && wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/dart.gpg \ + && echo 'deb [signed-by=/usr/share/keyrings/dart.gpg arch=amd64] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main' | tee /etc/apt/sources.list.d/dart_stable.list \ + && apt-get update && apt-get install -y dart \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/usr/lib/dart/bin:$PATH" WORKDIR /app -# Install SQLite3 library for FFI and FFmpeg for IPTV stream transcoding -RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev ffmpeg && rm -rf /var/lib/apt/lists/* +# Install SQLite3 library for FFI +RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/* + +# Install FFmpeg with NVIDIA NVENC support (static build from johnvansickle.com) +RUN apt-get update && apt-get install -y xz-utils \ + && wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ + && tar xf ffmpeg-release-amd64-static.tar.xz \ + && mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \ + && mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \ + && rm -rf ffmpeg-* \ + && rm -rf /var/lib/apt/lists/* # Create directory for HLS stream output RUN mkdir -p /tmp/streams && chmod 777 /tmp/streams From b4c1cd919f86c224f9884071ffbd20d28702b790 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 20:26:34 +0100 Subject: [PATCH 24/35] Revert Dockerfile to stable dart:stable base, add GPU toggle in settings UI --- Dockerfile | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9fadb9..af954ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,34 +44,12 @@ RUN flutter build web --release --base-href="/" --no-wasm-dry-run --no-tree-shak # ============================================ # Stage 2: Serve with custom Dart server (with API proxy) # ============================================ -# Use NVIDIA CUDA base image for GPU support -FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 - -# Install Dart SDK -RUN apt-get update && apt-get install -y \ - apt-transport-https \ - wget \ - gnupg \ - && wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/dart.gpg \ - && echo 'deb [signed-by=/usr/share/keyrings/dart.gpg arch=amd64] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main' | tee /etc/apt/sources.list.d/dart_stable.list \ - && apt-get update && apt-get install -y dart \ - && rm -rf /var/lib/apt/lists/* - -ENV PATH="/usr/lib/dart/bin:$PATH" +FROM dart:stable WORKDIR /app -# Install SQLite3 library for FFI -RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/* - -# Install FFmpeg with NVIDIA NVENC support (static build from johnvansickle.com) -RUN apt-get update && apt-get install -y xz-utils \ - && wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ - && tar xf ffmpeg-release-amd64-static.tar.xz \ - && mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \ - && mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \ - && rm -rf ffmpeg-* \ - && rm -rf /var/lib/apt/lists/* +# Install SQLite3 library for FFI and FFmpeg for IPTV stream transcoding +RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev ffmpeg && rm -rf /var/lib/apt/lists/* # Create directory for HLS stream output RUN mkdir -p /tmp/streams && chmod 777 /tmp/streams From 2d4f687b4b2fec4eb3c0d531b0ec65d5113b003b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 20:30:20 +0100 Subject: [PATCH 25/35] Use BtbN FFmpeg build with NVENC/NVDEC hardware acceleration support --- Dockerfile | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index af954ea..9fa587b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,8 +48,18 @@ FROM dart:stable WORKDIR /app -# Install SQLite3 library for FFI and FFmpeg for IPTV stream transcoding -RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev ffmpeg && rm -rf /var/lib/apt/lists/* +# Install SQLite3 library for FFI +RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev wget xz-utils && rm -rf /var/lib/apt/lists/* + +# Install FFmpeg with NVIDIA NVENC support from BtbN static builds +# This version includes hardware acceleration (NVENC/NVDEC/CUDA) +# 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 directory for HLS stream output RUN mkdir -p /tmp/streams && chmod 777 /tmp/streams From 363fa14453cd2853222cfde9d4d544e05b8f47ff Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 23:18:08 +0100 Subject: [PATCH 26/35] Add debug logging for GPU setting detection --- bin/database/database.dart | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bin/database/database.dart b/bin/database/database.dart index 53507e9..add0391 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -382,16 +382,24 @@ class AppDatabase { 'SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1', ); - if (result.isEmpty) return false; + if (result.isEmpty) { + print('[GPU] No user settings found in database'); + return false; + } final settingsJson = result.first['settings_json'] as String; - // Parse JSON to check the setting - // Settings key: enable_nvidia_gpu - if (settingsJson.contains('"enable_nvidia_gpu":true')) { - return true; - } + 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('[Database] Error checking GPU setting: $e'); + print('[GPU] Error checking GPU setting: $e'); } return false; } From a00d5ae4dad5f35c5bc117375568909e3ee32068 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 23:19:48 +0100 Subject: [PATCH 27/35] Force GPU transcoding (h264_nvenc) for Live TV when GPU toggle is enabled --- bin/api/streaming_handler.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 5154e4c..895af4c 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -194,15 +194,22 @@ Handler createLiveStreamHandler( // Output encoding options if (useNvidiaGpu) { - // With GPU: Use NVENC for any re-encoding needed + // With GPU: Transcode using NVIDIA NVENC for hardware acceleration + print('[Live] Transcoding video with h264_nvenc (GPU)'); ffmpegArgs.addAll([ - '-c:v', 'copy', // Still copy video (no transcode needed for live) + '-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder + '-preset', 'p4', // Good quality/speed balance (p1=fastest, p7=best) + '-tune', 'll', // Low latency tuning for live streams + '-rc', 'vbr', // Variable bitrate + '-cq', '23', // Quality level (similar to CRF) + '-maxrate', '6000k', // Max bitrate for live + '-bufsize', '12000k', // Buffer size '-c:a', 'aac', // Audio to AAC (browser compatible) '-b:a', '192k', '-ac', '2', ]); } else { - // Without GPU: Standard CPU processing + // 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 From cd3a49ce636741892ed1945a56f4a4d1178a3ab0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 23:27:04 +0100 Subject: [PATCH 28/35] Fix GPU transcoding timeout - use CBR mode, increase timeouts, add keyframe interval --- bin/api/streaming_handler.dart | 31 ++++++++++++-------- lib/features/iptv/screens/player_screen.dart | 4 +-- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 895af4c..0794d95 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -157,14 +157,14 @@ Handler createLiveStreamHandler( final ffmpegArgs = [ '-hide_banner', '-loglevel', - 'error', + '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 - '-hwaccel_output_format', 'cuda', // Keep frames on GPU + // Note: Don't use hwaccel_output_format cuda - it can cause issues with some codecs ]); } @@ -179,15 +179,17 @@ Handler createLiveStreamHandler( '-reconnect_streamed', '1', '-reconnect_delay_max', - '5', + '10', // Increased from 5 '-rw_timeout', - '15000000', + '30000000', // Increased from 15s to 30s + '-timeout', + '30000000', // Add explicit timeout '-fflags', - '+nobuffer+fastseek+genpts', + '+nobuffer+genpts+discardcorrupt', // Added discardcorrupt for robustness '-analyzeduration', - '3000000', + '5000000', // Increased for GPU decode '-probesize', - '5000000', + '10000000', // Increased for GPU decode '-i', targetUrl, ]); @@ -198,15 +200,18 @@ Handler createLiveStreamHandler( print('[Live] Transcoding video with h264_nvenc (GPU)'); ffmpegArgs.addAll([ '-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder - '-preset', 'p4', // Good quality/speed balance (p1=fastest, p7=best) + '-preset', 'p1', // Fastest preset for live (p1=fastest) '-tune', 'll', // Low latency tuning for live streams - '-rc', 'vbr', // Variable bitrate - '-cq', '23', // Quality level (similar to CRF) - '-maxrate', '6000k', // Max bitrate for live - '-bufsize', '12000k', // Buffer size + '-rc', 'cbr', // Constant bitrate for stable streaming + '-b:v', '4000k', // Fixed video bitrate + '-maxrate', '4500k', // Max bitrate + '-bufsize', '8000k', // Buffer size (2x bitrate) + '-g', '50', // Keyframe interval (every 2 seconds at 25fps) + '-bf', '0', // No B-frames for lower latency '-c:a', 'aac', // Audio to AAC (browser compatible) - '-b:a', '192k', + '-b:a', '128k', // Audio bitrate '-ac', '2', + '-ar', '44100', // Audio sample rate ]); } else { // Without GPU: Standard CPU processing (copy video) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index a7df62b..1b3b6fa 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -424,7 +424,7 @@ class _PlayerScreenState extends ConsumerState { // Bottom Controls - styled like EPG AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 100 : -100, + bottom: _showControls ? 16 : -100, left: 24, right: 24, child: Center( @@ -493,7 +493,7 @@ class _PlayerScreenState extends ConsumerState { if (widget.streamType == StreamType.live) AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 16 : -140, + bottom: _showControls ? 100 : -140, left: 24, right: 24, child: PointerInterceptor( From 409878e173a0826bc7d04fe19160719b2bac1a14 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 1 Jan 2026 23:33:02 +0100 Subject: [PATCH 29/35] Fix VOD GPU transcoding - remove hwaccel_output_format, use CBR mode for HLS --- bin/api/streaming_handler.dart | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 0794d95..61bdfeb 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -372,16 +372,14 @@ Handler createVodStreamHandler( final ffmpegArgs = [ '-hide_banner', '-loglevel', - 'error', + 'warning', ]; // NVIDIA GPU Hardware Acceleration if (useNvidiaGpu) { ffmpegArgs.addAll([ - '-hwaccel', - 'cuda', - '-hwaccel_output_format', - 'cuda', + '-hwaccel', 'cuda', + // Note: Don't use hwaccel_output_format cuda - it can cause issues ]); } @@ -396,13 +394,15 @@ Handler createVodStreamHandler( '-reconnect_streamed', '1', '-reconnect_delay_max', - '5', + '10', '-rw_timeout', - '15000000', + '30000000', + '-timeout', + '30000000', '-analyzeduration', - '2000000', + '5000000', '-probesize', - '2000000', + '10000000', '-i', targetUrl, ]); @@ -412,12 +412,14 @@ Handler createVodStreamHandler( // 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 (p1=fastest, p7=best) + '-preset', 'p4', // Good quality/speed balance '-tune', 'hq', // High quality mode - '-rc', 'vbr', // Variable bitrate - '-cq', '23', // Quality level (similar to CRF) - '-maxrate', '4000k', + '-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 { From f6d5ae776404c4400384ded25df73e67a6fd65d6 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Jan 2026 08:59:52 +0100 Subject: [PATCH 30/35] UI fixes: remove center play button from Native player, align EPG/controls in Live TV, match background colors --- lib/features/iptv/screens/player_screen.dart | 59 +++------- lib/features/iptv/widgets/epg_overlay.dart | 113 ++++++++++--------- 2 files changed, 74 insertions(+), 98 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 1b3b6fa..2b825c3 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -421,19 +421,20 @@ class _PlayerScreenState extends ConsumerState { ), ), - // Bottom Controls - styled like EPG + // Bottom Controls - styled like Native player AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 16 : -100, - left: 24, - right: 24, + bottom: _showControls ? 24 : -100, + left: 0, + right: 0, child: Center( child: Container( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 12), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(12), + color: + const Color(0xFF1A1A2E).withOpacity(0.85), + borderRadius: BorderRadius.circular(24), border: Border.all( color: Colors.white.withOpacity(0.1)), ), @@ -493,9 +494,9 @@ class _PlayerScreenState extends ConsumerState { if (widget.streamType == StreamType.live) AnimatedPositioned( duration: const Duration(milliseconds: 200), - bottom: _showControls ? 100 : -140, + bottom: _showControls ? 24 : -140, left: 24, - right: 24, + right: 0, child: PointerInterceptor( child: EpgOverlay( playlist: widget.playlist, @@ -578,41 +579,13 @@ class _PlayerScreenState extends ConsumerState { ), ), - // Center Play/Pause - Center( - child: _isLoading - ? const CircularProgressIndicator( - color: AppColors.primary) - : TvFocusableCard( - onTap: _togglePlayPause, - borderRadius: 100, - scaleFactor: 1.2, - focusColor: AppColors.primary, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - shape: BoxShape.circle, - 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: 40, - ), - ), - ), - ), + // Center Loading Indicator (only show when loading) + if (_isLoading) + const Center( + child: CircularProgressIndicator( + color: AppColors.primary, + ), + ), // Bottom Controls Positioned( 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(), From 8670e42cf8f3bc3b62f37fa984da95f4e3836dc4 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Jan 2026 09:01:14 +0100 Subject: [PATCH 31/35] Fix GPU Live TV rollbacks - add vsync cfr, async, smaller buffer, faster keyframes --- bin/api/streaming_handler.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 61bdfeb..6cd5d2c 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -199,19 +199,25 @@ Handler createLiveStreamHandler( // 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 size (2x bitrate) - '-g', '50', // Keyframe interval (every 2 seconds at 25fps) + '-bufsize', '4000k', // Smaller buffer for lower latency + '-g', '25', // Keyframe every 1 second (at 25fps) '-bf', '0', // No B-frames for lower latency + // Audio encoding '-c:a', 'aac', // Audio to AAC (browser compatible) '-b:a', '128k', // Audio bitrate '-ac', '2', - '-ar', '44100', // Audio sample rate + '-ar', '48000', // 48kHz sample rate (matches most sources) + // Sync options to prevent rollbacks + '-vsync', 'cfr', // Constant frame rate + '-async', '1', // Sync audio to video timestamps + '-max_muxing_queue_size', '1024', // Bigger muxing queue ]); } else { // Without GPU: Standard CPU processing (copy video) From d93d638b1bda04e0ddcaadd65254e01fb0020445 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Jan 2026 09:14:57 +0100 Subject: [PATCH 32/35] Unified Live TV control bar - EPG + buttons in single bar --- lib/features/iptv/screens/player_screen.dart | 290 +++++++++++++++---- 1 file changed, 226 insertions(+), 64 deletions(-) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 2b825c3..406cc6b 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -421,64 +421,84 @@ class _PlayerScreenState extends ConsumerState { ), ), - // Bottom Controls - styled like Native player + // Unified Bottom Bar - EPG + Controls in one bar AnimatedPositioned( duration: const Duration(milliseconds: 200), bottom: _showControls ? 24 : -100, - left: 0, - right: 0, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 12), - decoration: BoxDecoration( - color: - const Color(0xFF1A1A2E).withOpacity(0.85), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Colors.white.withOpacity(0.1)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Previous Channel - if (widget.channels != null && - widget.channels!.length > 1) + 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: Icons.skip_previous_rounded, - onTap: _previousChannel, - size: 48, + 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: 24), + 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: 56, - iconSize: 32, - highlighted: true, - ), + // Next Channel + if (widget.channels != null && + widget.channels!.length > 1) + _buildSimpleIconButton( + icon: Icons.skip_next_rounded, + onTap: _nextChannel, + size: 44, + ), - if (widget.channels != null && - widget.channels!.length > 1) - const SizedBox(width: 24), + const SizedBox(width: 16), - // Next Channel - if (widget.channels != null && - widget.channels!.length > 1) + // Mute button _buildSimpleIconButton( - icon: Icons.skip_next_rounded, - onTap: _nextChannel, - size: 48, + icon: _isMuted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + onTap: _toggleMute, + size: 44, ), - ], - ), + ], + ), + ], ), ), ), @@ -489,23 +509,6 @@ class _PlayerScreenState extends ConsumerState { ), ), ), - - // EPG Overlay (outside the main PointerInterceptor) - if (widget.streamType == StreamType.live) - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - bottom: _showControls ? 24 : -140, - left: 24, - right: 0, - child: PointerInterceptor( - child: EpgOverlay( - playlist: widget.playlist, - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId - : widget.streamId, - ), - ), - ), ], ), ); @@ -795,4 +798,163 @@ class _PlayerScreenState extends ConsumerState { ), ); } + + /// 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, + ), + ), + ); + }, + ); + } } From 33a331dce992b06020f5119270259c462913f8dc Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Jan 2026 09:21:51 +0100 Subject: [PATCH 33/35] Fix GPU rollbacks v2 - aresample filter, copyts, larger buffers --- bin/api/streaming_handler.dart | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 6cd5d2c..3758ad2 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -206,18 +206,20 @@ Handler createLiveStreamHandler( '-rc', 'cbr', // Constant bitrate for stable streaming '-b:v', '4000k', // Fixed video bitrate '-maxrate', '4500k', // Max bitrate - '-bufsize', '4000k', // Smaller buffer for lower latency - '-g', '25', // Keyframe every 1 second (at 25fps) + '-bufsize', '8000k', // Buffer for stability + '-g', '50', // Keyframe every 2 seconds (at 25fps) '-bf', '0', // No B-frames for lower latency - // Audio encoding + // 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 (matches most sources) - // Sync options to prevent rollbacks - '-vsync', 'cfr', // Constant frame rate - '-async', '1', // Sync audio to video timestamps - '-max_muxing_queue_size', '1024', // Bigger muxing queue + '-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) From 71630d75e88d03887d8460b7fd2b939942c20f78 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 3 Jan 2026 10:26:17 +0100 Subject: [PATCH 34/35] feat: Install FFmpeg with NVIDIA NVENC/NVDEC support via apt-get instead of a static build. --- bin/api/streaming_handler.dart | 22 +++-- bin/server.dart | 21 +++-- lib/core/theme/app_theme.dart | 80 +++++++++++-------- lib/core/widgets/hero_carousel.dart | 6 +- .../iptv/screens/dashboard_screen.dart | 17 ++-- lib/features/iptv/screens/player_screen.dart | 67 ++++++++++------ .../screens/playlist_selection_screen.dart | 5 +- lib/features/iptv/widgets/series_tab.dart | 2 - .../iptv/widgets/mobile_series_tab.dart | 1 - 9 files changed, 135 insertions(+), 86 deletions(-) diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 3758ad2..296025e 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -19,8 +19,9 @@ 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 } @@ -41,7 +42,8 @@ void _logGpuStatus() { print('[FFmpeg] NVIDIA GPU acceleration ENABLED (NVDEC/NVENC)'); } else { print( - '[FFmpeg] Using CPU processing (set NVIDIA_GPU=true to enable GPU)'); + '[FFmpeg] Using CPU processing (set NVIDIA_GPU=true to enable GPU)', + ); } _gpuStatusLogged = true; } @@ -116,8 +118,9 @@ PlaylistConfig? _getCurrentPlaylist(Request request) { // ========================================== Handler createLiveStreamHandler( - Future Function(Request) getPlaylist, - {bool Function()? isGpuEnabled}) { + Future Function(Request) getPlaylist, { + bool Function()? isGpuEnabled, +}) { final router = Router(); // Route: /api/live/{streamId} (Catch all pattern to debug) @@ -291,16 +294,18 @@ Handler createLiveStreamHandler( final _lastLogTime = {}; Handler createVodStreamHandler( - Future Function(Request) getPlaylist, - {bool Function()? isGpuEnabled}) { + 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'); + } final streamDir = Directory('${_hlsTempDir.path}/$streamId'); @@ -514,7 +519,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/server.dart b/bin/server.dart index 938b0b0..061969a 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -91,7 +91,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(); @@ -173,13 +174,19 @@ void main(List args) async { // Create Streaming Router (Mount handlers on correct paths) final streamingRouter = Router() ..mount( - '/api/live', - createLiveStreamHandler(getPlaylist, - isGpuEnabled: db.isNvidiaGpuEnabled)) + '/api/live', + createLiveStreamHandler( + getPlaylist, + isGpuEnabled: db.isNvidiaGpuEnabled, + ), + ) ..mount( - '/api/vod', - createVodStreamHandler(getPlaylist, - isGpuEnabled: db.isNvidiaGpuEnabled)); + '/api/vod', + createVodStreamHandler( + getPlaylist, + isGpuEnabled: db.isNvidiaGpuEnabled, + ), + ); // Main handler with API proxy and NEW Streaming Handlers final handler = Cascade() diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 221057b..56646e8 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -41,43 +41,52 @@ class AppTheme { // Typography: "Cinematic Modern" (Outfit for Headings, Inter for UI) textTheme: TextTheme( displayLarge: GoogleFonts.outfit( - fontSize: 56, - fontWeight: FontWeight.w700, - letterSpacing: -1.0, - color: AppColors.textPrimary), + 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), + fontSize: 48, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: AppColors.textPrimary, + ), displaySmall: GoogleFonts.outfit( - fontSize: 32, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 32, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), headlineMedium: GoogleFonts.outfit( - fontSize: 24, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), titleLarge: GoogleFonts.outfit( - fontSize: 20, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), titleMedium: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), bodyLarge: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w400, - color: AppColors.textSecondary), + fontSize: 16, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), bodyMedium: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.textSecondary), + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), labelLarge: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary), + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), ), // AppBar: Transparent / Glass @@ -99,7 +108,9 @@ class AppTheme { filled: true, fillColor: AppColors.surface, contentPadding: const EdgeInsets.symmetric( - horizontal: spacing24, vertical: spacing16), + horizontal: spacing24, + vertical: spacing16, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none, @@ -123,15 +134,18 @@ class AppTheme { elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusMd)), + borderRadius: BorderRadius.circular(radiusMd), + ), textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16), ).copyWith( overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.hovered)) + if (states.contains(WidgetState.hovered)) { return Colors.black.withOpacity(0.1); - if (states.contains(WidgetState.pressed)) + } + if (states.contains(WidgetState.pressed)) { return Colors.black.withOpacity(0.2); + } return null; }), ), diff --git a/lib/core/widgets/hero_carousel.dart b/lib/core/widgets/hero_carousel.dart index fe64a84..cd480a5 100644 --- a/lib/core/widgets/hero_carousel.dart +++ b/lib/core/widgets/hero_carousel.dart @@ -254,8 +254,10 @@ class _HeroCarouselState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.play_arrow_rounded, - color: Colors.white), + const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + ), const SizedBox(width: 8), Text( 'WATCH NOW', diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 6f1237f..371cf81 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -121,8 +121,11 @@ class _DashboardScreenState extends ConsumerState { ), ], ), - child: const Icon(Icons.play_arrow_rounded, - color: Colors.white, size: 28), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 28, + ), ), ), @@ -149,7 +152,8 @@ class _DashboardScreenState extends ConsumerState { borderRadius: BorderRadius.circular(16), border: isSelected ? Border.all( - color: AppColors.primary.withOpacity(0.5)) + color: AppColors.primary.withOpacity(0.5), + ) : null, ), child: Icon( @@ -176,8 +180,11 @@ class _DashboardScreenState extends ConsumerState { child: CircleAvatar( radius: 20, backgroundColor: Colors.white.withOpacity(0.1), - child: const Icon(Icons.person, - color: Colors.white70, size: 20), + child: const Icon( + Icons.person, + color: Colors.white70, + size: 20, + ), ), ), ), diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 406cc6b..4c944e8 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -243,7 +243,8 @@ class _PlayerScreenState extends ConsumerState { iframe.contentWindow?.postMessage(message, '*'); } else { print( - '[PlayerScreen] Error: Could not find iframe to send message: $message'); + '[PlayerScreen] Error: Could not find iframe to send message: $message', + ); } } @@ -359,7 +360,9 @@ class _PlayerScreenState extends ConsumerState { right: 0, child: Container( padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 12), + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -409,12 +412,6 @@ class _PlayerScreenState extends ConsumerState { ], ), ), - _buildSimpleIconButton( - icon: _isMuted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - onTap: _toggleMute, - ), ], ), ), @@ -429,12 +426,15 @@ class _PlayerScreenState extends ConsumerState { right: 24, child: Container( padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 16), + 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)), + color: Colors.white.withOpacity(0.1), + ), ), child: Row( children: [ @@ -608,10 +608,14 @@ class _PlayerScreenState extends ConsumerState { Row( children: [ Text( - _formatDuration(Duration( - seconds: _currentPosition.toInt())), + _formatDuration( + Duration( + seconds: _currentPosition.toInt(), + ), + ), style: GoogleFonts.inter( - color: Colors.white70), + color: Colors.white70, + ), ), const SizedBox(width: 16), Expanded( @@ -623,7 +627,8 @@ class _PlayerScreenState extends ConsumerState { Colors.white.withOpacity(0.2), thumbColor: Colors.white, thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8), + enabledThumbRadius: 8, + ), overlayColor: AppColors.primary.withOpacity(0.2), ), @@ -632,18 +637,22 @@ class _PlayerScreenState extends ConsumerState { min: 0, max: _totalDuration, onChanged: (val) => setState( - () => _currentPosition = val), + () => _currentPosition = val, + ), onChangeStart: (_) => setState(() => _isSeeking = true), onChangeEnd: (val) { _sendMessage( - {'type': 'seek', 'value': val}); + {'type': 'seek', 'value': val}, + ); Future.delayed( const Duration(milliseconds: 500), () { - if (mounted) + if (mounted) { setState( - () => _isSeeking = false); + () => _isSeeking = false, + ); + } }); }, ), @@ -651,10 +660,14 @@ class _PlayerScreenState extends ConsumerState { ), const SizedBox(width: 16), Text( - _formatDuration(Duration( - seconds: _totalDuration.toInt())), + _formatDuration( + Duration( + seconds: _totalDuration.toInt(), + ), + ), style: GoogleFonts.inter( - color: Colors.white70), + color: Colors.white70, + ), ), ], ), @@ -669,7 +682,7 @@ class _PlayerScreenState extends ConsumerState { onTap: () => _sendMessage({ 'type': 'seek', 'value': (_currentPosition - 10) - .clamp(0, _totalDuration) + .clamp(0, _totalDuration), }), transparent: true, ), @@ -688,7 +701,7 @@ class _PlayerScreenState extends ConsumerState { onTap: () => _sendMessage({ 'type': 'seek', 'value': (_currentPosition + 10) - .clamp(0, _totalDuration) + .clamp(0, _totalDuration), }), transparent: true, ), @@ -807,9 +820,11 @@ class _PlayerScreenState extends ConsumerState { return Consumer( builder: (context, ref, child) { - final epgAsync = ref.watch(epgByPlaylistProvider( - EpgRequestKey(playlist: widget.playlist, streamId: currentStreamId), - )); + final epgAsync = ref.watch( + epgByPlaylistProvider( + EpgRequestKey(playlist: widget.playlist, streamId: currentStreamId), + ), + ); return epgAsync.when( data: (epgEntries) { diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 505d1a7..eea75b3 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -73,7 +73,8 @@ class PlaylistSelectionScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 64, color: AppColors.error), + const Icon(Icons.error_outline, + size: 64, color: AppColors.error), const SizedBox(height: 16), Text( 'Error loading playlists', @@ -235,7 +236,7 @@ class _PlaylistCardState extends State<_PlaylistCard> { color: AppColors.primary.withOpacity(0.4), blurRadius: 16, offset: const Offset(0, 4), - ) + ), ] : [], ), diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index 20fdf2b..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/hero_carousel.dart'; -import '../../../core/widgets/themed_loading_screen.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/tv_focusable_card.dart'; 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'; From 7e5d50f258256e431cb17320aa0c59190d8a6a1f Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 3 Jan 2026 10:57:22 +0100 Subject: [PATCH 35/35] feat: Add IPTV player screen for live, VOD, and series streams with comprehensive playback controls. --- lib/features/iptv/screens/player_screen.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 4c944e8..bc997c6 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -412,6 +412,7 @@ class _PlayerScreenState extends ConsumerState { ], ), ), + // Top right mute button removed ], ), ),