diff --git a/bin/server.dart b/bin/server.dart index e69611f..a55e074 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -406,47 +406,77 @@ Handler _createStreamHandler() { // Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome) // Using fast preset for lower CPU usage // Added resilience flags for corrupted/discontinuous streams - final process = await Process.start('ffmpeg', [ + // + // Check if this is a VOD (file) or live stream + final isVod = streamId.startsWith('vod_'); + + final ffmpegArgs = [ '-y', // Overwrite output files '-loglevel', 'warning', // Reduce verbose output '-err_detect', 'ignore_err', // Ignore decoding errors - '-fflags', '+genpts+discardcorrupt+igndts', // Generate PTS, discard corrupt frames, ignore DTS - '-flags', 'low_delay', // Low delay mode - '-analyzeduration', '2000000', // Limit analysis time (2 seconds) - '-probesize', '1000000', // Limit probe size (1MB) + '-fflags', '+genpts+discardcorrupt', // Generate PTS, discard corrupt frames + '-analyzeduration', isVod ? '10000000' : '2000000', // 10s for VOD, 2s for live + '-probesize', isVod ? '5000000' : '1000000', // 5MB for VOD, 1MB for live '-user_agent', 'VLC/3.0.18 LibVLC/3.0.18', '-headers', 'Accept: */*\r\nConnection: keep-alive\r\n', - '-reconnect', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '5', - '-reconnect_on_network_error', '1', // Reconnect on network errors - '-reconnect_on_http_error', '4xx,5xx', // Reconnect on HTTP errors + ]; + + // Add reconnect flags only for live streams (not VOD) + if (!isVod) { + ffmpegArgs.addAll([ + '-reconnect', '1', + '-reconnect_streamed', '1', + '-reconnect_delay_max', '5', + '-reconnect_on_network_error', '1', + '-reconnect_on_http_error', '4xx,5xx', + ]); + } + + ffmpegArgs.addAll([ '-i', iptvUrl, // Video: transcode to H.264 for browser compatibility '-c:v', 'libx264', '-preset', 'ultrafast', // Fastest encoding (less CPU) - '-tune', 'zerolatency', // Low latency for live streaming '-profile:v', 'baseline', // Most compatible H.264 profile - '-level', '3.0', + '-level', '3.1', + '-pix_fmt', 'yuv420p', // Required for browser compatibility '-b:v', '2500k', // 2.5 Mbps video bitrate '-maxrate', '3000k', '-bufsize', '6000k', - '-vsync', 'passthrough', // Pass through timestamps as-is (fixes discontinuity) + ]); + + // Add latency flags only for live streams + if (!isVod) { + ffmpegArgs.addAll([ + '-tune', 'zerolatency', // Low latency for live streaming + '-vsync', 'passthrough', // Pass through timestamps as-is + ]); + } else { + ffmpegArgs.addAll([ + '-vsync', 'cfr', // Constant frame rate for VOD + '-g', '48', // Keyframe every 2 seconds at 24fps + '-keyint_min', '48', + ]); + } + + ffmpegArgs.addAll([ '-avoid_negative_ts', 'make_zero', // Handle negative timestamps // Audio: transcode to AAC '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', - '-async', '1', // Audio sync (stretch/compression allowed) + '-ac', '2', // Force stereo // HLS output settings '-f', 'hls', - '-hls_time', '2', // 2 second segments - '-hls_list_size', '5', // Keep only last 5 segments - '-hls_flags', 'delete_segments+append_list+omit_endlist', + '-hls_time', isVod ? '4' : '2', // 4 second segments for VOD, 2 for live + '-hls_list_size', isVod ? '0' : '5', // Keep all segments for VOD, last 5 for live + '-hls_flags', isVod ? 'independent_segments' : 'delete_segments+append_list+omit_endlist', '-hls_segment_filename', '${outputDir.path}/segment_%d.ts', outputPath, ]); + final process = await Process.start('ffmpeg', ffmpegArgs); + _activeStreams[streamId] = process; // Collect FFmpeg stderr for error reporting diff --git a/lib/features/iptv/providers/watch_history_provider.dart b/lib/features/iptv/providers/watch_history_provider.dart new file mode 100644 index 0000000..d8db5d1 --- /dev/null +++ b/lib/features/iptv/providers/watch_history_provider.dart @@ -0,0 +1,157 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +/// Watch history state - tracks which content has been watched +class WatchHistory { + /// Set of watched movie IDs + final Set watchedMovies; + + /// Set of watched episode IDs (format: seriesId_seasonNum_episodeNum) + final Set watchedEpisodes; + + const WatchHistory({ + this.watchedMovies = const {}, + this.watchedEpisodes = const {}, + }); + + WatchHistory copyWith({ + Set? watchedMovies, + Set? watchedEpisodes, + }) { + return WatchHistory( + watchedMovies: watchedMovies ?? this.watchedMovies, + watchedEpisodes: watchedEpisodes ?? this.watchedEpisodes, + ); + } + + /// Check if a movie has been watched + bool isMovieWatched(String movieId) => watchedMovies.contains(movieId); + + /// Check if an episode has been watched + bool isEpisodeWatched(String episodeId) => watchedEpisodes.contains(episodeId); + + /// Generate episode key + static String episodeKey(int seriesId, int seasonNum, int episodeNum) { + return '${seriesId}_${seasonNum}_$episodeNum'; + } +} + +/// Watch history notifier with persistence +class WatchHistoryNotifier extends StateNotifier { + static const String _moviesKey = 'watch_history_movies'; + static const String _episodesKey = 'watch_history_episodes'; + + SharedPreferences? _prefs; + bool _initialized = false; + + WatchHistoryNotifier() : super(const WatchHistory()) { + _loadHistory(); + } + + Future _loadHistory() async { + if (_initialized) return; + + try { + _prefs = await SharedPreferences.getInstance(); + + final moviesJson = _prefs?.getString(_moviesKey); + final episodesJson = _prefs?.getString(_episodesKey); + + final movies = moviesJson != null + ? Set.from(jsonDecode(moviesJson) as List) + : {}; + + final episodes = episodesJson != null + ? Set.from(jsonDecode(episodesJson) as List) + : {}; + + state = WatchHistory( + watchedMovies: movies, + watchedEpisodes: episodes, + ); + + _initialized = true; + print('Watch history loaded: ${movies.length} movies, ${episodes.length} episodes'); + } catch (e) { + print('Error loading watch history: $e'); + } + } + + Future _saveMovies() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + await _prefs?.setString(_moviesKey, jsonEncode(state.watchedMovies.toList())); + } catch (e) { + print('Error saving movie watch history: $e'); + } + } + + Future _saveEpisodes() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + await _prefs?.setString(_episodesKey, jsonEncode(state.watchedEpisodes.toList())); + } catch (e) { + print('Error saving episode watch history: $e'); + } + } + + /// Mark a movie as watched + void markMovieWatched(String movieId) { + final newSet = Set.from(state.watchedMovies)..add(movieId); + state = state.copyWith(watchedMovies: newSet); + _saveMovies(); + } + + /// Mark a movie as unwatched + void markMovieUnwatched(String movieId) { + final newSet = Set.from(state.watchedMovies)..remove(movieId); + state = state.copyWith(watchedMovies: newSet); + _saveMovies(); + } + + /// Toggle movie watched status + void toggleMovieWatched(String movieId) { + if (state.isMovieWatched(movieId)) { + markMovieUnwatched(movieId); + } else { + markMovieWatched(movieId); + } + } + + /// Mark an episode as watched + void markEpisodeWatched(String episodeKey) { + final newSet = Set.from(state.watchedEpisodes)..add(episodeKey); + state = state.copyWith(watchedEpisodes: newSet); + _saveEpisodes(); + } + + /// Mark an episode as unwatched + void markEpisodeUnwatched(String episodeKey) { + final newSet = Set.from(state.watchedEpisodes)..remove(episodeKey); + state = state.copyWith(watchedEpisodes: newSet); + _saveEpisodes(); + } + + /// Toggle episode watched status + void toggleEpisodeWatched(String episodeKey) { + if (state.isEpisodeWatched(episodeKey)) { + markEpisodeUnwatched(episodeKey); + } else { + markEpisodeWatched(episodeKey); + } + } + + /// Clear all watch history + void clearAll() { + state = const WatchHistory(); + _saveMovies(); + _saveEpisodes(); + } +} + +/// Provider for watch history +final watchHistoryProvider = + StateNotifierProvider((ref) { + return WatchHistoryNotifier(); +}); diff --git a/lib/features/iptv/screens/series_detail_screen.dart b/lib/features/iptv/screens/series_detail_screen.dart index e79e1c3..3e2437e 100644 --- a/lib/features/iptv/screens/series_detail_screen.dart +++ b/lib/features/iptv/screens/series_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; +import '../providers/watch_history_provider.dart'; import 'player_screen.dart'; /// Series detail screen showing seasons and episodes @@ -241,11 +242,22 @@ class _SeriesDetailScreenState extends ConsumerState { } Widget _buildEpisodeTile(Episode episode) { + final watchHistory = ref.watch(watchHistoryProvider); + final episodeKey = WatchHistory.episodeKey( + widget.series.seriesId, + _selectedSeason, + episode.episodeNum, + ); + final isWatched = watchHistory.isEpisodeWatched(episodeKey); + return Card( color: Colors.grey.shade800, margin: const EdgeInsets.only(bottom: 8), child: InkWell( onTap: () { + // Mark as watched when playing + ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey); + Navigator.push( context, MaterialPageRoute( @@ -259,6 +271,10 @@ class _SeriesDetailScreenState extends ConsumerState { ), ); }, + onLongPress: () { + // Toggle watched on long press + ref.read(watchHistoryProvider.notifier).toggleEpisodeWatched(episodeKey); + }, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(12), @@ -269,18 +285,20 @@ class _SeriesDetailScreenState extends ConsumerState { width: 40, height: 40, decoration: BoxDecoration( - color: Colors.blue.shade700, + color: isWatched ? Colors.green : Colors.blue.shade700, borderRadius: BorderRadius.circular(8), ), child: Center( - child: Text( - '${episode.episodeNum}', - style: GoogleFonts.roboto( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), + child: isWatched + ? const Icon(Icons.check, color: Colors.white, size: 20) + : Text( + '${episode.episodeNum}', + style: GoogleFonts.roboto( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), ), ), const SizedBox(width: 12), @@ -295,7 +313,7 @@ class _SeriesDetailScreenState extends ConsumerState { style: GoogleFonts.roboto( fontSize: 14, fontWeight: FontWeight.w500, - color: Colors.white, + color: isWatched ? Colors.white54 : Colors.white, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -313,7 +331,11 @@ class _SeriesDetailScreenState extends ConsumerState { ), // Play icon - const Icon(Icons.play_circle_outline, color: Colors.white70, size: 28), + Icon( + isWatched ? Icons.replay : Icons.play_circle_outline, + color: Colors.white70, + size: 28, + ), ], ), ), diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 5e0ba2d..4f91609 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -6,6 +6,7 @@ import '../../../core/models/playlist_config.dart'; import '../models/xtream_models.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; +import '../providers/watch_history_provider.dart'; import '../screens/player_screen.dart'; class MoviesTab extends ConsumerStatefulWidget { @@ -136,7 +137,7 @@ class _MoviesTabState extends ConsumerState { } } -class _MovieCard extends StatelessWidget { +class _MovieCard extends ConsumerWidget { final Movie movie; final PlaylistConfig playlist; @@ -146,12 +147,17 @@ class _MovieCard extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final watchHistory = ref.watch(watchHistoryProvider); + final isWatched = watchHistory.isMovieWatched(movie.streamId); + return Card( clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { - // Use PlayerScreen for VOD (video on demand) + // Mark as watched when opening + ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); + Navigator.push( context, MaterialPageRoute( @@ -165,67 +171,93 @@ class _MovieCard extends StatelessWidget { ), ); }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + onLongPress: () { + // Toggle watched status on long press + ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); + }, + child: Stack( children: [ - Expanded( - child: movie.streamIcon != null - ? CachedNetworkImage( - imageUrl: movie.streamIcon!, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Colors.grey.shade800, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - errorWidget: (context, url, error) => Container( - color: Colors.grey.shade800, - child: const Icon(Icons.movie, size: 48), - ), - ) - : Container( - color: Colors.grey.shade800, - child: const Icon(Icons.movie, size: 48), - ), - ), - Container( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - movie.name, - style: GoogleFonts.roboto( - fontSize: 11, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (movie.rating != null) ...[ - const SizedBox(height: 2), - Row( - children: [ - Icon( - Icons.star, - size: 12, - color: Colors.amber.shade700, - ), - const SizedBox(width: 2), - Text( - movie.rating!, - style: GoogleFonts.roboto( - fontSize: 10, - color: Colors.grey.shade600, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: movie.streamIcon != null + ? CachedNetworkImage( + imageUrl: movie.streamIcon!, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Colors.grey.shade800, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), ), + errorWidget: (context, url, error) => Container( + color: Colors.grey.shade800, + child: const Icon(Icons.movie, size: 48), + ), + ) + : Container( + color: Colors.grey.shade800, + child: const Icon(Icons.movie, size: 48), + ), + ), + Container( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + movie.name, + style: GoogleFonts.roboto( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (movie.rating != null) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.star, + size: 12, + color: Colors.amber.shade700, + ), + const SizedBox(width: 2), + Text( + movie.rating!, + style: GoogleFonts.roboto( + fontSize: 10, + color: Colors.grey.shade600, + ), + ), + ], ), ], - ), - ], - ], - ), + ], + ), + ), + ], ), + // Watched indicator + if (isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.check, + size: 14, + color: Colors.white, + ), + ), + ), ], ), ),