feat: Add LitePlayerScreen for IPTV playback with video controls, EPG, and stream type handling.

This commit is contained in:
Michael committed 2025-12-25 12:54:10 +01:00
1 parent dbde32d20a
commit 9a0bda4b7d
10 files changed
+1258 -777

No files matched your search

+6 -3
View File
@@ -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
@@ -264,7 +264,7 @@ class XtreamServiceMobile {
}
/// Load VOD categories mapping
Future<Map<String, String>> _getVodCategories() async {
Future<Map<String, String>> 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<List<xm.Movie>> 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<dynamic> movies = response.data as List<dynamic>;
return movies.map((movieData) {
final data =
Map<String, dynamic>.from(movieData as Map<String, dynamic>);
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<List<xm.Movie>> 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,
@@ -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<LitePlayerScreen>
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<LitePlayerScreen>
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<LitePlayerScreen>
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<LitePlayerScreen>
? _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<LitePlayerScreen>
);
_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<LitePlayerScreen>
// 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<LitePlayerScreen>
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<LitePlayerScreen>
}
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<LitePlayerScreen>
_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<LitePlayerScreen>
: 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<LitePlayerScreen>
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<LitePlayerScreen>
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<LitePlayerScreen>
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<LitePlayerScreen>
padding: const EdgeInsets.only(bottom: 12),
child: Actions(
actions: {
DirectionalFocusIntent:
CallbackAction<DirectionalFocusIntent>(onInvoke: (intent) {
if (intent.direction == TraversalDirection.up ||
intent.direction == TraversalDirection.down) {
FocusScope.of(context).focusInDirection(intent.direction);
DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>(
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<LitePlayerScreen>
),
);
}
void _cycleAspectRatio() {
final current = ref.read(mobileSettingsProvider).aspectRatioMode;
final next = current == 'contain'
? 'cover'
: (current == 'cover' ? 'fill' : 'contain');
ref.read(mobileSettingsProvider.notifier).setAspectRatioMode(next);
_onUserInteraction();
}
}
@@ -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<MobileDashboardScreen> {
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,
),
);
}
@@ -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<NativePlayerScreen>
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<NativePlayerScreen>
(_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<NativePlayerScreen>
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<NativePlayerScreen>
// 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<NativePlayerScreen>
if (key == LogicalKeyboardKey.channelDown) {
if (widget.streamType == StreamType.live && widget.channels != null) {
_playPrevious();
_onUserInteraction();
return true;
}
}
@@ -876,6 +899,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (key == LogicalKeyboardKey.channelUp) {
if (widget.streamType == StreamType.live && widget.channels != null) {
_playNext();
_onUserInteraction();
return true;
}
}
@@ -957,7 +981,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
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<NativePlayerScreen>
),
// 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<NativePlayerScreen>
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<NativePlayerScreen>
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<NativePlayerScreen>
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<NativePlayerScreen>
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<NativePlayerScreen>
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: <Type, Action<Intent>>{
// Override directional focus intents for Up/Down
DirectionalFocusIntent:
CallbackAction<DirectionalFocusIntent>(
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: <Type, Action<Intent>>{
DirectionalFocusIntent:
CallbackAction<DirectionalFocusIntent>(
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, Intent>{
LogicalKeySet(LogicalKeyboardKey.arrowUp):
const DirectionalFocusIntent(
TraversalDirection.up,
),
LogicalKeySet(LogicalKeyboardKey.arrowDown):
const DirectionalFocusIntent(
TraversalDirection.down,
),
},
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
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,
),
),
],
),
],
),
),
@@ -74,10 +74,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
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<MobileLiveTVTab>
setState(() => _showFavoritesOnly = false);
} else if (!uiState.isCategoryView) {
uiNotifier.state = uiState.copyWith(isCategoryView: true);
} else {
// Root View (Categories) -> Show Exit Dialog
final shouldExit = await showDialog<bool>(
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<MobileLiveTVTab>
// 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<MobileLiveTVTab>
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<MobileLiveTVTab>
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<MobileLiveTVTab>
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<MobileLiveTVTab>
_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<MobileLiveTVTab>
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<MobileLiveTVTab>
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<MobileLiveTVTab>
);
}
void _playChannel(BuildContext context, Channel channel,
List<Channel> channels, PlaylistConfig playlist, int index) async {
void _playChannel(
BuildContext context,
Channel channel,
List<Channel> 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<MobileLiveTVTab>
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,
),
),
),
],
@@ -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<MobileMoviesTab>
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final List<Movie> _movies = [];
// Data
List<MapEntry<String, String>> _categories = [];
List<Movie> _categoryMovies = [];
String? _selectedCategoryId;
String? _selectedCategoryName;
// Search
List<Movie>? _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<MobileMoviesTab>
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<MobileMoviesTab>
}
}
Future<void> _loadMoreMovies() async {
// Prevent race conditions
if (_isLoading || !_hasMore || !mounted) return;
Future<void> _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<void> _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<MobileMoviesTab>
} 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<MobileMoviesTab>
}
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<MobileMoviesTab>
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<Movie> 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<bool>(
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()),
),
),
],
),
),
),
),
@@ -219,150 +219,212 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
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<bool>(
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()),
),
),
),
],
],
),
),
),
),
@@ -75,7 +75,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(
SizedBox(
width: 48,
height: 48,
// No decoration for free-floating logo
@@ -15,6 +15,8 @@ class MobileSettings {
final String aspectRatioMode; // 'contain', 'cover', 'fill'
final bool deinterlace;
final int bufferDuration; // seconds, 0 = auto
final List<String>
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<String>? 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<MobileSettings> {
_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: <String>[]) as List?;
state = MobileSettings(
liveTvKeywords: liveTv?.cast<String>() ?? [],
@@ -126,6 +133,7 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
aspectRatioMode: aspectRatioMode ?? 'contain',
deinterlace: deinterlace ?? false,
bufferDuration: buffer ?? 0,
deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [],
);
}
@@ -178,6 +186,17 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
state = state.copyWith(bufferDuration: seconds);
_box?.put('bufferDuration', seconds);
}
void toggleChannelDeinterlace(String streamId) {
final currentList = List<String>.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<MobileWatchHistory> {
// 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<String, int>.from(positions));