mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: add LiveTVTab widget for displaying categorized live TV channels with pagination.
This commit is contained in:
1 parent
5671afbd26
commit
ebbf9217fc
9 files changed
+576
-253
No files matched your search
+37
-13
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user