From 9a0bda4b7d62686198890cc5434aeef1cb9e51dd Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 25 Dec 2025 12:54:10 +0100 Subject: [PATCH] feat: Add LitePlayerScreen for IPTV playback with video controls, EPG, and stream type handling. --- bin/database/database.dart | 9 +- .../iptv/services/xtream_service_mobile.dart | 40 +- .../iptv/screens/lite_player_screen.dart | 355 +++++++---- .../iptv/screens/mobile_dashboard_screen.dart | 16 +- .../iptv/screens/native_player_screen.dart | 601 ++++++++++-------- .../iptv/widgets/mobile_live_tv_tab.dart | 140 +++- .../iptv/widgets/mobile_movies_tab.dart | 531 ++++++++++------ .../iptv/widgets/mobile_series_tab.dart | 316 +++++---- .../iptv/widgets/mobile_settings_tab.dart | 2 +- .../providers/mobile_settings_providers.dart | 25 +- 10 files changed, 1258 insertions(+), 777 deletions(-) diff --git a/bin/database/database.dart b/bin/database/database.dart index d0b5f74..c65dd19 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -80,11 +80,14 @@ class AppDatabase { // Indexes _db.execute( - 'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)'); + 'CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)', + ); _db.execute( - 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)'); + 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)', + ); _db.execute( - 'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)'); + 'CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id)', + ); } /// Seed default admin user if no users exist diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index 8ebb648..6a4ce92 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -264,7 +264,7 @@ class XtreamServiceMobile { } /// Load VOD categories mapping - Future> _getVodCategories() async { + Future> getVodCategories() async { if (_currentPlaylist == null) return {}; try { @@ -338,7 +338,7 @@ class XtreamServiceMobile { // Load from cache or fetch from API if (_cachedMoviesRaw == null) { // First load: fetch from API and cache - _cachedVodCategories ??= await _getVodCategories(); + _cachedVodCategories ??= await getVodCategories(); final response = await _dio.get( _effectiveApiBaseUrl, @@ -383,13 +383,47 @@ class XtreamServiceMobile { } } + /// Get movies by category (Fast, optimized for large libraries) + Future> getMoviesByCategory(String categoryId) async { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + try { + final categoryMap = await getVodCategories(); + final categoryName = categoryMap[categoryId] ?? 'Unknown'; + + final response = await _dio.get( + _effectiveApiBaseUrl, + queryParameters: { + 'username': _currentPlaylist!.username, + 'password': _currentPlaylist!.password, + 'action': 'get_vod_streams', + 'category_id': categoryId, + }, + options: _getOptions(), + ); + + if (response.data is! List) return []; + final List movies = response.data as List; + + return movies.map((movieData) { + final data = + Map.from(movieData as Map); + data['category_name'] = categoryName; + return xm.Movie.fromJson(data); + }).toList(); + } catch (e) { + // Return empty list instead of throwing to prevent UI crash + return []; + } + } + /// Search movies in the entire catalogue Future> searchMovies(String query) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); if (query.isEmpty) return []; try { - final categoryMap = await _getVodCategories(); + final categoryMap = await getVodCategories(); final response = await _dio.get( _effectiveApiBaseUrl, diff --git a/lib/mobile/features/iptv/screens/lite_player_screen.dart b/lib/mobile/features/iptv/screens/lite_player_screen.dart index bc3e49e..349daf0 100644 --- a/lib/mobile/features/iptv/screens/lite_player_screen.dart +++ b/lib/mobile/features/iptv/screens/lite_player_screen.dart @@ -11,8 +11,7 @@ import 'package:xtremflow/core/models/playlist_config.dart'; import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart'; import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremflow/core/theme/app_colors.dart'; -import 'package:xtremflow/mobile/features/iptv/screens/native_player_screen.dart' - show StreamType; +import 'package:xtremflow/mobile/features/iptv/screens/native_player_screen.dart'; import 'package:xtremflow/features/iptv/models/xtream_models.dart' as xm; import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; @@ -77,7 +76,6 @@ class _LitePlayerScreenState extends ConsumerState final FocusNode _playPauseFocusNode = FocusNode(); final FocusNode _prevFocusNode = FocusNode(); final FocusNode _nextFocusNode = FocusNode(); - final FocusNode _aspectFocusNode = FocusNode(); @override void initState() { @@ -111,8 +109,10 @@ class _LitePlayerScreenState extends ConsumerState void _startClock() { _clockTimer?.cancel(); _updateTime(); - _clockTimer = - Timer.periodic(const Duration(seconds: 1), (_) => _updateTime()); + _clockTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _updateTime(), + ); } void _updateTime() { @@ -133,8 +133,10 @@ class _LitePlayerScreenState extends ConsumerState if (widget.streamType == StreamType.live) { _updateEPG(); - _epgTimer = - Timer.periodic(const Duration(minutes: 5), (_) => _updateEPG()); + _epgTimer = Timer.periodic( + const Duration(minutes: 5), + (_) => _updateEPG(), + ); } } catch (e) { if (mounted) setState(() => _errorMessage = "Initialization Error: $e"); @@ -176,9 +178,13 @@ class _LitePlayerScreenState extends ConsumerState ? _xtreamService!.getLiveStreamUrl(currentStreamId) : (widget.streamType == StreamType.vod ? _xtreamService!.getVodStreamUrl( - currentStreamId, widget.containerExtension ?? 'mp4') + currentStreamId, + widget.containerExtension ?? 'mp4', + ) : _xtreamService!.getSeriesStreamUrl( - currentStreamId, widget.containerExtension ?? 'mp4')); + currentStreamId, + widget.containerExtension ?? 'mp4', + )); final controller = VideoPlayerController.networkUrl( Uri.parse(streamUrl), @@ -186,7 +192,10 @@ class _LitePlayerScreenState extends ConsumerState ); _controller = controller; - await controller.initialize(); + await controller.initialize().timeout(const Duration(seconds: 15), + onTimeout: () { + throw Exception('Connection timed out'); + }); if (!mounted) { await controller.dispose(); @@ -195,7 +204,7 @@ class _LitePlayerScreenState extends ConsumerState // Buffer Optimization: Pre-roll delay for TS streams if (widget.streamType == StreamType.live) { - await Future.delayed(const Duration(milliseconds: 3000)); + await Future.delayed(const Duration(milliseconds: 2000)); } await controller.play(); @@ -222,19 +231,15 @@ class _LitePlayerScreenState extends ConsumerState setState(() => _isPlaying = value.isPlaying); // Stabilization Masking: spinner stays until 1.5s of smooth play - final shouldShowLoading = value.isBuffering || _isStabilizing; - if (shouldShowLoading != _isLoading) - setState(() => _isLoading = shouldShowLoading); - - if (value.isPlaying && !value.isBuffering && _isStabilizing) { + // Fix: If playing, ignore isBuffering to prevent infinite spinner on some TS streams + // Force hide loader immediately when playing + if (value.isPlaying) { + if (_isLoading) setState(() => _isLoading = false); + if (_isStabilizing) _isStabilizing = false; _stabilizationTimer?.cancel(); - _stabilizationTimer = Timer(const Duration(milliseconds: 1500), () { - if (mounted) - setState(() { - _isStabilizing = false; - _isLoading = false; - }); - }); + } else { + final shouldShow = value.isBuffering || _isStabilizing; + if (shouldShow != _isLoading) setState(() => _isLoading = shouldShow); } if (!_isSeeking) { @@ -296,8 +301,10 @@ class _LitePlayerScreenState extends ConsumerState } if (key == LogicalKeyboardKey.channelDown) { if (widget.channels != null) - _switchChannel((_currentIndex - 1 + widget.channels!.length) % - widget.channels!.length); + _switchChannel( + (_currentIndex - 1 + widget.channels!.length) % + widget.channels!.length, + ); return true; } @@ -334,6 +341,7 @@ class _LitePlayerScreenState extends ConsumerState _epgTimer?.cancel(); _controlsTimer?.cancel(); _stabilizationTimer?.cancel(); + _controller?.pause(); // Force stop audio immediately _controller?.dispose(); _xtreamService?.dispose(); super.dispose(); @@ -359,36 +367,81 @@ class _LitePlayerScreenState extends ConsumerState : const CircularProgressIndicator(color: AppColors.primary), ), - // Top Overlay (Clock) - if (_showControls && settings.showClock) + // Top Bar (Back Button + Clock) + if (_showControls) Positioned( - top: 20, - right: 30, - child: Text(_currentTime, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - shadows: [ - Shadow(blurRadius: 10, color: Colors.black) - ])), + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Back Button + TVFocusable( + onPressed: () => Navigator.pop(context), + borderRadius: BorderRadius.circular(50), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.arrow_back, + color: Colors.white, + size: 28, + ), + ), + ), + // Clock + if (settings.showClock) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black45, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _currentTime, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), ), // Error Overlay if (_errorMessage != null) Center( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(8)), - child: Text(_errorMessage!, - style: const TextStyle(color: Colors.red)))), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + ), // Loading Spinner if (_isLoading) const Center( - child: CircularProgressIndicator(color: AppColors.primary)), + child: CircularProgressIndicator(color: AppColors.primary), + ), // OSD (EPG + Controls) if (_showControls) _buildOSD(title), @@ -404,15 +457,19 @@ class _LitePlayerScreenState extends ConsumerState return SizedBox.expand(child: VideoPlayer(_controller!)); if (mode == 'cover') return SizedBox.expand( - child: FittedBox( - fit: BoxFit.cover, - child: SizedBox( - width: _controller!.value.size.width, - height: _controller!.value.size.height, - child: VideoPlayer(_controller!)))); + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!), + ), + ), + ); return AspectRatio( - aspectRatio: _controller!.value.aspectRatio, - child: VideoPlayer(_controller!)); + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), + ); } Widget _buildOSD(String title) { @@ -448,38 +505,82 @@ class _LitePlayerScreenState extends ConsumerState children: [ if (widget.streamType == StreamType.live) TVFocusable( - focusNode: _prevFocusNode, - onPressed: () => _switchChannel((_currentIndex - + focusNode: _prevFocusNode, + onPressed: () => _switchChannel( + (_currentIndex - 1 + (widget.channels?.length ?? 0)) % - (widget.channels?.length ?? 1)), - child: const Icon(Icons.skip_previous, - color: Colors.white, size: 36)), + (widget.channels?.length ?? 1), + ), + child: const Icon( + Icons.skip_previous, + color: Colors.white, + size: 36, + ), + ), const SizedBox(width: 16), TVFocusable( - focusNode: _playPauseFocusNode, - onPressed: _togglePlayPause, - child: Icon( - _isPlaying - ? Icons.pause_circle_filled - : Icons.play_circle_filled, - color: Colors.white, - size: 56)), + focusNode: _playPauseFocusNode, + onPressed: _togglePlayPause, + child: Icon( + _isPlaying + ? Icons.pause_circle_filled + : Icons.play_circle_filled, + color: Colors.white, + size: 56, + ), + ), const SizedBox(width: 16), if (widget.streamType == StreamType.live) TVFocusable( - focusNode: _nextFocusNode, - onPressed: () => _switchChannel( - (_currentIndex + 1) % - (widget.channels?.length ?? 1)), - child: const Icon(Icons.skip_next, - color: Colors.white, size: 36)), + focusNode: _nextFocusNode, + onPressed: () => _switchChannel( + (_currentIndex + 1) % + (widget.channels?.length ?? 1), + ), + child: const Icon( + Icons.skip_next, + color: Colors.white, + size: 36, + ), + ), const SizedBox(width: 16), + const SizedBox(width: 16), + // Deinterlace Button (Switches to Native Player with forced Deinterlace) TVFocusable( - focusNode: _aspectFocusNode, - onPressed: _cycleAspectRatio, - child: const Icon(Icons.aspect_ratio, - color: Colors.white70, size: 28)), + onPressed: () { + // Save preference using streamId + final streamId = + widget.channels?[_currentIndex].streamId ?? + widget.streamId; + ref + .read(mobileSettingsProvider.notifier) + .toggleChannelDeinterlace(streamId); + + // Switch to NativePlayer with Deinterlace ON + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => NativePlayerScreen( + streamId: streamId, + title: widget.channels?[_currentIndex].name ?? + widget.title, + playlist: widget.playlist, + streamType: widget.streamType, + channels: widget.channels, + initialIndex: _currentIndex, + forceDeinterlace: true, + ), + ), + ); + }, + child: const Icon( + Icons + .grid_on, // Icon representing interlaced grid/mesh + color: Colors.white, + size: 32, + ), + ), ], ), ], @@ -507,42 +608,55 @@ class _LitePlayerScreenState extends ConsumerState if (widget.channels != null && widget.channels![_currentIndex].streamIcon.isNotEmpty) Container( - margin: const EdgeInsets.only(right: 12), - width: 40, - height: 40, - child: CachedNetworkImage( - imageUrl: widget.channels![_currentIndex].streamIcon)), + margin: const EdgeInsets.only(right: 12), + width: 40, + height: 40, + child: CachedNetworkImage( + imageUrl: widget.channels![_currentIndex].streamIcon, + ), + ), Expanded( - child: Text(title, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold))), + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), ], ), const SizedBox(height: 8), - Text(program, - style: const TextStyle( - color: AppColors.primary, - fontSize: 16, - fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis), + Text( + program, + style: const TextStyle( + color: AppColors.primary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), if (widget.streamType == StreamType.live && _epg?.nowPlaying != null) ...[ const SizedBox(height: 6), ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: progress, - backgroundColor: Colors.white10, - color: AppColors.primary, - minHeight: 4)), + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.white10, + color: AppColors.primary, + minHeight: 4, + ), + ), const SizedBox(height: 6), - Text(next, - style: const TextStyle(color: Colors.white54, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis), + Text( + next, + style: const TextStyle(color: Colors.white54, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ], ); @@ -553,27 +667,29 @@ class _LitePlayerScreenState extends ConsumerState padding: const EdgeInsets.only(bottom: 12), child: Actions( actions: { - DirectionalFocusIntent: - CallbackAction(onInvoke: (intent) { - if (intent.direction == TraversalDirection.up || - intent.direction == TraversalDirection.down) { - FocusScope.of(context).focusInDirection(intent.direction); + DirectionalFocusIntent: CallbackAction( + onInvoke: (intent) { + if (intent.direction == TraversalDirection.up || + intent.direction == TraversalDirection.down) { + FocusScope.of(context).focusInDirection(intent.direction); + return null; + } return null; - } - return null; - }) + }, + ), }, child: Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up), LogicalKeySet(LogicalKeyboardKey.arrowDown): - const DirectionalFocusIntent(TraversalDirection.down) + const DirectionalFocusIntent(TraversalDirection.down), }, child: Slider( - value: _position.inSeconds - .toDouble() - .clamp(0, _duration.inSeconds.toDouble()), + value: _position.inSeconds.toDouble().clamp( + 0, + _duration.inSeconds.toDouble(), + ), min: 0, max: _duration.inSeconds.toDouble(), activeColor: AppColors.primary, @@ -592,13 +708,4 @@ class _LitePlayerScreenState extends ConsumerState ), ); } - - void _cycleAspectRatio() { - final current = ref.read(mobileSettingsProvider).aspectRatioMode; - final next = current == 'contain' - ? 'cover' - : (current == 'cover' ? 'fill' : 'contain'); - ref.read(mobileSettingsProvider.notifier).setAspectRatioMode(next); - _onUserInteraction(); - } } diff --git a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart index 62a95e1..470e9e6 100644 --- a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../../core/models/playlist_config.dart'; import '../../../widgets/mobile_scaffold.dart'; import '../../../theme/mobile_theme.dart'; @@ -48,15 +49,12 @@ class _MobileDashboardScreenState extends ConsumerState { return Theme( data: MobileTheme.darkTheme, - child: PopScope( - canPop: false, // Prevent accidental exit to playlist selection - child: MobileScaffold( - currentIndex: currentIndex, - onIndexChanged: (index) { - ref.read(mobileDashboardIndexProvider.notifier).state = index; - }, - child: currentTab, - ), + child: MobileScaffold( + currentIndex: currentIndex, + onIndexChanged: (index) { + ref.read(mobileDashboardIndexProvider.notifier).state = index; + }, + child: currentTab, ), ); } diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index da45911..ad5c7e1 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -12,6 +12,8 @@ import 'package:xtremflow/core/models/playlist_config.dart'; import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart'; import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremflow/core/theme/app_colors.dart'; +import 'package:xtremflow/mobile/features/iptv/screens/lite_player_screen.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:xtremflow/mobile/providers/mobile_xtream_providers.dart'; /// Stream type enum for player @@ -33,6 +35,9 @@ class NativePlayerScreen extends ConsumerStatefulWidget { final int? season; final int? episodeNum; + // Experimental + final bool forceDeinterlace; + const NativePlayerScreen({ super.key, required this.streamId, @@ -46,6 +51,7 @@ class NativePlayerScreen extends ConsumerStatefulWidget { this.seriesId, this.season, this.episodeNum, + this.forceDeinterlace = false, }); final Duration? initialPosition; @@ -74,7 +80,7 @@ class _NativePlayerScreenState extends ConsumerState bool _isPlaying = false; Duration _position = Duration.zero; Duration _duration = Duration.zero; - bool _isSeeking = false; + final bool _isSeeking = false; bool _useSoftwareDecoder = false; Timer? _clockTimer; @@ -135,8 +141,14 @@ class _NativePlayerScreenState extends ConsumerState (_player.platform as dynamic)?.setProperty('hwdec', decoderMode); // Performance & Scaling Fixes for Android - (_player.platform as dynamic)?.setProperty('opengl-pbo', 'yes'); + // 'opengl-pbo' removed to save VRAM on low-end devices preventing long-run crashes (_player.platform as dynamic)?.setProperty('video-unscaled', 'no'); + + if (widget.forceDeinterlace) { + debugPrint('MediaKitPlayer: Forcing Deinterlace ON'); + (_player.platform as dynamic)?.setProperty('deinterlace', 'yes'); + } + debugPrint('MediaKitPlayer: Decoder Mode set to $decoderMode'); // ============ PERFORMANCE 3.0 (ANTI MICRO-COUPURE PROFILE) ============ @@ -149,39 +161,46 @@ class _NativePlayerScreenState extends ConsumerState if (widget.streamType == StreamType.live) { // LIVE PROFILE: Increased stability, no longer low-latency (prevents cuts) debugPrint( - 'MediaKitPlayer: Applying LIVE optimization profile (STABILITY)'); + 'MediaKitPlayer: Applying LIVE optimization profile (STABILITY)', + ); (_player.platform as dynamic) ?.setProperty('cache-secs', '15'); // 15s cache - (_player.platform as dynamic) - ?.setProperty('demuxer-max-bytes', '64000000'); // 64MB + (_player.platform as dynamic)?.setProperty( + 'demuxer-max-bytes', + '32000000', + ); // Reduced to 32MB (approx 20-30s @ 10Mbps) for stability (_player.platform as dynamic) ?.setProperty('demuxer-readahead-secs', '15'); (_player.platform as dynamic) ?.setProperty('demuxer-max-back-bytes', '10000000'); + // Ensure temp files are cleaned up + (_player.platform as dynamic)?.setProperty('cache-unlink-files', 'yes'); // ALWAYS wait for buffer fill to prevent glitches (_player.platform as dynamic)?.setProperty('cache-pause-initial', 'yes'); (_player.platform as dynamic) - ?.setProperty('cache-pause-wait', '3'); // Wait 3s max + ?.setProperty('cache-pause-wait', '6'); // Wait 6s max } else { // VOD PROFILE: Max stability, large buffer - debugPrint('MediaKitPlayer: Applying VOD optimization profile'); - (_player.platform as dynamic)?.setProperty('cache-secs', '120'); + // VOD PROFILE: Max stability, moderate buffer (reduced from 100MB to fix startup heap issues) + debugPrint('MediaKitPlayer: Applying VOD optimization profile (FIX)'); + (_player.platform as dynamic)?.setProperty('cache-secs', '50'); (_player.platform as dynamic) - ?.setProperty('demuxer-max-bytes', '100000000'); // 100MB + ?.setProperty('demuxer-max-bytes', '50000000'); // 50MB (_player.platform as dynamic) - ?.setProperty('demuxer-readahead-secs', '120'); + ?.setProperty('demuxer-readahead-secs', '60'); (_player.platform as dynamic) - ?.setProperty('demuxer-max-back-bytes', '20000000'); // 20MB back + ?.setProperty('demuxer-max-back-bytes', '10000000'); // 10MB back - // Wait a bit to fill buffer for smooth playback - (_player.platform as dynamic)?.setProperty('cache-pause-initial', 'yes'); - (_player.platform as dynamic)?.setProperty('cache-pause-wait', '5'); + // Disable initial cache pause to allow immediate playback attempt + (_player.platform as dynamic)?.setProperty('cache-pause-initial', 'no'); + // (_player.platform as dynamic)?.setProperty('cache-pause-wait', '5'); } // Audio/Video Sync - critical for smooth playback + // Changed to 'audio' (default) instead of 'display-resample' to prevent resource exhaustion/drift over time (_player.platform as dynamic) - ?.setProperty('video-sync', 'display-resample'); // Better A/V sync + ?.setProperty('video-sync', 'audio'); // More stable for long streams (_player.platform as dynamic) ?.setProperty('interpolation', 'no'); // Keep off for safety (_player.platform as dynamic) @@ -259,7 +278,10 @@ class _NativePlayerScreenState extends ConsumerState // Listen to player state _player.stream.playing.listen((playing) { if (mounted) { - setState(() => _isPlaying = playing); + setState(() { + _isPlaying = playing; + if (playing) _errorMessage = null; // Clear error on successful play + }); if (playing) { // Smooth Loading: Hide loading screen 1s AFTER playback actually starts // This masks initial glitches/re-buffering @@ -868,6 +890,7 @@ class _NativePlayerScreenState extends ConsumerState if (key == LogicalKeyboardKey.channelDown) { if (widget.streamType == StreamType.live && widget.channels != null) { _playPrevious(); + _onUserInteraction(); return true; } } @@ -876,6 +899,7 @@ class _NativePlayerScreenState extends ConsumerState if (key == LogicalKeyboardKey.channelUp) { if (widget.streamType == StreamType.live && widget.channels != null) { _playNext(); + _onUserInteraction(); return true; } } @@ -957,7 +981,8 @@ class _NativePlayerScreenState extends ConsumerState controller: _controller, controls: NoVideoControls, // We use our own custom controls fit: _getBoxFit( - ref.watch(mobileSettingsProvider).aspectRatioMode), + ref.watch(mobileSettingsProvider).aspectRatioMode, + ), ), ), @@ -970,7 +995,7 @@ class _NativePlayerScreenState extends ConsumerState ), // Custom controls overlay - if (_showControls && _errorMessage == null && !_isLoading) + if (_showControls && _errorMessage == null) _buildControlsOverlay(title), // Top bar (always visible when controls are shown) @@ -1117,16 +1142,214 @@ class _NativePlayerScreenState extends ConsumerState return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; } + Widget _buildUnifiedOSD(String title) { + return Positioned( + bottom: 20, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white10), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // EPG Info (Left) + Expanded(child: _buildEPGBox(title)), + const SizedBox(width: 24), + // Controls Continuity (Right) + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Prev Channel + TVFocusable( + focusNode: _prevFocusNode, + onPressed: _playPrevious, + onFocus: _resetControlsTimer, + child: const Icon( + Icons.skip_previous, + color: Colors.white, + size: 36, + ), + ), + const SizedBox(width: 16), + + // Play/Pause + TVFocusable( + focusNode: _playPauseFocusNode, + onPressed: _togglePlayPause, + onFocus: _resetControlsTimer, + child: Icon( + _isPlaying + ? Icons.pause_circle_filled + : Icons.play_circle_filled, + color: Colors.white, + size: 56, + ), + ), + const SizedBox(width: 16), + + // Next Channel + TVFocusable( + focusNode: _nextFocusNode, + onPressed: _playNext, + onFocus: _resetControlsTimer, + child: const Icon( + Icons.skip_next, + color: Colors.white, + size: 36, + ), + ), + const SizedBox(width: 16), + + // Disable Deinterlace Button + if (widget.forceDeinterlace) ...[ + const SizedBox(width: 16), + TVFocusable( + onPressed: () { + final streamId = + widget.channels?[_currentIndex].streamId ?? + widget.streamId; + ref + .read(mobileSettingsProvider.notifier) + .toggleChannelDeinterlace(streamId); + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => LitePlayerScreen( + streamId: streamId, + title: widget.channels?[_currentIndex].name ?? + widget.title, + playlist: widget.playlist, + streamType: widget.streamType, + channels: widget.channels, + initialIndex: _currentIndex, + ), + ), + ); + }, + onFocus: _resetControlsTimer, + child: const Icon( + Icons.grid_off, + color: Colors.white, + size: 32, + ), + ), + ], + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildEPGBox(String title) { + String nowPlaying = _epg?.nowPlaying ?? "Pas d'infos EPG"; + String nextPlaying = + _epg?.nextPlaying != null ? "Suivant: ${_epg!.nextPlaying}" : ""; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (widget.channels != null && + widget.channels![_currentIndex].streamIcon.isNotEmpty) + Container( + margin: const EdgeInsets.only(right: 12), + width: 40, + height: 40, + child: CachedNetworkImage( + imageUrl: widget.channels![_currentIndex].streamIcon, + fit: BoxFit.contain, + errorWidget: (_, __, ___) => + const Icon(Icons.tv, color: Colors.white38), + ), + ), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + nowPlaying, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Live Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: const EdgeInsets.only(left: 8), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + if (nextPlaying.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + nextPlaying, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + Widget _buildControlsOverlay(String title) { - final settings = ref.watch(mobileSettingsProvider); + if (widget.streamType == StreamType.live) { + return _buildUnifiedOSD(title); + } + return Positioned.fill( child: Container( color: Colors.black.withOpacity(0.4), child: Stack( children: [ - // Removed Duplicate Back Button (Top Left) - handled by Top Bar now - - // Bottom Controls (Aligned with EPG) + // Bottom Controls (VOD - Centered) Positioned( bottom: 80, left: 0, @@ -1135,46 +1358,28 @@ class _NativePlayerScreenState extends ConsumerState child: Row( mainAxisSize: MainAxisSize.min, children: [ - // Prev Channel / Replay 10s - if (widget.streamType == StreamType.live && - widget.channels != null && - widget.channels!.isNotEmpty) - TVFocusable( - focusNode: _prevFocusNode, - onPressed: _playPrevious, - onFocus: _resetControlsTimer, - borderRadius: BorderRadius.circular(50), - child: IconButton( - icon: const Icon( - Icons.skip_previous, - color: Colors.white, - size: 48, - ), - onPressed: _playPrevious, + // Prev 10s + TVFocusable( + focusNode: _prevFocusNode, + onPressed: () async { + final pos = await _player.stream.position.first; + _player.seek(pos - const Duration(seconds: 5)); + _resetControlsTimer(); + }, + onFocus: _resetControlsTimer, + borderRadius: BorderRadius.circular(50), + child: IconButton( + icon: const Icon( + Icons.replay_10, + color: Colors.white, + size: 48, ), - ) - else if (widget.streamType != StreamType.live) - TVFocusable( - focusNode: _prevFocusNode, onPressed: () async { final pos = await _player.stream.position.first; _player.seek(pos - const Duration(seconds: 5)); - _resetControlsTimer(); }, - onFocus: _resetControlsTimer, - borderRadius: BorderRadius.circular(50), - child: IconButton( - icon: const Icon( - Icons.replay_10, - color: Colors.white, - size: 48, - ), - onPressed: () async { - final pos = await _player.stream.position.first; - _player.seek(pos - const Duration(seconds: 5)); - }, - ), ), + ), const SizedBox(width: 32), @@ -1183,7 +1388,7 @@ class _NativePlayerScreenState extends ConsumerState focusNode: _playPauseFocusNode, onPressed: _togglePlayPause, onFocus: _resetControlsTimer, - scale: 1.1, // Larger scale for main button + scale: 1.1, borderRadius: BorderRadius.circular(50), child: IconButton( iconSize: 72, @@ -1199,130 +1404,32 @@ class _NativePlayerScreenState extends ConsumerState const SizedBox(width: 32), - // Next Channel / Forward 10s - if (widget.streamType == StreamType.live && - widget.channels != null && - widget.channels!.isNotEmpty) - TVFocusable( - focusNode: _nextFocusNode, - onPressed: _playNext, - onFocus: _resetControlsTimer, - borderRadius: BorderRadius.circular(50), - child: IconButton( - icon: const Icon( - Icons.skip_next, - color: Colors.white, - size: 48, - ), - onPressed: _playNext, + // Next 10s + TVFocusable( + focusNode: _nextFocusNode, + onPressed: () async { + final pos = await _player.stream.position.first; + _player.seek(pos + const Duration(seconds: 5)); + _resetControlsTimer(); + }, + onFocus: _resetControlsTimer, + borderRadius: BorderRadius.circular(50), + child: IconButton( + icon: const Icon( + Icons.forward_10, + color: Colors.white, + size: 48, ), - ) - else if (widget.streamType != StreamType.live) - TVFocusable( - focusNode: _nextFocusNode, onPressed: () async { final pos = await _player.stream.position.first; _player.seek(pos + const Duration(seconds: 5)); - _resetControlsTimer(); }, - onFocus: _resetControlsTimer, - borderRadius: BorderRadius.circular(50), - child: IconButton( - icon: const Icon( - Icons.forward_10, - color: Colors.white, - size: 48, - ), - onPressed: () async { - final pos = await _player.stream.position.first; - _player.seek(pos + const Duration(seconds: 5)); - }, - ), - ), - ], - ), - ), - ), - - // EPG Box (Bottom Left) + LIVE Badge - if (widget.streamType == StreamType.live && - _epg != null && - _epg!.nowPlaying != null) - Positioned( - bottom: 180, // Moved up to avoid overlap with central buttons - left: 24, - width: 350, // Increased width for badge - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white24), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _epg!.nowPlaying!, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (_epg!.nextPlaying != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - "A suivre: ${_epg!.nextPlaying}", - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - // LIVE Badge inside the box, right side - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - margin: const EdgeInsets.only(left: 8), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LIVE', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ], - ), ), ), ], ), ), + ), // Progress Bar / Time (Bottom) Positioned( @@ -1341,112 +1448,70 @@ class _NativePlayerScreenState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (widget.streamType != StreamType.live) - Row( - children: [ - Text( - _formatDuration(_position), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), + Row( + children: [ + Text( + _formatDuration(_position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, ), - Expanded( - child: Actions( - actions: >{ - // Override directional focus intents for Up/Down - DirectionalFocusIntent: - CallbackAction( - onInvoke: (intent) { - if (intent.direction == - TraversalDirection.up || - intent.direction == - TraversalDirection.down) { - // Manually move focus in the requested direction - FocusScope.of(context) - .focusInDirection(intent.direction); - return null; - } - // For left/right, don't handle (let Slider do seeking) + ), + Expanded( + child: Actions( + actions: >{ + DirectionalFocusIntent: + CallbackAction( + onInvoke: (intent) { + if (intent.direction == + TraversalDirection.up || + intent.direction == + TraversalDirection.down) { + FocusScope.of(context) + .focusInDirection(intent.direction); return null; - }, + } + return null; + }, + ), + }, + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowUp): + const DirectionalFocusIntent( + TraversalDirection.up, + ), + LogicalKeySet(LogicalKeyboardKey.arrowDown): + const DirectionalFocusIntent( + TraversalDirection.down, ), }, - child: Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.arrowUp): - const DirectionalFocusIntent( - TraversalDirection.up), - LogicalKeySet(LogicalKeyboardKey.arrowDown): - const DirectionalFocusIntent( - TraversalDirection.down), + child: Slider( + value: _position.inSeconds + .toDouble() + .clamp(0, _duration.inSeconds.toDouble()), + min: 0.0, + max: _duration.inSeconds.toDouble(), + onChanged: (value) { + _player + .seek(Duration(seconds: value.toInt())); + _resetControlsTimer(); }, - child: Slider( - value: _position.inSeconds - .toDouble() - .clamp(0, _duration.inSeconds.toDouble()), - min: 0, - max: _duration.inSeconds.toDouble(), - divisions: _duration.inSeconds > 0 - ? (_duration.inSeconds / 10).ceil() - : null, - activeColor: AppColors.primary, - inactiveColor: Colors.white24, - onChangeStart: (_) => _isSeeking = true, - onChangeEnd: (value) async { - await _player - .seek(Duration(seconds: value.toInt())); - _isSeeking = false; - }, - onChanged: (value) { - setState(() { - _position = - Duration(seconds: value.toInt()); - }); - }, - ), + activeColor: AppColors.primary, + inactiveColor: Colors.white24, ), ), ), - Text( - _formatDuration(_duration), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ], - ) - else - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.streamType == StreamType.live && - settings.showClock) - // Clock - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _currentTime, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ], ), - ), + Text( + _formatDuration(_duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), ], ), ), diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index f4e2085..3754123 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -74,10 +74,8 @@ class _MobileLiveTVTabState extends ConsumerState backgroundColor: Colors.transparent, body: PopScope( // Only allow full pop if we're at category view with no filters AND not just returned from player - canPop: uiState.isCategoryView && - _searchQuery.isEmpty && - !_showFavoritesOnly, - onPopInvokedWithResult: (didPop, _) { + canPop: false, + onPopInvokedWithResult: (didPop, _) async { if (didPop) return; // If just returned from player, reset the flag and do nothing (stay on channel list) @@ -95,6 +93,43 @@ class _MobileLiveTVTabState extends ConsumerState setState(() => _showFavoritesOnly = false); } else if (!uiState.isCategoryView) { uiNotifier.state = uiState.copyWith(isCategoryView: true); + } else { + // Root View (Categories) -> Show Exit Dialog + final shouldExit = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.grey[900], + title: const Text( + "Quitter l'application", + style: TextStyle(color: Colors.white), + ), + content: const Text( + "Voulez-vous vraiment quitter l'application ?", + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + autofocus: true, + child: const Text( + 'Annuler', + style: TextStyle(color: Colors.blueAccent), + ), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text( + 'Quitter', + style: TextStyle(color: Colors.redAccent), + ), + ), + ], + ), + ); + + if (shouldExit == true) { + SystemNavigator.pop(); + } } }, child: channelsAsync.when( @@ -165,7 +200,9 @@ class _MobileLiveTVTabState extends ConsumerState // Header (Search + Title/Back) Padding( padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), + horizontal: 16, + vertical: 8, + ), child: Column( children: [ // Search Bar - Wrapped in TVFocusable for remote access @@ -193,8 +230,10 @@ class _MobileLiveTVTabState extends ConsumerState const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - const Icon(Icons.search, - color: AppColors.textSecondary), + const Icon( + Icons.search, + color: AppColors.textSecondary, + ), const SizedBox(width: 8), Expanded( child: ExcludeFocus( @@ -206,7 +245,8 @@ class _MobileLiveTVTabState extends ConsumerState focusNode: _searchFocusNode, readOnly: !_isSearchEditing, style: const TextStyle( - color: AppColors.textPrimary), + color: AppColors.textPrimary, + ), decoration: const InputDecoration( hintText: 'Rechercher une chaîne...', border: InputBorder.none, @@ -216,15 +256,18 @@ class _MobileLiveTVTabState extends ConsumerState isDense: true, ), onSubmitted: (_) => setState( - () => _isSearchEditing = false), + () => _isSearchEditing = false, + ), ), ), ), if (_searchQuery.isNotEmpty) GestureDetector( onTap: () => _searchController.clear(), - child: const Icon(Icons.close, - color: AppColors.textSecondary), + child: const Icon( + Icons.close, + color: AppColors.textSecondary, + ), ), ], ), @@ -240,17 +283,21 @@ class _MobileLiveTVTabState extends ConsumerState _showFavoritesOnly || _searchQuery.isNotEmpty) ...[ IconButton( - icon: const Icon(Icons.arrow_back, - color: AppColors.textPrimary), + icon: const Icon( + Icons.arrow_back, + color: AppColors.textPrimary, + ), onPressed: () { if (_searchQuery.isNotEmpty) { _searchController.clear(); } else if (_showFavoritesOnly) { setState( - () => _showFavoritesOnly = false); + () => _showFavoritesOnly = false, + ); } else { uiNotifier.state = uiState.copyWith( - isCategoryView: true); + isCategoryView: true, + ); } }, padding: EdgeInsets.zero, @@ -288,13 +335,15 @@ class _MobileLiveTVTabState extends ConsumerState onPressed: () => setState(() { _showFavoritesOnly = !_showFavoritesOnly; // Reset category view if entering favorites - if (_showFavoritesOnly) + if (_showFavoritesOnly) { uiNotifier.state = uiState.copyWith(isCategoryView: false); + } // If exiting favorites, default depends on logic (here back to category grid if was previously) - if (!_showFavoritesOnly) + if (!_showFavoritesOnly) { uiNotifier.state = uiState.copyWith(isCategoryView: true); + } }), ), ], @@ -351,12 +400,12 @@ class _MobileLiveTVTabState extends ConsumerState borderRadius: BorderRadius.circular(16), child: Container( decoration: BoxDecoration( - gradient: LinearGradient( + gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - const Color(0xFF4A4A4C), // Lighter grey top - const Color(0xFF2C2C2E), // Medium grey bottom + Color(0xFF4A4A4C), // Lighter grey top + Color(0xFF2C2C2E), // Medium grey bottom ], ), borderRadius: BorderRadius.circular(16), @@ -429,12 +478,20 @@ class _MobileLiveTVTabState extends ConsumerState ); } - void _playChannel(BuildContext context, Channel channel, - List channels, PlaylistConfig playlist, int index) async { + void _playChannel( + BuildContext context, + Channel channel, + List channels, + PlaylistConfig playlist, + int index, + ) async { final settings = ref.read(mobileSettingsProvider); - final useNative = settings.playerEngine == 'ultra'; - if (useNative) { + // Check if channel has forced deinterlacing enabled + final forceDeinterlace = + settings.deinterlacedChannels.contains(channel.streamId); + + if (forceDeinterlace) { await Navigator.push( context, MaterialPageRoute( @@ -445,6 +502,7 @@ class _MobileLiveTVTabState extends ConsumerState streamType: StreamType.live, channels: channels, // Pass full channel list for zapping initialIndex: index, + forceDeinterlace: true, ), ), ); @@ -529,12 +587,12 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> { }, child: Container( decoration: BoxDecoration( - gradient: LinearGradient( + gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - const Color(0xFF4A4A4C), // Lighter grey top - const Color(0xFF2C2C2E), // Medium grey bottom + Color(0xFF4A4A4C), // Lighter grey top + Color(0xFF2C2C2E), // Medium grey bottom ], ), borderRadius: BorderRadius.circular(12), @@ -559,13 +617,22 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> { ? CachedNetworkImage( imageUrl: iconUrl, fit: BoxFit.contain, - errorWidget: (_, __, ___) => const Icon(Icons.tv, - color: Colors.white38, size: 40), - placeholder: (_, __) => const Icon(Icons.tv, - color: Colors.white24, size: 40), + errorWidget: (_, __, ___) => const Icon( + Icons.tv, + color: Colors.white38, + size: 40, + ), + placeholder: (_, __) => const Icon( + Icons.tv, + color: Colors.white24, + size: 40, + ), ) - : const Icon(Icons.tv, - color: Colors.white38, size: 40), + : const Icon( + Icons.tv, + color: Colors.white38, + size: 40, + ), ), // Favorite badge if (isFavorite) @@ -578,8 +645,11 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> { color: Colors.red, borderRadius: BorderRadius.circular(12), ), - child: const Icon(Icons.favorite, - color: Colors.white, size: 12), + child: const Icon( + Icons.favorite, + color: Colors.white, + size: 12, + ), ), ), ], diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index 576749f..f401a1c 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -6,13 +6,10 @@ import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; import 'package:xtremflow/mobile/widgets/mobile_poster_card.dart'; import '../../../../core/models/playlist_config.dart'; import '../../../../core/theme/app_colors.dart'; -import '../../../../core/widgets/components/hero_carousel.dart'; -import '../../../../core/widgets/components/ui_components.dart'; import '../../../providers/mobile_settings_providers.dart'; import '../../../providers/mobile_xtream_providers.dart'; import '../../../../features/iptv/services/xtream_service_mobile.dart'; import '../screens/native_player_screen.dart'; -import '../screens/lite_player_screen.dart'; class MobileMoviesTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -27,25 +24,30 @@ class _MobileMoviesTabState extends ConsumerState final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); - final List _movies = []; + + // Data + List> _categories = []; + List _categoryMovies = []; + String? _selectedCategoryId; + String? _selectedCategoryName; + + // Search List? _searchResults; String _searchQuery = ''; bool _isSearchEditing = false; - bool _isLoading = false; bool _isSearching = false; - bool _hasMore = true; - int _currentOffset = 0; - static const int _pageSize = 50; Timer? _searchDebounce; + // State + bool _isLoading = false; + @override void initState() { super.initState(); - _scrollController.addListener(_onScroll); - // Defer initial load to after first frame to ensure providers are ready + // Defer initial load WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _movies.isEmpty) { - _loadMoreMovies(); + if (mounted && _categories.isEmpty) { + _loadCategories(); } }); } @@ -59,16 +61,6 @@ class _MobileMoviesTabState extends ConsumerState super.dispose(); } - void _onScroll() { - // Only trigger load when near bottom and not already loading - if (!_isLoading && - _hasMore && - _scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8) { - _loadMoreMovies(); - } - } - void _onSearchChanged(String query) { _searchDebounce?.cancel(); setState(() { @@ -106,40 +98,66 @@ class _MobileMoviesTabState extends ConsumerState } } - Future _loadMoreMovies() async { - // Prevent race conditions - if (_isLoading || !_hasMore || !mounted) return; - + Future _loadCategories() async { + if (_isLoading || !mounted) return; setState(() => _isLoading = true); try { final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); - - // Add timeout to prevent infinite loading state - final newMovies = await service - .getMoviesPaginated( - offset: _currentOffset, - limit: _pageSize, - ) - .timeout(const Duration(seconds: 15)); + final categoriesMap = await service.getVodCategories(); if (mounted) { setState(() { - _movies.addAll(newMovies); - _currentOffset += _pageSize; - _hasMore = newMovies.length == _pageSize; + _categories = categoriesMap.entries.toList() + ..sort((a, b) => a.value.compareTo(b.value)); _isLoading = false; }); } } catch (e) { - debugPrint('Error loading movies: $e'); + if (mounted) setState(() => _isLoading = false); + } + } + + Future _selectCategory(String id, String name) async { + setState(() { + _selectedCategoryId = id; + _selectedCategoryName = name; + _categoryMovies = []; + _isLoading = true; + }); + + try { + final service = + await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + final movies = await service.getMoviesByCategory(id); + if (mounted) { setState(() { + _categoryMovies = movies; _isLoading = false; - // Do NOT set _hasMore to false on error }); } + } catch (e) { + if (mounted) setState(() => _isLoading = false); + } + } + + void _onBack() { + if (_searchQuery.isNotEmpty) { + _onSearchChanged(''); + _searchController.clear(); + setState(() => _isSearchEditing = false); + return; + } + + if (_selectedCategoryId != null) { + setState(() { + _selectedCategoryId = null; + _selectedCategoryName = null; + _categoryMovies = []; + }); + return; } } @@ -152,15 +170,16 @@ class _MobileMoviesTabState extends ConsumerState } catch (_) {} setState(() { - _movies.clear(); - _currentOffset = 0; - _hasMore = true; + _categories.clear(); + _selectedCategoryId = null; + _selectedCategoryName = null; + _categoryMovies.clear(); _searchResults = null; _searchQuery = ''; _searchController.clear(); _isLoading = false; }); - await _loadMoreMovies(); + await _loadCategories(); } String? _formatRating(String? rating) { @@ -179,16 +198,13 @@ class _MobileMoviesTabState extends ConsumerState } void _playMovie(BuildContext context, Movie movie) { - if (widget.playlist == null) return; - - // Enforce Native Player for Movies (VOD) Navigator.push( context, MaterialPageRoute( builder: (context) => NativePlayerScreen( streamId: movie.streamId, title: movie.name, - playlist: widget.playlist!, + playlist: widget.playlist, streamType: StreamType.vod, containerExtension: movie.containerExtension, ), @@ -205,192 +221,297 @@ class _MobileMoviesTabState extends ConsumerState final settings = ref.watch(mobileSettingsProvider); final watchHistory = ref.watch(mobileWatchHistoryProvider); - // Retry load if movies list is empty (fixes issue where EPG loads but tab needs refresh) - if (_movies.isEmpty && !_isLoading && _hasMore) { + // Initial load retry + if (_categories.isEmpty && !_isLoading) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _movies.isEmpty) { - _loadMoreMovies(); + if (mounted && _categories.isEmpty) { + _loadCategories(); } }); } - List displayMovies; - if (_searchQuery.isNotEmpty && _searchResults != null) { - displayMovies = _searchResults!; - } else { - displayMovies = settings.moviesKeywords.isEmpty - ? _movies - : _movies - .where((m) => settings.matchesMoviesFilter(m.categoryName)) - .toList(); - } + // Determine what to display + final bool showingCategories = + _searchQuery.isEmpty && _selectedCategoryId == null; + final bool showingMovies = + _searchQuery.isEmpty && _selectedCategoryId != null; + final bool showingSearch = _searchQuery.isNotEmpty; - // Hero Items (use filtered movies) - final heroItems = displayMovies - .take(3) - .map((m) => HeroItem( - id: m.streamId, - title: m.name, - imageUrl: _getImageUrl(m.streamIcon), - subtitle: - m.rating != null ? '${_formatRating(m.rating)} ★' : null, - onMoreInfo: () => _playMovie(context, m), - )) + // Filter categories based on settings + final filteredCategories = _categories + .where((entry) => settings.matchesMoviesFilter(entry.value)) .toList(); + int itemCount = 0; + if (showingCategories) { + itemCount = filteredCategories.length; + } else if (showingMovies) { + itemCount = _categoryMovies.length; + } else if (showingSearch) { + itemCount = _searchResults?.length ?? 0; + } + return Container( decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), - child: SafeArea( - bottom: false, - child: RefreshIndicator( - onRefresh: _refresh, - color: AppColors.primary, - backgroundColor: AppColors.surface, - child: CustomScrollView( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // Hero Section (reduced height, moved above search) - if (_searchQuery.isEmpty && heroItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8), - child: SizedBox( - height: 180, // Reduced from 250 - child: HeroCarousel( - items: heroItems, - onTap: (item) { - try { - final movie = _movies.firstWhere( - (element) => element.streamId == item.id); - _playMovie(context, movie); - } catch (e) { - // Fallback ignored - } - }, - ), - ), + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + + // Handle Search Exit + if (_searchQuery.isNotEmpty || _isSearching) { + _onSearchChanged(''); // clear search + _searchController.clear(); + setState(() => _isSearchEditing = false); + return; + } + + // Handle Category Back + if (_selectedCategoryId != null) { + _onBack(); + return; + } + + // Root View -> Show Exit Dialog + final shouldExit = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.grey[900], + title: const Text( + "Quitter l'application", + style: TextStyle(color: Colors.white), + ), + content: const Text( + "Voulez-vous vraiment quitter l'application ?", + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + autofocus: true, + child: const Text( + 'Annuler', + style: TextStyle(color: Colors.blueAccent), ), ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text( + 'Quitter', + style: TextStyle(color: Colors.redAccent), + ), + ), + ], + ), + ); - // Search Bar (moved below carousel) - SliverToBoxAdapter( - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TVFocusable( - scale: 1.0, // Disable scaling to prevent overflow - focusColor: Colors.white, // Solid white selection frame - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 40, - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - // Removed internal editing border here to prevent conflict and "inside frame" look - ), - padding: const EdgeInsets.symmetric(horizontal: 12), + if (shouldExit == true) { + SystemNavigator.pop(); + } + }, + child: SafeArea( + bottom: false, + child: RefreshIndicator( + onRefresh: _refresh, + color: AppColors.primary, + backgroundColor: AppColors.surface, + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Category Title (If inside a category) + if (showingMovies) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), child: Row( children: [ - const Icon(Icons.search, - size: 20, color: AppColors.textSecondary), + IconButton( + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + onPressed: _onBack, + ), const SizedBox(width: 8), Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: Colors.white, // Extra safety - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: const TextStyle( - fontSize: 14, color: AppColors.textPrimary), - decoration: const InputDecoration( - hintText: 'Rechercher un film...', - border: InputBorder.none, - focusedBorder: InputBorder - .none, // Explicitly remove focus border - enabledBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(bottom: 11), - ), - onChanged: _onSearchChanged, - onSubmitted: (_) => - setState(() => _isSearchEditing = false), + child: Text( + _selectedCategoryName ?? 'Films', + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, ), ), ), - if (_isSearching) - const SizedBox( - width: 12, - height: 12, - child: - CircularProgressIndicator(strokeWidth: 2)), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: const Icon(Icons.close, - size: 16, color: AppColors.textSecondary), - ), ], ), ), ), - ), - ), - // Grid - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.7, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index >= displayMovies.length) return null; - final movie = displayMovies[index]; - final isWatched = - watchHistory.isMovieWatched(movie.streamId); - - return MobilePosterCard( - title: movie.name, - imageUrl: _getImageUrl(movie.streamIcon), - rating: _formatRating(movie.rating), - isWatched: isWatched, - onTap: () => _playMovie(context, movie), - onLongPress: () { - ref - .read(mobileWatchHistoryProvider.notifier) - .toggleMovieWatched(movie.streamId); - }, - ); - }, - childCount: displayMovies.length, - ), - ), - ), - - if (_isLoading) - const SliverToBoxAdapter( + // Search Bar + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TVFocusable( + scale: 1.0, + focusColor: Colors.white, + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 40, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + const Icon( + Icons.search, + size: 20, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: Colors.white, + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + ), + decoration: const InputDecoration( + hintText: 'Rechercher un film...', + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(bottom: 11), + ), + onChanged: _onSearchChanged, + onSubmitted: (_) => + setState(() => _isSearchEditing = false), + ), + ), + ), + if (_isSearching) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: const Icon( + Icons.close, + size: 16, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), ), ), - ], + + // Grid + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: showingCategories + ? 4 + : 8, // Bigger cards for categories + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: showingCategories + ? 2.5 + : 0.7, // Wide cards for categories + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (showingCategories) { + if (index >= filteredCategories.length) return null; + final cat = filteredCategories[index]; + return MobilePosterCard( + title: cat.value, // Name + imageUrl: null, // No image for category + placeholderIcon: Icons.folder, // Folder icon + onTap: () => _selectCategory(cat.key, cat.value), + isWatched: false, + ); + } else if (showingMovies) { + if (index >= _categoryMovies.length) return null; + final movie = _categoryMovies[index]; + final isWatched = + watchHistory.isMovieWatched(movie.streamId); + return MobilePosterCard( + title: movie.name, + imageUrl: _getImageUrl(movie.streamIcon), + rating: _formatRating(movie.rating), + isWatched: isWatched, + onTap: () => _playMovie(context, movie), + onLongPress: () { + ref + .read(mobileWatchHistoryProvider.notifier) + .toggleMovieWatched(movie.streamId); + }, + ); + } else if (showingSearch) { + if (_searchResults == null || + index >= _searchResults!.length) { + return null; + } + final movie = _searchResults![index]; + final isWatched = + watchHistory.isMovieWatched(movie.streamId); + return MobilePosterCard( + title: movie.name, + imageUrl: _getImageUrl(movie.streamIcon), + rating: _formatRating(movie.rating), + isWatched: isWatched, + onTap: () => _playMovie(context, movie), + onLongPress: () { + ref + .read(mobileWatchHistoryProvider.notifier) + .toggleMovieWatched(movie.streamId); + }, + ); + } + return null; + }, + childCount: itemCount, + ), + ), + ), + + if (_isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ), + ], + ), ), ), ), diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index f94ff0a..9910748 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -219,150 +219,212 @@ class _MobileSeriesTabState extends ConsumerState return Container( decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), - child: SafeArea( - bottom: false, - child: RefreshIndicator( - onRefresh: _refresh, - color: AppColors.primary, - backgroundColor: AppColors.surface, - child: CustomScrollView( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // Hero Section (reduced height, moved above search) - if (_searchQuery.isEmpty && heroItems.isNotEmpty) + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + + if (_searchQuery.isNotEmpty || _isSearching) { + _onSearchChanged(''); // clear search + _searchController.clear(); + setState(() => _isSearchEditing = false); + return; + } + + // Root View -> Show Exit Dialog + final shouldExit = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.grey[900], + title: const Text( + "Quitter l'application", + style: TextStyle(color: Colors.white), + ), + content: const Text( + "Voulez-vous vraiment quitter l'application ?", + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + autofocus: true, + child: const Text( + 'Annuler', + style: TextStyle(color: Colors.blueAccent), + ), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text( + 'Quitter', + style: TextStyle(color: Colors.redAccent), + ), + ), + ], + ), + ); + + if (shouldExit == true) { + SystemNavigator.pop(); + } + }, + child: SafeArea( + bottom: false, + child: RefreshIndicator( + onRefresh: _refresh, + color: AppColors.primary, + backgroundColor: AppColors.surface, + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Hero Section (reduced height, moved above search) + if (_searchQuery.isEmpty && heroItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: SizedBox( + height: 180, // Reduced from 250 + child: HeroCarousel( + items: heroItems, + onTap: (item) { + try { + final series = _series.firstWhere( + (s) => s.seriesId.toString() == item.id, + ); + _openSeries(series); + } catch (e) { + // Fallback logic not critical + } + }, + ), + ), + ), + ), + + // Search Bar (moved below carousel) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8), - child: SizedBox( - height: 180, // Reduced from 250 - child: HeroCarousel( - items: heroItems, - onTap: (item) { - try { - final series = _series.firstWhere( - (s) => s.seriesId.toString() == item.id); - _openSeries(series); - } catch (e) { - // Fallback logic not critical - } - }, - ), - ), - ), - ), - - // Search Bar (moved below carousel) - SliverToBoxAdapter( - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TVFocusable( - scale: 1.0, // Disable scaling to prevent overflow - focusColor: Colors.white, // Solid white selection frame - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 40, - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - // Removed internal editing border here to prevent conflict and "inside frame" look - ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - const Icon(Icons.search, - size: 20, color: AppColors.textSecondary), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - cursorColor: Colors.white, // Extra safety - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: const TextStyle( - fontSize: 14, color: AppColors.textPrimary), - decoration: const InputDecoration( - hintText: 'Rechercher une série...', - border: InputBorder.none, - focusedBorder: InputBorder - .none, // Explicitly remove focus border - enabledBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(bottom: 11), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TVFocusable( + scale: 1.0, // Disable scaling to prevent overflow + focusColor: Colors.white, // Solid white selection frame + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 40, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + // Removed internal editing border here to prevent conflict and "inside frame" look + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + const Icon( + Icons.search, + size: 20, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + cursorColor: Colors.white, // Extra safety + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + ), + decoration: const InputDecoration( + hintText: 'Rechercher une série...', + border: InputBorder.none, + focusedBorder: InputBorder + .none, // Explicitly remove focus border + enabledBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(bottom: 11), + ), + onChanged: _onSearchChanged, + onSubmitted: (_) => + setState(() => _isSearchEditing = false), ), - onChanged: _onSearchChanged, - onSubmitted: (_) => - setState(() => _isSearchEditing = false), ), ), - ), - if (_isSearching) - const SizedBox( + if (_isSearching) + const SizedBox( width: 12, height: 12, - child: - CircularProgressIndicator(strokeWidth: 2)), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: const Icon(Icons.close, - size: 16, color: AppColors.textSecondary), - ), - ], + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: const Icon( + Icons.close, + size: 16, + color: AppColors.textSecondary, + ), + ), + ], + ), ), ), ), ), - ), - // Grid - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, // Reduced size further (was 4) - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.7, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index >= displaySeries.length) return null; - final series = displaySeries[index]; + // Grid + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, // Reduced size further (was 4) + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.7, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= displaySeries.length) return null; + final series = displaySeries[index]; - return MobilePosterCard( - title: series.name, - imageUrl: _getImageUrl(series.cover), - rating: _formatRating(series.rating), - placeholderIcon: Icons.tv, - onTap: () => _openSeries(series), - ); - }, - childCount: displaySeries.length, + return MobilePosterCard( + title: series.name, + imageUrl: _getImageUrl(series.cover), + rating: _formatRating(series.rating), + placeholderIcon: Icons.tv, + onTap: () => _openSeries(series), + ); + }, + childCount: displaySeries.length, + ), ), ), - ), - if (_isLoading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), + if (_isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index 9de22ca..b8cdeac 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -75,7 +75,7 @@ class _MobileSettingsTabState extends ConsumerState { padding: const EdgeInsets.only(bottom: 16), child: Row( children: [ - Container( + SizedBox( width: 48, height: 48, // No decoration for free-floating logo diff --git a/lib/mobile/providers/mobile_settings_providers.dart b/lib/mobile/providers/mobile_settings_providers.dart index 59eeeb6..8a14554 100644 --- a/lib/mobile/providers/mobile_settings_providers.dart +++ b/lib/mobile/providers/mobile_settings_providers.dart @@ -15,6 +15,8 @@ class MobileSettings { final String aspectRatioMode; // 'contain', 'cover', 'fill' final bool deinterlace; final int bufferDuration; // seconds, 0 = auto + final List + deinterlacedChannels; // IDs of channels with forced deinterlacing const MobileSettings({ this.liveTvKeywords = const [], @@ -27,6 +29,7 @@ class MobileSettings { this.aspectRatioMode = 'contain', this.deinterlace = false, this.bufferDuration = 0, + this.deinterlacedChannels = const [], }); MobileSettings copyWith({ @@ -40,6 +43,7 @@ class MobileSettings { String? aspectRatioMode, bool? deinterlace, int? bufferDuration, + List? deinterlacedChannels, }) { return MobileSettings( liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords, @@ -52,6 +56,7 @@ class MobileSettings { aspectRatioMode: aspectRatioMode ?? this.aspectRatioMode, deinterlace: deinterlace ?? this.deinterlace, bufferDuration: bufferDuration ?? this.bufferDuration, + deinterlacedChannels: deinterlacedChannels ?? this.deinterlacedChannels, ); } @@ -114,6 +119,8 @@ class MobileSettingsNotifier extends StateNotifier { _box?.get('aspectRatioMode', defaultValue: 'contain') as String?; final deinterlace = _box?.get('deinterlace', defaultValue: false) as bool?; final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?; + final deinterlacedChannels = + _box?.get('deinterlacedChannels', defaultValue: []) as List?; state = MobileSettings( liveTvKeywords: liveTv?.cast() ?? [], @@ -126,6 +133,7 @@ class MobileSettingsNotifier extends StateNotifier { aspectRatioMode: aspectRatioMode ?? 'contain', deinterlace: deinterlace ?? false, bufferDuration: buffer ?? 0, + deinterlacedChannels: deinterlacedChannels?.cast() ?? [], ); } @@ -178,6 +186,17 @@ class MobileSettingsNotifier extends StateNotifier { state = state.copyWith(bufferDuration: seconds); _box?.put('bufferDuration', seconds); } + + void toggleChannelDeinterlace(String streamId) { + final currentList = List.from(state.deinterlacedChannels); + if (currentList.contains(streamId)) { + currentList.remove(streamId); + } else { + currentList.add(streamId); + } + state = state.copyWith(deinterlacedChannels: currentList); + _box?.put('deinterlacedChannels', currentList); + } } final mobileSettingsProvider = @@ -294,12 +313,14 @@ class MobileWatchHistoryNotifier extends StateNotifier { // Only save if position is meaningful (> 30 seconds) if (positionSeconds < 30) { debugPrint( - '[WatchHistory] Position too short ($positionSeconds), skipping save'); + '[WatchHistory] Position too short ($positionSeconds), skipping save', + ); return; } debugPrint( - '[WatchHistory] Saving position for $contentId: ${positionSeconds}s'); + '[WatchHistory] Saving position for $contentId: ${positionSeconds}s', + ); final positions = {...state.resumePositions, contentId: positionSeconds}; state = state.copyWith(resumePositions: positions); _box?.put('resumePositions', Map.from(positions));