mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-12 01:39:36 +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)');
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user