From bf5893e54540bbc501cdaea54400564e6dee887e Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 10 Dec 2025 11:13:53 +0100 Subject: [PATCH] Feature: Resume playback from last position - Added resumePositions storage in MobileWatchHistory - Save position on player exit (if not finished) - Auto-seek to saved position on content load - Clear resume position when content finished (80%+) --- .../iptv/screens/native_player_screen.dart | 49 +++++++++++++++++++ .../providers/mobile_settings_providers.dart | 39 ++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index e260363..52608d7 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -228,6 +228,9 @@ class _NativePlayerScreenState extends ConsumerState { @override void dispose() { + // Save resume position before cleanup (only for VOD/Series) + _saveResumePositionOnExit(); + _clockTimer?.cancel(); _controlsTimer?.cancel(); _liveWatchdog?.cancel(); @@ -246,6 +249,38 @@ class _NativePlayerScreenState extends ConsumerState { super.dispose(); } + /// Get content ID for resume position storage + String get _contentId { + if (widget.streamType == StreamType.series && + widget.seriesId != null && + widget.season != null && + widget.episodeNum != null) { + return MobileWatchHistory.episodeKey(widget.seriesId, widget.season!, widget.episodeNum!); + } + return widget.streamId; + } + + /// Save resume position when exiting player + void _saveResumePositionOnExit() { + if (widget.streamType == StreamType.live) return; + if (_duration.inSeconds <= 0) return; + + final progress = _position.inSeconds / _duration.inSeconds; + + // Don't save if almost finished (> 90%) or already marked as watched + if (progress > 0.90 || _hasMarkedAsWatched) { + ref.read(mobileWatchHistoryProvider.notifier).clearResumePosition(_contentId); + return; + } + + // Save position + ref.read(mobileWatchHistoryProvider.notifier).saveResumePosition( + _contentId, + _position.inSeconds, + ); + debugPrint('MediaKitPlayer: Saved resume position at ${_position.inSeconds}s'); + } + /// Attempt to reconnect a live stream that stopped void _attemptReconnect() { if (!mounted) return; @@ -393,6 +428,20 @@ class _NativePlayerScreenState extends ConsumerState { if (widget.streamType == StreamType.live) { _startLiveWatchdog(); } + + // Resume from saved position (VOD/Series only) + if (widget.streamType != StreamType.live) { + final resumePos = ref.read(mobileWatchHistoryProvider).getResumePosition(_contentId); + if (resumePos > 30) { + // Wait for player to stabilize, then seek + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted && _isPlaying) { + debugPrint('MediaKitPlayer: Resuming from ${resumePos}s'); + _player.seek(Duration(seconds: resumePos)); + } + }); + } + } } catch (e) { debugPrint('MediaKitPlayer error: $e'); diff --git a/lib/mobile/providers/mobile_settings_providers.dart b/lib/mobile/providers/mobile_settings_providers.dart index 41b5f59..c235281 100644 --- a/lib/mobile/providers/mobile_settings_providers.dart +++ b/lib/mobile/providers/mobile_settings_providers.dart @@ -122,24 +122,31 @@ final mobileSettingsProvider = StateNotifierProvider watchedMovies; final Set watchedEpisodes; + final Map resumePositions; // streamId -> position in seconds const MobileWatchHistory({ this.watchedMovies = const {}, this.watchedEpisodes = const {}, + this.resumePositions = const {}, }); MobileWatchHistory copyWith({ Set? watchedMovies, Set? watchedEpisodes, + Map? resumePositions, }) { return MobileWatchHistory( watchedMovies: watchedMovies ?? this.watchedMovies, watchedEpisodes: watchedEpisodes ?? this.watchedEpisodes, + resumePositions: resumePositions ?? this.resumePositions, ); } bool isMovieWatched(String streamId) => watchedMovies.contains(streamId); bool isEpisodeWatched(String episodeKey) => watchedEpisodes.contains(episodeKey); + + /// Get resume position in seconds (0 if none saved) + int getResumePosition(String contentId) => resumePositions[contentId] ?? 0; static String episodeKey(dynamic seriesId, int season, int episodeNum) { return '$seriesId:$season:$episodeNum'; @@ -159,17 +166,23 @@ class MobileWatchHistoryNotifier extends StateNotifier { final movies = _box?.get('watchedMovies', defaultValue: []) as List?; final episodes = _box?.get('watchedEpisodes', defaultValue: []) as List?; + final positions = _box?.get('resumePositions', defaultValue: {}) as Map?; state = MobileWatchHistory( watchedMovies: movies?.cast().toSet() ?? {}, watchedEpisodes: episodes?.cast().toSet() ?? {}, + resumePositions: positions?.cast() ?? {}, ); } void markMovieWatched(String streamId) { final updated = {...state.watchedMovies, streamId}; - state = state.copyWith(watchedMovies: updated); + // Clear resume position when marked as fully watched + final positions = {...state.resumePositions}; + positions.remove(streamId); + state = state.copyWith(watchedMovies: updated, resumePositions: positions); _box?.put('watchedMovies', updated.toList()); + _box?.put('resumePositions', positions); } void toggleMovieWatched(String streamId) { @@ -185,8 +198,30 @@ class MobileWatchHistoryNotifier extends StateNotifier { void markEpisodeWatched(String episodeKey) { final updated = {...state.watchedEpisodes, episodeKey}; - state = state.copyWith(watchedEpisodes: updated); + // Clear resume position when marked as fully watched + final positions = {...state.resumePositions}; + positions.remove(episodeKey); + state = state.copyWith(watchedEpisodes: updated, resumePositions: positions); _box?.put('watchedEpisodes', updated.toList()); + _box?.put('resumePositions', positions); + } + + /// Save resume position for movie or episode + void saveResumePosition(String contentId, int positionSeconds) { + // Only save if position is meaningful (> 30 seconds and < 95% of typical content) + if (positionSeconds < 30) return; + + final positions = {...state.resumePositions, contentId: positionSeconds}; + state = state.copyWith(resumePositions: positions); + _box?.put('resumePositions', positions); + } + + /// Clear resume position (when content finished or user wants to restart) + void clearResumePosition(String contentId) { + final positions = {...state.resumePositions}; + positions.remove(contentId); + state = state.copyWith(resumePositions: positions); + _box?.put('resumePositions', positions); } }