feat: add LiveTVTab widget for displaying categorized live TV channels with pagination.

This commit is contained in:
Michael committed 2025-12-06 15:28:05 +01:00
1 parent 5671afbd26
commit ebbf9217fc
9 files changed
+576 -253

No files matched your search

+37 -13
View File
@@ -166,9 +166,10 @@ Handler _createXtreamProxyHandler() {
print('Proxying request to: $targetUrl (video streaming: $isVideoFile)'); print('Proxying request to: $targetUrl (video streaming: $isVideoFile)');
// For video files, use streaming to avoid loading entire file in memory // For video files, use streaming with Range support for seeking
if (isVideoFile) { if (isVideoFile) {
return _streamVideoFile(targetUrl); final rangeHeader = request.headers['range'];
return _streamVideoFile(targetUrl, rangeHeader);
} }
// Headers to simulate a legitimate IPTV client (VLC/Kodi style) // Headers to simulate a legitimate IPTV client (VLC/Kodi style)
@@ -244,33 +245,56 @@ Handler _createXtreamProxyHandler() {
}; };
} }
/// Stream video file using HttpClient (avoids loading entire file in memory) /// Stream video file using HttpClient with Range support for seeking
Future<Response> _streamVideoFile(Uri targetUrl) async { Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
try { try {
final client = HttpClient(); final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 30); // Increase timeouts for large video files
client.connectionTimeout = const Duration(seconds: 60);
client.idleTimeout = const Duration(minutes: 5); // Keep connection alive longer
final req = await client.getUrl(targetUrl); final req = await client.getUrl(targetUrl);
req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18'); req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18');
req.headers.set('Accept', '*/*'); req.headers.set('Accept', '*/*');
req.headers.set('Connection', 'keep-alive'); req.headers.set('Connection', 'keep-alive');
req.headers.set('Accept-Encoding', 'identity'); // Don't compress video
// Forward the Range header for seeking support
if (rangeHeader != null && rangeHeader.isNotEmpty) {
req.headers.set('Range', rangeHeader);
print('Video streaming with Range: $rangeHeader');
}
final response = await req.close(); final response = await req.close();
// Get content type from response // Get content type from response
final contentType = response.headers.contentType?.mimeType ?? 'video/mp4'; final contentType = response.headers.contentType?.mimeType ?? 'video/mp4';
// Create a streaming response // Build response headers for optimal streaming/buffering
final responseHeaders = <String, String>{
'content-type': contentType,
'access-control-allow-origin': '*',
'accept-ranges': 'bytes',
'cache-control': 'no-cache', // Allow browser to cache but revalidate
'connection': 'keep-alive',
};
// Add Content-Length if available (critical for seeking)
if (response.contentLength > 0) {
responseHeaders['content-length'] = response.contentLength.toString();
}
// Add Content-Range if this is a partial response (206)
final contentRange = response.headers.value('content-range');
if (contentRange != null) {
responseHeaders['content-range'] = contentRange;
}
// Return appropriate status code (200 for full, 206 for partial)
return Response( return Response(
response.statusCode, response.statusCode,
body: response, body: response,
headers: { headers: responseHeaders,
'content-type': contentType,
'access-control-allow-origin': '*',
'accept-ranges': 'bytes',
if (response.contentLength > 0)
'content-length': response.contentLength.toString(),
},
); );
} catch (e) { } catch (e) {
print('Video streaming error: $e'); print('Video streaming error: $e');
+7 -42
View File
@@ -136,48 +136,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
debugPrint('PlayerScreen: FFmpeg HLS URL: $hlsUrl'); debugPrint('PlayerScreen: FFmpeg HLS URL: $hlsUrl');
} else { } else {
// For VOD and Series // For VOD and Series - use direct proxy streaming with Range support
final ext = widget.containerExtension.toLowerCase(); // This allows seeking via HTTP Range requests
final baseUrl = html.window.location.origin; final streamUrl = widget.streamType == StreamType.vod
? xtreamService.getVodStreamUrl(widget.streamId, widget.containerExtension)
// Check if format needs transcoding (MKV, AVI, etc. are not browser-native) : xtreamService.getSeriesStreamUrl(widget.streamId, widget.containerExtension);
final needsTranscoding = ext == 'mkv' || ext == 'avi' || ext == 'wmv' || ext == 'flv'; hlsUrl = streamUrl;
debugPrint('PlayerScreen: Direct playback URL: $hlsUrl');
if (needsTranscoding) {
// Use FFmpeg for non-browser-compatible formats
setState(() {
_statusMessage = 'Transcoding video for browser compatibility...';
});
final vodPath = widget.streamType == StreamType.vod ? 'movie' : 'series';
final iptvUrl = '${widget.playlist.dns}/$vodPath/${widget.playlist.username}/${widget.playlist.password}/${widget.streamId}.$ext';
final encodedUrl = Uri.encodeComponent(iptvUrl);
final streamEndpoint = '$baseUrl/api/stream/vod_${widget.streamId}?url=$encodedUrl';
debugPrint('PlayerScreen: Starting FFmpeg VOD transcode: $streamEndpoint');
final response = await http.get(Uri.parse(streamEndpoint));
if (response.statusCode != 200) {
throw Exception('Failed to start VOD transcode: ${response.body}');
}
final data = jsonDecode(response.body);
if (data['status'] != 'started') {
throw Exception('VOD transcode failed: ${data['error'] ?? 'Unknown error'}');
}
hlsUrl = '$baseUrl${data['hlsUrl']}';
debugPrint('PlayerScreen: FFmpeg VOD HLS URL: $hlsUrl');
} else {
// MP4, WebM can be played directly via proxy
final streamUrl = widget.streamType == StreamType.vod
? xtreamService.getVodStreamUrl(widget.streamId, widget.containerExtension)
: xtreamService.getSeriesStreamUrl(widget.streamId, widget.containerExtension);
hlsUrl = streamUrl;
debugPrint('PlayerScreen: Direct playback URL: $hlsUrl');
}
} }
setState(() { setState(() {
@@ -362,6 +362,46 @@ class XtreamService {
} }
} }
/// 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 response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allMovies = response.data as List<dynamic>;
final queryLower = query.toLowerCase();
// Filter by search query
return allMovies
.where((m) {
final name = (m['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
})
.take(100) // Limit results
.map((movieData) {
final data = movieData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Movie.fromJson(data);
})
.toList();
} catch (e) {
return [];
}
}
/// Get series with pagination support (returns flat list) /// Get series with pagination support (returns flat list)
Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async { Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
@@ -399,6 +439,46 @@ class XtreamService {
} }
} }
/// Search series in the entire catalogue
Future<List<xm.Series>> searchSeries(String query) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (query.isEmpty) return [];
try {
final categoryMap = await _getSeriesCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allSeries = response.data as List<dynamic>;
final queryLower = query.toLowerCase();
// Filter by search query
return allSeries
.where((s) {
final name = (s['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
})
.take(100) // Limit results
.map((seriesData) {
final data = seriesData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Series.fromJson(data);
})
.toList();
} catch (e) {
return [];
}
}
/// Get series info with seasons and episodes /// Get series info with seasons and episodes
Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async { Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
@@ -459,6 +539,39 @@ class XtreamService {
} }
} }
/// Get short EPG as ShortEPG object (for EPGWidget)
Future<xm.ShortEPG> getShortEPG(String streamId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg',
'stream_id': streamId,
},
options: Options(
extra: CacheOptions(
store: _cacheOptions.store,
policy: CachePolicy.request,
maxStale: const Duration(minutes: 5),
).toExtra(),
),
);
if (response.data == null) {
return const xm.ShortEPG();
}
return xm.ShortEPG.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
// EPG is optional, don't throw on failure
return const xm.ShortEPG();
}
}
/// Dispose resources /// Dispose resources
void dispose() { void dispose() {
_dio.close(); _dio.close();
+88 -92
View File
@@ -1,10 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
/// EPG overlay widget showing "Now" and "Next" programs /// Minimal EPG overlay - shows briefly then fades out
class EpgOverlay extends ConsumerWidget { class EpgOverlay extends ConsumerStatefulWidget {
final String streamId; final String streamId;
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -15,9 +16,46 @@ class EpgOverlay extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<EpgOverlay> createState() => _EpgOverlayState();
}
class _EpgOverlayState extends ConsumerState<EpgOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _fadeController;
Timer? _hideTimer;
@override
void initState() {
super.initState();
_fadeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
value: 1.0, // Start visible
);
// Auto-hide after 5 seconds
_startHideTimer();
}
void _startHideTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 5), () {
if (mounted) {
_fadeController.reverse(); // Fade out
}
});
}
@override
void dispose() {
_hideTimer?.cancel();
_fadeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final epgAsync = ref.watch(epgByPlaylistProvider( final epgAsync = ref.watch(epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: streamId), EpgRequestKey(playlist: widget.playlist, streamId: widget.streamId),
)); ));
return epgAsync.when( return epgAsync.when(
@@ -26,7 +64,7 @@ class EpgOverlay extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Get "Now" and "Next" programs // Get "Now" program
final now = DateTime.now(); final now = DateTime.now();
final currentProgram = epgEntries.firstWhere( final currentProgram = epgEntries.firstWhere(
(entry) { (entry) {
@@ -37,86 +75,53 @@ class EpgOverlay extends ConsumerWidget {
orElse: () => epgEntries.first, orElse: () => epgEntries.first,
); );
final progress = currentProgram.getProgress(); // Minimal info display - just title and time
return FadeTransition(
return Container( opacity: _fadeController,
margin: const EdgeInsets.all(16), child: Align(
padding: const EdgeInsets.all(12), alignment: Alignment.topLeft,
decoration: BoxDecoration( child: Container(
color: Colors.black.withOpacity(0.7), margin: const EdgeInsets.all(16),
borderRadius: BorderRadius.circular(8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), decoration: BoxDecoration(
child: Column( color: Colors.black.withOpacity(0.6),
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(6),
mainAxisSize: MainAxisSize.min,
children: [
// Current program
Text(
'NOW: ${currentProgram.title}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), child: Row(
// Progress bar mainAxisSize: MainAxisSize.min,
ClipRRect( children: [
borderRadius: BorderRadius.circular(4), Container(
child: LinearProgressIndicator( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
value: progress, decoration: BoxDecoration(
backgroundColor: Colors.white24, color: Colors.red,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red), borderRadius: BorderRadius.circular(3),
minHeight: 4, ),
), child: const Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Text(
currentProgram.title,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
), ),
const SizedBox(height: 4), ),
// Time info
Text(
'${_formatTime(currentProgram.start)} - ${_formatTime(currentProgram.end)}',
style: const TextStyle(
color: Colors.white70,
fontSize: 11,
),
),
// Description if available
if (currentProgram.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
currentProgram.description,
style: const TextStyle(
color: Colors.white60,
fontSize: 11,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
// Next program
if (epgEntries.length > 1) ...[
const SizedBox(height: 8),
const Divider(color: Colors.white24, height: 1),
const SizedBox(height: 8),
Text(
'NEXT: ${epgEntries[1].title}',
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
_formatTime(epgEntries[1].start),
style: const TextStyle(
color: Colors.white60,
fontSize: 10,
),
),
],
],
), ),
); );
}, },
@@ -124,13 +129,4 @@ class EpgOverlay extends ConsumerWidget {
error: (_, __) => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(),
); );
} }
String _formatTime(String isoString) {
try {
final dateTime = DateTime.parse(isoString);
return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
} catch (e) {
return isoString;
}
}
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../services/xtream_service.dart'; import '../providers/xtream_provider.dart';
import '../models/xtream_models.dart'; import '../models/xtream_models.dart';
class EPGWidget extends ConsumerStatefulWidget { class EPGWidget extends ConsumerStatefulWidget {
+83 -47
View File
@@ -7,6 +7,7 @@ import '../providers/settings_provider.dart';
import '../screens/player_screen.dart'; import '../screens/player_screen.dart';
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import 'epg_widget.dart';
/// Live TV tab with category box grid navigation /// Live TV tab with category box grid navigation
class LiveTVTab extends ConsumerStatefulWidget { class LiveTVTab extends ConsumerStatefulWidget {
@@ -219,7 +220,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: paginatedChannels.length, itemCount: paginatedChannels.length,
itemExtent: 72,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final channel = paginatedChannels[index]; final channel = paginatedChannels[index];
return _buildChannelTile(context, channel); return _buildChannelTile(context, channel);
@@ -231,9 +231,14 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
} }
Widget _buildChannelTile(BuildContext context, Channel channel) { Widget _buildChannelTile(BuildContext context, Channel channel) {
// Check if image URL is HTTP (will be blocked by Mixed Content on HTTPS site) // Accept both HTTP and HTTPS icons - use proxy for HTTP
final bool hasValidImage = channel.streamIcon.isNotEmpty && final bool hasIcon = channel.streamIcon.isNotEmpty;
channel.streamIcon.startsWith('https://'); String iconUrl = channel.streamIcon;
// Proxy HTTP images through our server to avoid Mixed Content blocking
if (hasIcon && channel.streamIcon.startsWith('http://')) {
iconUrl = '/api/xtream/${Uri.encodeComponent(channel.streamIcon)}';
}
final Widget placeholder = Container( final Widget placeholder = Container(
width: 48, width: 48,
@@ -245,52 +250,83 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: const Icon(Icons.tv, color: Colors.white54), child: const Icon(Icons.tv, color: Colors.white54),
); );
return ListTile( return Card(
leading: hasValidImage margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
? ClipRRect( child: InkWell(
borderRadius: BorderRadius.circular(8), onTap: () {
child: CachedNetworkImage( Navigator.of(context).push(
imageUrl: channel.streamIcon, MaterialPageRoute(
width: 48, builder: (context) => PlayerScreen(
height: 48, streamId: channel.streamId,
fit: BoxFit.cover, title: channel.name,
placeholder: (context, url) => placeholder, playlist: widget.playlist,
errorWidget: (context, url, error) => placeholder, streamType: StreamType.live,
), ),
)
: placeholder,
title: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.roboto(fontWeight: FontWeight.w500),
),
subtitle: channel.num.isNotEmpty
? Text('Ch. ${channel.num}', style: const TextStyle(fontSize: 12))
: null,
trailing: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.play_arrow,
color: Theme.of(context).colorScheme.primary,
),
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: widget.playlist,
streamType: StreamType.live,
), ),
);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
// Channel icon
hasIcon
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: iconUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (context, url) => placeholder,
errorWidget: (context, url, error) => placeholder,
),
)
: placeholder,
const SizedBox(width: 12),
// Channel info + EPG
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.roboto(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
const SizedBox(height: 4),
// EPG info
EPGWidget(
channelId: channel.streamId,
playlist: widget.playlist,
),
],
),
),
// Play button
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.play_arrow,
color: Theme.of(context).colorScheme.primary,
),
),
],
), ),
); ),
}, ),
); );
} }
} }
+70 -20
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -22,11 +23,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final List<Movie> _movies = []; final List<Movie> _movies = [];
List<Movie>? _searchResults; // Results from full catalog search
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = false; bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true; bool _hasMore = true;
int _currentOffset = 0; int _currentOffset = 0;
static const int _pageSize = 100; static const int _pageSize = 100;
Timer? _searchDebounce;
@override @override
void initState() { void initState() {
@@ -39,6 +43,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
_searchDebounce?.cancel();
super.dispose(); super.dispose();
} }
@@ -49,6 +54,46 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
} }
} }
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_searchResults = null;
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchMovies(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isSearching = false);
}
}
}
Future<void> _loadMoreMovies() async { Future<void> _loadMoreMovies() async {
if (_isLoading || !_hasMore) return; if (_isLoading || !_hasMore) return;
@@ -85,17 +130,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
// Filter movies by category // Use search results if searching, otherwise use loaded movies with category filter
var filteredMovies = settings.moviesKeywords.isEmpty List<Movie> displayMovies;
? _movies if (_searchQuery.isNotEmpty && _searchResults != null) {
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); displayMovies = _searchResults!;
} else {
// Apply search filter displayMovies = settings.moviesKeywords.isEmpty
if (_searchQuery.isNotEmpty) { ? _movies
final query = _searchQuery.toLowerCase(); : _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
filteredMovies = filteredMovies.where((m) =>
m.name.toLowerCase().contains(query)
).toList();
} }
if (_movies.isEmpty && !_isLoading) { if (_movies.isEmpty && !_isLoading) {
@@ -110,15 +152,23 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search movies...', hintText: 'Search all movies...',
hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500), hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500),
prefixIcon: const Icon(Icons.search, color: Colors.grey), prefixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey), icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
setState(() => _searchQuery = ''); _onSearchChanged('');
}, },
) )
: null, : null,
@@ -131,7 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
style: GoogleFonts.roboto(color: Colors.white), style: GoogleFonts.roboto(color: Colors.white),
onChanged: (value) => setState(() => _searchQuery = value), onChanged: _onSearchChanged,
), ),
), ),
@@ -142,7 +192,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
'${filteredMovies.length} result${filteredMovies.length != 1 ? 's' : ''} found', '${displayMovies.length} result${displayMovies.length != 1 ? 's' : ''} found',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
), ),
), ),
@@ -150,7 +200,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
// Movie grid // Movie grid
Expanded( Expanded(
child: filteredMovies.isEmpty && _movies.isNotEmpty child: displayMovies.isEmpty && _movies.isNotEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -179,13 +229,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
mainAxisSpacing: 6, mainAxisSpacing: 6,
childAspectRatio: 0.65, childAspectRatio: 0.65,
), ),
itemCount: filteredMovies.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0), itemCount: displayMovies.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= filteredMovies.length) { if (index >= displayMovies.length) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final movie = filteredMovies[index]; final movie = displayMovies[index];
return _MovieCard( return _MovieCard(
movie: movie, movie: movie,
playlist: widget.playlist, playlist: widget.playlist,
+70 -20
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -21,11 +22,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final List<Series> _series = []; final List<Series> _series = [];
List<Series>? _searchResults;
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = false; bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true; bool _hasMore = true;
int _currentOffset = 0; int _currentOffset = 0;
static const int _pageSize = 100; static const int _pageSize = 100;
Timer? _searchDebounce;
@override @override
void initState() { void initState() {
@@ -38,6 +42,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
_searchDebounce?.cancel();
super.dispose(); super.dispose();
} }
@@ -48,6 +53,46 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
} }
} }
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_searchResults = null;
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isSearching = false);
}
}
}
Future<void> _loadMoreSeries() async { Future<void> _loadMoreSeries() async {
if (_isLoading || !_hasMore) return; if (_isLoading || !_hasMore) return;
@@ -84,17 +129,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
// Filter series by category // Use search results if searching, otherwise use loaded series with category filter
var filteredSeries = settings.seriesKeywords.isEmpty List<Series> displaySeries;
? _series if (_searchQuery.isNotEmpty && _searchResults != null) {
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); displaySeries = _searchResults!;
} else {
// Apply search filter displaySeries = settings.seriesKeywords.isEmpty
if (_searchQuery.isNotEmpty) { ? _series
final query = _searchQuery.toLowerCase(); : _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
filteredSeries = filteredSeries.where((s) =>
s.name.toLowerCase().contains(query)
).toList();
} }
if (_series.isEmpty && !_isLoading) { if (_series.isEmpty && !_isLoading) {
@@ -109,15 +151,23 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search series...', hintText: 'Search all series...',
hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500), hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500),
prefixIcon: const Icon(Icons.search, color: Colors.grey), prefixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey), icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
setState(() => _searchQuery = ''); _onSearchChanged('');
}, },
) )
: null, : null,
@@ -130,7 +180,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
style: GoogleFonts.roboto(color: Colors.white), style: GoogleFonts.roboto(color: Colors.white),
onChanged: (value) => setState(() => _searchQuery = value), onChanged: _onSearchChanged,
), ),
), ),
@@ -141,7 +191,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
'${filteredSeries.length} result${filteredSeries.length != 1 ? 's' : ''} found', '${displaySeries.length} result${displaySeries.length != 1 ? 's' : ''} found',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600), style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
), ),
), ),
@@ -149,7 +199,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
// Series grid // Series grid
Expanded( Expanded(
child: filteredSeries.isEmpty && _series.isNotEmpty child: displaySeries.isEmpty && _series.isNotEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -178,13 +228,13 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
mainAxisSpacing: 6, mainAxisSpacing: 6,
childAspectRatio: 0.65, childAspectRatio: 0.65,
), ),
itemCount: filteredSeries.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0), itemCount: displaySeries.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= filteredSeries.length) { if (index >= displaySeries.length) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final serie = filteredSeries[index]; final serie = displaySeries[index];
return _SeriesCard(series: serie, playlist: widget.playlist); return _SeriesCard(series: serie, playlist: widget.playlist);
}, },
), ),
+107 -18
View File
@@ -321,16 +321,59 @@
async function playDirect(url) { async function playDirect(url) {
log('Trying direct playback: ' + url); log('Trying direct playback: ' + url);
showLoading('Loading stream directly...'); showLoading('Loading video...');
// Configure video for better buffering
video.preload = 'auto';
// Handle buffering events
video.addEventListener('waiting', () => {
log('Buffering...');
showLoading('Buffering...');
});
video.addEventListener('playing', () => {
hideLoading();
});
video.src = url;
video.addEventListener('canplay', () => { video.addEventListener('canplay', () => {
hideLoading(); hideLoading();
video.play().catch(e => log('Autoplay prevented: ' + e)); video.play().catch(e => log('Autoplay prevented: ' + e));
}, { once: true });
video.addEventListener('error', (e) => {
const error = video.error;
let msg = 'Playback error';
if (error) {
switch (error.code) {
case MediaError.MEDIA_ERR_NETWORK:
msg = 'Network error - retrying...';
log(msg);
// Retry after network error
setTimeout(() => {
video.load();
video.play().catch(() => { });
}, 2000);
return;
case MediaError.MEDIA_ERR_DECODE:
msg = 'Video decode error';
break;
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
msg = 'Format not supported';
break;
}
}
showError(msg);
}); });
video.addEventListener('error', () => {
showError('Direct playback error'); // Handle stalled playback (prevent rollback)
video.addEventListener('stalled', () => {
log('Stream stalled, waiting for data...');
}); });
// Set source and start loading
video.src = url;
video.load();
} }
async function initPlayer() { async function initPlayer() {
@@ -344,28 +387,74 @@
// Determine stream type and try appropriate player // Determine stream type and try appropriate player
const lowerUrl = streamUrl.toLowerCase(); const lowerUrl = streamUrl.toLowerCase();
// Check for standard video files that should be played directly // HLS streams first
if (lowerUrl.includes('.mp4') || lowerUrl.includes('.mkv') || if (lowerUrl.includes('.m3u8') || lowerUrl.includes('/hls/')) {
lowerUrl.includes('.avi') || lowerUrl.includes('.webm') ||
lowerUrl.includes('.mov')) {
log('Detected direct video file, playing directly');
await playDirect(streamUrl);
}
// HLS streams
else if (lowerUrl.includes('.m3u8')) {
await playWithHLS(streamUrl); await playWithHLS(streamUrl);
return;
} }
// MPEG-TS streams
else if (lowerUrl.includes('.ts')) { // MP4 and WebM - play directly (supports seeking via Range)
if (lowerUrl.includes('.mp4') || lowerUrl.includes('.webm') || lowerUrl.includes('.mov')) {
log('Detected MP4/WebM/MOV, playing directly with seek support');
await playDirect(streamUrl);
return;
}
// MKV, AVI - not natively supported, try anyway with fallback
if (lowerUrl.includes('.mkv') || lowerUrl.includes('.avi') || lowerUrl.includes('.wmv') || lowerUrl.includes('.flv')) {
log('Detected MKV/AVI - limited browser support, trying direct play...');
showLoading('Loading video (format may not support seeking)...');
// Try direct first, some browsers/servers may transcode on the fly
video.src = streamUrl;
let playStarted = false;
const onCanPlay = () => {
playStarted = true;
hideLoading();
video.play().catch(e => log('Autoplay prevented: ' + e));
};
const onError = async () => {
if (!playStarted) {
log('Direct play failed for MKV/AVI, trying HLS fallback...');
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
// Try HLS as fallback (in case URL was actually HLS disguised)
const hlsSuccess = await playWithHLS(streamUrl);
if (!hlsSuccess) {
showError('Format not supported by browser. Try MP4 version if available.');
}
}
};
video.addEventListener('canplay', onCanPlay, { once: true });
video.addEventListener('error', onError, { once: true });
return;
}
// MPEG-TS streams (live)
if (lowerUrl.includes('.ts')) {
const success = await playWithMpegTS(streamUrl); const success = await playWithMpegTS(streamUrl);
if (!success) { if (!success) {
await playDirect(streamUrl); await playDirect(streamUrl);
} }
return;
} }
// Unknown format - try HLS first (for FFmpeg generated streams)
else { // Unknown format - try direct first (for VOD with Range support), then HLS
log('Unknown format, trying direct first then HLS fallback');
video.src = streamUrl;
video.addEventListener('canplay', () => {
hideLoading();
video.play().catch(e => log('Autoplay prevented: ' + e));
}, { once: true });
video.addEventListener('error', async () => {
log('Direct play failed, trying HLS...');
await playWithHLS(streamUrl); await playWithHLS(streamUrl);
} }, { once: true });
} }
initPlayer(); initPlayer();