Merge pull request #40 from R0m1k3/antigravity

feat: add LiveTVTab widget for displaying categorized live TV channel…
This commit is contained in:
LogiFlow authored and GitHub committed 2025-12-06 15:28:24 +01:00
commit f649317576
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)');
// For video files, use streaming to avoid loading entire file in memory
// For video files, use streaming with Range support for seeking
if (isVideoFile) {
return _streamVideoFile(targetUrl);
final rangeHeader = request.headers['range'];
return _streamVideoFile(targetUrl, rangeHeader);
}
// 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)
Future<Response> _streamVideoFile(Uri targetUrl) async {
/// Stream video file using HttpClient with Range support for seeking
Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
try {
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);
req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18');
req.headers.set('Accept', '*/*');
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();
// Get content type from response
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(
response.statusCode,
body: response,
headers: {
'content-type': contentType,
'access-control-allow-origin': '*',
'accept-ranges': 'bytes',
if (response.contentLength > 0)
'content-length': response.contentLength.toString(),
},
headers: responseHeaders,
);
} catch (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');
} else {
// For VOD and Series
final ext = widget.containerExtension.toLowerCase();
final baseUrl = html.window.location.origin;
// Check if format needs transcoding (MKV, AVI, etc. are not browser-native)
final needsTranscoding = ext == 'mkv' || ext == 'avi' || ext == 'wmv' || ext == 'flv';
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');
}
// For VOD and Series - use direct proxy streaming with Range support
// This allows seeking via HTTP Range requests
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(() {
@@ -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)
Future<List<xm.Series>> getSeriesPaginated({int offset = 0, int limit = 100}) async {
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
Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async {
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
void dispose() {
_dio.close();
+88 -92
View File
@@ -1,10 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart';
/// EPG overlay widget showing "Now" and "Next" programs
class EpgOverlay extends ConsumerWidget {
/// Minimal EPG overlay - shows briefly then fades out
class EpgOverlay extends ConsumerStatefulWidget {
final String streamId;
final PlaylistConfig playlist;
@@ -15,9 +16,46 @@ class EpgOverlay extends ConsumerWidget {
});
@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(
EpgRequestKey(playlist: playlist, streamId: streamId),
EpgRequestKey(playlist: widget.playlist, streamId: widget.streamId),
));
return epgAsync.when(
@@ -26,7 +64,7 @@ class EpgOverlay extends ConsumerWidget {
return const SizedBox.shrink();
}
// Get "Now" and "Next" programs
// Get "Now" program
final now = DateTime.now();
final currentProgram = epgEntries.firstWhere(
(entry) {
@@ -37,86 +75,53 @@ class EpgOverlay extends ConsumerWidget {
orElse: () => epgEntries.first,
);
final progress = currentProgram.getProgress();
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
// Minimal info display - just title and time
return FadeTransition(
opacity: _fadeController,
child: Align(
alignment: Alignment.topLeft,
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(6),
),
const SizedBox(height: 4),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
minHeight: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(3),
),
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(),
);
}
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:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../services/xtream_service.dart';
import '../providers/xtream_provider.dart';
import '../models/xtream_models.dart';
class EPGWidget extends ConsumerStatefulWidget {
+83 -47
View File
@@ -7,6 +7,7 @@ import '../providers/settings_provider.dart';
import '../screens/player_screen.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import 'epg_widget.dart';
/// Live TV tab with category box grid navigation
class LiveTVTab extends ConsumerStatefulWidget {
@@ -219,7 +220,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Expanded(
child: ListView.builder(
itemCount: paginatedChannels.length,
itemExtent: 72,
itemBuilder: (context, index) {
final channel = paginatedChannels[index];
return _buildChannelTile(context, channel);
@@ -231,9 +231,14 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
}
Widget _buildChannelTile(BuildContext context, Channel channel) {
// Check if image URL is HTTP (will be blocked by Mixed Content on HTTPS site)
final bool hasValidImage = channel.streamIcon.isNotEmpty &&
channel.streamIcon.startsWith('https://');
// Accept both HTTP and HTTPS icons - use proxy for HTTP
final bool hasIcon = channel.streamIcon.isNotEmpty;
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(
width: 48,
@@ -245,52 +250,83 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: const Icon(Icons.tv, color: Colors.white54),
);
return ListTile(
leading: hasValidImage
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: channel.streamIcon,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (context, url) => placeholder,
errorWidget: (context, url, error) => placeholder,
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: widget.playlist,
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_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -22,11 +23,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final List<Movie> _movies = [];
List<Movie>? _searchResults; // Results from full catalog search
String _searchQuery = '';
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 100;
Timer? _searchDebounce;
@override
void initState() {
@@ -39,6 +43,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchDebounce?.cancel();
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 {
if (_isLoading || !_hasMore) return;
@@ -85,17 +130,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
// Filter movies by category
var filteredMovies = settings.moviesKeywords.isEmpty
? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
// Apply search filter
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filteredMovies = filteredMovies.where((m) =>
m.name.toLowerCase().contains(query)
).toList();
// Use search results if searching, otherwise use loaded movies with category filter
List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displayMovies = _searchResults!;
} else {
displayMovies = settings.moviesKeywords.isEmpty
? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
}
if (_movies.isEmpty && !_isLoading) {
@@ -110,15 +152,23 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search movies...',
hintText: 'Search all movies...',
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
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
_onSearchChanged('');
},
)
: null,
@@ -131,7 +181,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: GoogleFonts.roboto(color: Colors.white),
onChanged: (value) => setState(() => _searchQuery = value),
onChanged: _onSearchChanged,
),
),
@@ -142,7 +192,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
child: Align(
alignment: Alignment.centerLeft,
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),
),
),
@@ -150,7 +200,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
// Movie grid
Expanded(
child: filteredMovies.isEmpty && _movies.isNotEmpty
child: displayMovies.isEmpty && _movies.isNotEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -179,13 +229,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
mainAxisSpacing: 6,
childAspectRatio: 0.65,
),
itemCount: filteredMovies.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemCount: displayMovies.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index >= filteredMovies.length) {
if (index >= displayMovies.length) {
return const Center(child: CircularProgressIndicator());
}
final movie = filteredMovies[index];
final movie = displayMovies[index];
return _MovieCard(
movie: movie,
playlist: widget.playlist,
+70 -20
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -21,11 +22,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final List<Series> _series = [];
List<Series>? _searchResults;
String _searchQuery = '';
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 100;
Timer? _searchDebounce;
@override
void initState() {
@@ -38,6 +42,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchDebounce?.cancel();
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 {
if (_isLoading || !_hasMore) return;
@@ -84,17 +129,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
// Filter series by category
var filteredSeries = settings.seriesKeywords.isEmpty
? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
// Apply search filter
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filteredSeries = filteredSeries.where((s) =>
s.name.toLowerCase().contains(query)
).toList();
// Use search results if searching, otherwise use loaded series with category filter
List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!;
} else {
displaySeries = settings.seriesKeywords.isEmpty
? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
}
if (_series.isEmpty && !_isLoading) {
@@ -109,15 +151,23 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search series...',
hintText: 'Search all series...',
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
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
_onSearchChanged('');
},
)
: null,
@@ -130,7 +180,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: GoogleFonts.roboto(color: Colors.white),
onChanged: (value) => setState(() => _searchQuery = value),
onChanged: _onSearchChanged,
),
),
@@ -141,7 +191,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
child: Align(
alignment: Alignment.centerLeft,
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),
),
),
@@ -149,7 +199,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
// Series grid
Expanded(
child: filteredSeries.isEmpty && _series.isNotEmpty
child: displaySeries.isEmpty && _series.isNotEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -178,13 +228,13 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
mainAxisSpacing: 6,
childAspectRatio: 0.65,
),
itemCount: filteredSeries.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemCount: displaySeries.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index >= filteredSeries.length) {
if (index >= displaySeries.length) {
return const Center(child: CircularProgressIndicator());
}
final serie = filteredSeries[index];
final serie = displaySeries[index];
return _SeriesCard(series: serie, playlist: widget.playlist);
},
),
+107 -18
View File
@@ -321,16 +321,59 @@
async function playDirect(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', () => {
hideLoading();
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() {
@@ -344,28 +387,74 @@
// Determine stream type and try appropriate player
const lowerUrl = streamUrl.toLowerCase();
// Check for standard video files that should be played directly
if (lowerUrl.includes('.mp4') || lowerUrl.includes('.mkv') ||
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')) {
// HLS streams first
if (lowerUrl.includes('.m3u8') || lowerUrl.includes('/hls/')) {
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);
if (!success) {
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);
}
}, { once: true });
}
initPlayer();