diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index 83c9443..82f4b1e 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -51,6 +51,9 @@ class _SettingsKeys { static const String epgCacheDuration = 'epg_cache_duration'; static const String transcodingMode = 'transcoding_mode'; static const String preferDirectPlay = 'prefer_direct_play'; + // Player Display Settings + static const String showClock = 'show_clock'; + static const String preferredAspectRatio = 'aspect_ratio'; } /// Settings state for IPTV preferences with persistence @@ -68,6 +71,10 @@ class IptvSettings { final EpgCacheDuration epgCacheDuration; final TranscodingMode transcodingMode; final bool preferDirectPlay; + + // Player Display Settings + final bool showClock; + final String preferredAspectRatio; const IptvSettings({ // Filters @@ -82,6 +89,9 @@ class IptvSettings { this.epgCacheDuration = EpgCacheDuration.medium, this.transcodingMode = TranscodingMode.auto, this.preferDirectPlay = false, + // Player defaults + this.showClock = false, + this.preferredAspectRatio = 'contain', }); IptvSettings copyWith({ @@ -95,6 +105,8 @@ class IptvSettings { EpgCacheDuration? epgCacheDuration, TranscodingMode? transcodingMode, bool? preferDirectPlay, + bool? showClock, + String? preferredAspectRatio, }) { return IptvSettings( liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, @@ -107,6 +119,8 @@ class IptvSettings { epgCacheDuration: epgCacheDuration ?? this.epgCacheDuration, transcodingMode: transcodingMode ?? this.transcodingMode, preferDirectPlay: preferDirectPlay ?? this.preferDirectPlay, + showClock: showClock ?? this.showClock, + preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio, ); } @@ -251,6 +265,10 @@ class IptvSettingsNotifier extends StateNotifier { ]; final directPlay = _prefs?.getBool(_SettingsKeys.preferDirectPlay) ?? false; + // Load Player Display Settings + final showClock = _prefs?.getBool(_SettingsKeys.showClock) ?? false; + final aspectRatio = _prefs?.getString(_SettingsKeys.preferredAspectRatio) ?? 'contain'; + state = IptvSettings( liveTvCategoryFilter: liveTv, moviesCategoryFilter: movies, @@ -262,10 +280,12 @@ class IptvSettingsNotifier extends StateNotifier { epgCacheDuration: epgCache, transcodingMode: transcoding, preferDirectPlay: directPlay, + showClock: showClock, + preferredAspectRatio: aspectRatio, ); _initialized = true; - print('Settings loaded: LiveTV="$liveTv", Quality=${quality.name}'); + print('Settings loaded: Clock=$showClock, Ratio=$aspectRatio'); } catch (e) { print('Error loading settings: $e'); } @@ -364,6 +384,18 @@ class IptvSettingsNotifier extends StateNotifier { _saveBool(_SettingsKeys.preferDirectPlay, value); } + // ===== Player Display Setters ===== + + void setShowClock(bool value) { + state = state.copyWith(showClock: value); + _saveBool(_SettingsKeys.showClock, value); + } + + void setPreferredAspectRatio(String value) { + state = state.copyWith(preferredAspectRatio: value); + _saveString(_SettingsKeys.preferredAspectRatio, value); + } + // Legacy methods void setCategoryFilter(String filter) => setLiveTvFilter(filter); void clearCategoryFilter() => clearLiveTvFilter(); diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 7c8fe64..0afbfde 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -9,6 +9,7 @@ import '../providers/xtream_provider.dart'; import '../providers/playback_positions_provider.dart'; import '../providers/settings_provider.dart'; import '../widgets/epg_overlay.dart'; +import '../widgets/clock_widget.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/models/iptv_models.dart'; @@ -62,14 +63,7 @@ class _PlayerScreenState extends ConsumerState { List> _audioTracks = []; String _aspectRatio = 'contain'; - @override - void initState() { - super.initState(); - _currentIndex = widget.initialIndex; - - _initializePlayer(); - _setupMessageListener(); - } + void _setupMessageListener() { // Listen for postMessage from player.html iframe @@ -300,7 +294,26 @@ class _PlayerScreenState extends ConsumerState { } } + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + + // Load preferred aspect ratio from settings + final settings = ref.read(iptvSettingsProvider); + _aspectRatio = settings.preferredAspectRatio; + + _initializePlayer(); + _setupMessageListener(); + } + void _showSettingsDialog() { + // Read current settings + final settingsNotifier = ref.read(iptvSettingsProvider.notifier); + // We use a local state variable for the switch in the dialog, + // initialized from the provider. + bool showClock = ref.read(iptvSettingsProvider).showClock; + showDialog( context: context, useRootNavigator: true, @@ -308,129 +321,155 @@ class _PlayerScreenState extends ConsumerState { child: Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(24), - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - builder: (context, value, child) => Transform.scale( - scale: 0.9 + (0.1 * value), - child: Opacity( - opacity: value, - child: child, - ), - ), - child: Container( - width: 400, - decoration: BoxDecoration( - color: AppColors.surface.withOpacity(0.8), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withOpacity(0.1)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.5), - blurRadius: 30, - offset: const Offset(0, 10), + child: StatefulBuilder( + builder: (context, setDialogState) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + builder: (context, value, child) => Transform.scale( + scale: 0.9 + (0.1 * value), + child: Opacity( + opacity: value, + child: child, ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + ), + child: Container( + width: 400, + decoration: BoxDecoration( + color: AppColors.surface.withOpacity(0.8), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withOpacity(0.1)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Réglages', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - letterSpacing: -0.5, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Réglages', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white54), + onPressed: () => Navigator.pop(context), + tooltip: 'Fermer', + ), + ], + ), + const SizedBox(height: 24), + const Text('FORMAT D\'IMAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _buildRatioOption('Original', 'contain', Icons.crop_original, setDialogState), + _buildRatioOption('Remplir', 'cover', Icons.crop_free, setDialogState), + _buildRatioOption('Étirer', 'fill', Icons.aspect_ratio, setDialogState), + ], ), ), - IconButton( - icon: const Icon(Icons.close, color: Colors.white54), - onPressed: () => Navigator.pop(context), - tooltip: 'Fermer', - ), - ], - ), - const SizedBox(height: 24), - const Text('FORMAT D\'IMAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - _buildRatioOption('Original', 'contain', Icons.crop_original), - _buildRatioOption('Remplir', 'cover', Icons.crop_free), - _buildRatioOption('Étirer', 'fill', Icons.aspect_ratio), - ], - ), - ), - if (_audioTracks.isNotEmpty) ...[ - const SizedBox(height: 32), - const Text('AUDIO', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), - const SizedBox(height: 12), - Container( - constraints: const BoxConstraints(maxHeight: 200), - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withOpacity(0.05)), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: SingleChildScrollView( - child: Column( - children: _audioTracks.map((track) { - return Material( - color: Colors.transparent, - child: ListTile( - title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white, fontSize: 14)), - subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54, fontSize: 12)), - onTap: () => _setAudioTrack(track['id']), - dense: true, - leading: const Icon(Icons.audiotrack, size: 18, color: Colors.white54), - hoverColor: Colors.white.withOpacity(0.1), - ), - ); - }).toList(), + if (_audioTracks.isNotEmpty) ...[ + const SizedBox(height: 32), + const Text('AUDIO', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), + const SizedBox(height: 12), + Container( + constraints: const BoxConstraints(maxHeight: 150), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.05)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SingleChildScrollView( + child: Column( + children: _audioTracks.map((track) { + return Material( + color: Colors.transparent, + child: ListTile( + title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white, fontSize: 14)), + subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54, fontSize: 12)), + onTap: () { + _setAudioTrack(track['id']); + setDialogState(() {}); + }, + dense: true, + leading: const Icon(Icons.audiotrack, size: 18, color: Colors.white54), + hoverColor: Colors.white.withOpacity(0.1), + ), + ); + }).toList(), + ), ), ), ), - ), + ], + const SizedBox(height: 32), + const Text('AFFICHAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), + const SizedBox(height: 12), + SwitchListTile( + value: showClock, + onChanged: (val) { + setDialogState(() { + showClock = val; + }); + settingsNotifier.setShowClock(val); + }, + title: const Text('Afficher l\'heure', style: TextStyle(color: Colors.white, fontSize: 14)), + secondary: const Icon(Icons.access_time, color: Colors.white54), + activeColor: AppColors.primary, + contentPadding: EdgeInsets.zero, + ), ], - ], + ), + ), ), ), ), - ), - ), + ); + }, ), ), ), ); } - Widget _buildRatioOption(String label, String value, IconData icon) { + Widget _buildRatioOption(String label, String value, IconData icon, StateSetter setDialogState) { final isSelected = _aspectRatio == value; return Expanded( child: GestureDetector( onTap: () { debugPrint('Changing Aspect Ratio to: $value'); _setAspectRatio(value); + // Save preference + ref.read(iptvSettingsProvider.notifier).setPreferredAspectRatio(value); + setDialogState(() {}); // Force rebuild of the dialog content }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -462,6 +501,7 @@ class _PlayerScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final settings = ref.watch(iptvSettingsProvider); return Scaffold( appBar: _showControls ? AppBar( title: Text(widget.channels != null && widget.channels!.isNotEmpty @@ -607,6 +647,24 @@ class _PlayerScreenState extends ConsumerState { ), ), ], + + // Clock Widget (Always visible if enabled, or only with controls? User asked "met l'heure dans les options") + // Positioned in top right + if (settings.showClock) + const Positioned( + top: 24, + right: 24, + child: IgnorePointer( + child: ClockWidget( + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black, blurRadius: 8)], + ), + ), + ), + ), ], ), ), diff --git a/lib/features/iptv/widgets/clock_widget.dart b/lib/features/iptv/widgets/clock_widget.dart new file mode 100644 index 0000000..e6fb9ed --- /dev/null +++ b/lib/features/iptv/widgets/clock_widget.dart @@ -0,0 +1,55 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class ClockWidget extends StatefulWidget { + final TextStyle? style; + + const ClockWidget({super.key, this.style}); + + @override + State createState() => _ClockWidgetState(); +} + +class _ClockWidgetState extends State { + late DateTime _now; + Timer? _timer; + + @override + void initState() { + super.initState(); + _now = DateTime.now(); + _startTimer(); + } + + void _startTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _now = DateTime.now(); + }); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Format: HH:mm + final timeString = DateFormat('HH:mm').format(_now); + return Text( + timeString, + style: widget.style ?? const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black, blurRadius: 4)], + ), + ); + } +} diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart index 4afc75e..53fe5b1 100644 --- a/lib/features/iptv/widgets/epg_overlay.dart +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -4,8 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/playlist_config.dart'; import '../providers/xtream_provider.dart'; -/// Minimal EPG overlay - shows briefly then fades out -class EpgOverlay extends ConsumerStatefulWidget { + +/// Minimal EPG overlay - shows program title and info +class EpgOverlay extends ConsumerWidget { final String streamId; final PlaylistConfig playlist; @@ -16,46 +17,9 @@ class EpgOverlay extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _EpgOverlayState(); -} - -class _EpgOverlayState extends ConsumerState - with SingleTickerProviderStateMixin { - late AnimationController _fadeController; - Timer? _hideTimer; - - @override - void initState() { - super.initState(); - _fadeController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 500), - value: 1.0, // Start visible - ); - // Auto-hide after 5 seconds - _startHideTimer(); - } - - void _startHideTimer() { - _hideTimer?.cancel(); - _hideTimer = Timer(const Duration(seconds: 5), () { - if (mounted) { - _fadeController.reverse(); // Fade out - } - }); - } - - @override - void dispose() { - _hideTimer?.cancel(); - _fadeController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final epgAsync = ref.watch(epgByPlaylistProvider( - EpgRequestKey(playlist: widget.playlist, streamId: widget.streamId), + EpgRequestKey(playlist: playlist, streamId: streamId), )); return epgAsync.when( @@ -75,54 +39,69 @@ class _EpgOverlayState extends ConsumerState orElse: () => epgEntries.first, ); - // Minimal info display - just title and time - return FadeTransition( - opacity: _fadeController, - child: Align( - alignment: Alignment.topLeft, + // Enhanced info display + return Align( + alignment: Alignment.bottomLeft, // Align to bottom left near controls child: Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: const EdgeInsets.fromLTRB(24, 0, 24, 24), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.6), - borderRadius: BorderRadius.circular(6), + 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: 6, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.red, - borderRadius: BorderRadius.circular(3), + borderRadius: BorderRadius.circular(4), ), child: const Text( 'LIVE', style: TextStyle( color: Colors.white, - fontSize: 10, + fontSize: 12, // Larger fontWeight: FontWeight.bold, ), ), ), - const SizedBox(width: 8), + const SizedBox(width: 12), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: Text( - currentProgram.title, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + 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.description, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, // Larger desc + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ], ), ), - ), ); }, loading: () => const SizedBox.shrink(),