mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: Add LitePlayerScreen for IPTV playback with video controls, EPG, and stream type handling.
This commit is contained in:
1 parent
dbde32d20a
commit
9a0bda4b7d
10 files changed
+1258
-777
No files matched your search
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user