From f237597dd859a4a652eb5f2d04d62bbb460cafe6 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 6 Dec 2025 15:28:05 +0100 Subject: [PATCH] feat: add LiveTVTab widget for displaying categorized live TV channels with pagination. --- bin/server.dart | 50 +++-- lib/features/iptv/screens/player_screen.dart | 49 +---- .../iptv/services/xtream_service.dart | 113 +++++++++++ lib/features/iptv/widgets/epg_overlay.dart | 180 +++++++++--------- lib/features/iptv/widgets/epg_widget.dart | 2 +- lib/features/iptv/widgets/live_tv_tab.dart | 130 ++++++++----- lib/features/iptv/widgets/movies_tab.dart | 90 +++++++-- lib/features/iptv/widgets/series_tab.dart | 90 +++++++-- web/player.html | 125 ++++++++++-- 9 files changed, 576 insertions(+), 253 deletions(-) diff --git a/bin/server.dart b/bin/server.dart index 2df756d..ab34572 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -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 _streamVideoFile(Uri targetUrl) async { +/// Stream video file using HttpClient with Range support for seeking +Future _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 = { + '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'); diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 7815cf0..f78aea4 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -136,48 +136,13 @@ class _PlayerScreenState extends ConsumerState { 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(() { diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 2f80f68..d607c91 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -362,6 +362,46 @@ class XtreamService { } } + /// Search movies in the entire catalogue + Future> 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 allMovies = response.data as List; + 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; + 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> 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> 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 allSeries = response.data as List; + 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; + 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 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 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); + } catch (e) { + // EPG is optional, don't throw on failure + return const xm.ShortEPG(); + } + } + /// Dispose resources void dispose() { _dio.close(); diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart index 0599398..4afc75e 100644 --- a/lib/features/iptv/widgets/epg_overlay.dart +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -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 createState() => _EpgOverlayState(); +} + +class _EpgOverlayState extends ConsumerState + 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(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; - } - } } diff --git a/lib/features/iptv/widgets/epg_widget.dart b/lib/features/iptv/widgets/epg_widget.dart index 692c135..7c0071f 100644 --- a/lib/features/iptv/widgets/epg_widget.dart +++ b/lib/features/iptv/widgets/epg_widget.dart @@ -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 { diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 9c8081b..6de8a26 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -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 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 } 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 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, + ), + ), + ], ), - ); - }, + ), + ), ); } } diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 5aac2e6..392bcef 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -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 { final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final List _movies = []; + List? _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 { void dispose() { _scrollController.dispose(); _searchController.dispose(); + _searchDebounce?.cancel(); super.dispose(); } @@ -49,6 +54,46 @@ class _MoviesTabState extends ConsumerState { } } + 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 _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 _loadMoreMovies() async { if (_isLoading || !_hasMore) return; @@ -85,17 +130,14 @@ class _MoviesTabState extends ConsumerState { 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 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 { 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 { 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 { 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 { // 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 { 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, diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index af1fb5b..db6da38 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -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 { final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final List _series = []; + List? _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 { void dispose() { _scrollController.dispose(); _searchController.dispose(); + _searchDebounce?.cancel(); super.dispose(); } @@ -48,6 +53,46 @@ class _SeriesTabState extends ConsumerState { } } + 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 _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 _loadMoreSeries() async { if (_isLoading || !_hasMore) return; @@ -84,17 +129,14 @@ class _SeriesTabState extends ConsumerState { 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 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 { 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 { 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 { 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 { // 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 { 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); }, ), diff --git a/web/player.html b/web/player.html index 23dc2cd..7a3e10c 100644 --- a/web/player.html +++ b/web/player.html @@ -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();