diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 4ce9aae8..424ecded 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -53,6 +53,7 @@ import '../utils/grid_size_calculator.dart'; import '../utils/layout_constants.dart'; import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; +import '../providers/watch_state_store.dart'; import '../theme/mono_tokens.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; @@ -181,22 +182,6 @@ class _SeasonEpisodePager { if (changed) _states[entry.key] = entry.value.replaceItems(next); } } - - void patchWhere(bool Function(String seasonId, MediaItem episode) test, MediaItem Function(MediaItem episode) patch) { - for (final entry in _states.entries.toList()) { - var changed = false; - final next = []; - for (final episode in entry.value.items) { - if (test(entry.key, episode)) { - changed = true; - next.add(patch(episode)); - } else { - next.add(episode); - } - } - if (changed) _states[entry.key] = entry.value.replaceItems(next); - } - } } class MediaDetailScreen extends StatefulWidget { @@ -246,7 +231,6 @@ class _MediaDetailScreenState extends State bool _showEpisodesDirectly = false; MediaItem? _fullMetadata; MediaItem? _onDeckEpisode; - final Map _localProgressById = {}; bool _isLoadingMetadata = true; List? _extras; List _relatedHubs = []; @@ -379,25 +363,14 @@ class _MediaDetailScreenState extends State @override void onWatchStateChanged(WatchStateEvent event) { _watchStateChanged = true; - final epIndex = _episodes.indexWhere((e) => e.id == event.itemId); + if (event.changeType == WatchStateChangeType.removedFromContinueWatching) return; - if (event.changeType == WatchStateChangeType.removedFromContinueWatching) { - return; - } + // Lists keep their server snapshots untouched; cards and the hero resolve + // them against [WatchStateStore] at build, so a rebuild is all the + // visuals need. + setStateIfMounted(() {}); - if (event.changeType == WatchStateChangeType.progressUpdate) { - if (event.viewOffset != null) { - _patchLocalProgress(event.itemId, event.viewOffset!, epIndex: epIndex); - } - if (event.isNowWatched != true) return; - } - - _localProgressById.remove(event.itemId); - _patchWatchedStateFromEvent( - event, - epIndex: epIndex, - clearWatchedProgress: !widget.isOffline || event.changeType == WatchStateChangeType.progressUpdate, - ); + if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) return; if (widget.isOffline) { if (_metadata.isShow) { @@ -408,190 +381,34 @@ class _MediaDetailScreenState extends State // Online: refresh server-derived counters and on-deck state. A watched // episode can change the hero play target even when the episode row itself - // was already visible and patched locally. + // was already visible. unawaited(_refreshWatchState()); } - void _patchWatchedStateFromEvent(WatchStateEvent event, {required int epIndex, required bool clearWatchedProgress}) { - final isWatched = event.isNowWatched; - if (isWatched == null) return; - - setStateIfMounted(() { - final base = _fullMetadata ?? widget.metadata; - final episodeDelta = epIndex == -1 ? 0 : _watchStateDelta(_episodes[epIndex], isWatched); - - if (base.id == event.itemId) { - _fullMetadata = _withWatchedPatch(base, isWatched, clearWatchedProgress: clearWatchedProgress); - } - - final onDeckEpisode = _onDeckEpisode; - if (onDeckEpisode != null && onDeckEpisode.id == event.itemId) { - _onDeckEpisode = _withWatchedPatch(onDeckEpisode, isWatched, clearWatchedProgress: clearWatchedProgress); - } - - _patchWatchedInListWhere( - _seasons, - (item) => item.id == event.itemId, - isWatched, - clearWatchedProgress: clearWatchedProgress, - ); - _patchWatchedInListWhere( - _episodes, - (item) => item.id == event.itemId, - isWatched, - clearWatchedProgress: clearWatchedProgress, - ); - _seasonEpisodePager.patchWhere( - (_, item) => item.id == event.itemId, - (item) => _withWatchedPatch(item, isWatched, clearWatchedProgress: clearWatchedProgress), - ); - final extras = _extras; - if (extras != null) { - _patchWatchedInListWhere( - extras, - (item) => item.id == event.itemId, - isWatched, - clearWatchedProgress: clearWatchedProgress, - ); - } - _relatedHubs = _patchWatchedInHubs( - _relatedHubs, - (item) => item.id == event.itemId, - isWatched, - clearWatchedProgress: clearWatchedProgress, - ); - - if (base.id == event.itemId || _seasons.any((season) => season.id == event.itemId)) { - _patchLoadedDescendantsOf(event.itemId, isWatched, clearWatchedProgress: clearWatchedProgress); - } else if (episodeDelta != 0) { - _adjustParentWatchCounts(event.parentChain, episodeDelta); - } - _syncFlattenEpisodeState(); - }); - } - - MediaItem _withWatchedPatch(MediaItem item, bool isWatched, {required bool clearWatchedProgress}) { - final viewOffsetMs = isWatched && !clearWatchedProgress ? null : 0; - var updated = item.copyWith(viewCount: isWatched ? 1 : 0); - if (item.leafCount != null || item.viewedLeafCount != null) { - updated = updated.copyWith(viewedLeafCount: isWatched ? (item.leafCount ?? item.viewedLeafCount ?? 1) : 0); - } - return viewOffsetMs == null ? updated : updated.copyWith(viewOffsetMs: viewOffsetMs); - } - - int _watchStateDelta(MediaItem item, bool isWatched) { - if (item.isWatched == isWatched) return 0; - return isWatched ? 1 : -1; - } - - void _patchWatchedInListWhere( - List items, - bool Function(MediaItem item) test, - bool isWatched, { - required bool clearWatchedProgress, - }) { - for (var i = 0; i < items.length; i++) { - if (test(items[i])) { - items[i] = _withWatchedPatch(items[i], isWatched, clearWatchedProgress: clearWatchedProgress); - } + /// Session-fresh view of [item]: server snapshot + newest watch-state patch. + MediaItem _fresh(MediaItem item) { + try { + return context.read().apply(item); + } on ProviderNotFoundException { + return item; } } - List _patchWatchedInHubs( - List hubs, - bool Function(MediaItem item) test, - bool isWatched, { - required bool clearWatchedProgress, - }) { - var changed = false; - final updatedHubs = []; - for (final hub in hubs) { - var hubChanged = false; - final items = List.of(hub.items); - _patchWatchedInListWhere( - items, - (item) { - final matches = test(item); - hubChanged = hubChanged || matches; - return matches; - }, - isWatched, - clearWatchedProgress: clearWatchedProgress, - ); - changed = changed || hubChanged; - updatedHubs.add(hubChanged ? hub.copyWith(items: items) : hub); + List _freshAll(List items) { + try { + return context.read().applyAll(items); + } on ProviderNotFoundException { + return items; } - return changed ? updatedHubs : hubs; - } - - void _patchLoadedDescendantsOf(String parentId, bool isWatched, {required bool clearWatchedProgress}) { - bool isDescendant(MediaItem item) => item.parentChain.contains(parentId); - _patchWatchedInListWhere(_seasons, isDescendant, isWatched, clearWatchedProgress: clearWatchedProgress); - _patchWatchedInListWhere(_episodes, isDescendant, isWatched, clearWatchedProgress: clearWatchedProgress); - _seasonEpisodePager.patchWhere( - (seasonId, item) => seasonId == parentId || isDescendant(item), - (item) => _withWatchedPatch(item, isWatched, clearWatchedProgress: clearWatchedProgress), - ); - } - - void _adjustParentWatchCounts(List parentIds, int delta) { - if (parentIds.isEmpty) return; - final parentIdSet = parentIds.toSet(); - final base = _fullMetadata ?? widget.metadata; - if (parentIdSet.contains(base.id)) { - _fullMetadata = _withAdjustedViewedLeafCount(base, delta); - } - for (var i = 0; i < _seasons.length; i++) { - if (parentIdSet.contains(_seasons[i].id)) { - _seasons[i] = _withAdjustedViewedLeafCount(_seasons[i], delta); - } - } - } - - MediaItem _withAdjustedViewedLeafCount(MediaItem item, int delta) { - final viewedLeafCount = item.viewedLeafCount; - if (viewedLeafCount == null) return item; - final max = item.leafCount ?? (1 << 30); - final next = (viewedLeafCount + delta).clamp(0, max).toInt(); - return item.copyWith(viewedLeafCount: next); - } - - void _patchLocalProgress(String itemId, int viewOffset, {int? epIndex}) { - _localProgressById[itemId] = viewOffset; - setStateIfMounted(() { - final base = _fullMetadata ?? widget.metadata; - if (base.id == itemId) { - _fullMetadata = base.copyWith(viewOffsetMs: viewOffset); - } - - final onDeckEpisode = _onDeckEpisode; - if (onDeckEpisode != null && onDeckEpisode.id == itemId) { - _onDeckEpisode = onDeckEpisode.copyWith(viewOffsetMs: viewOffset); - } - - final index = epIndex ?? _episodes.indexWhere((e) => e.id == itemId); - if (index != -1) { - final updated = _episodes[index].copyWith(viewOffsetMs: viewOffset); - _episodes[index] = updated; - _syncEpisodeToCache(index, updated); - } - }); - } - - MediaItem _applyLocalProgress(MediaItem item) { - if (!_localProgressById.containsKey(item.id)) return item; - return item.copyWith(viewOffsetMs: _localProgressById[item.id]!); } MediaItem _normalizeRefreshedItem(MediaItem item, MediaItem fallback) { - return _applyLocalProgress( - _withFallbackLibrary( - item.copyWith( - serverId: item.serverId ?? fallback.serverId ?? _metadata.serverId, - serverName: item.serverName ?? fallback.serverName ?? _metadata.serverName, - ), - fallback, + return _withFallbackLibrary( + item.copyWith( + serverId: item.serverId ?? fallback.serverId ?? _metadata.serverId, + serverName: item.serverName ?? fallback.serverName ?? _metadata.serverName, ), + fallback, ); } @@ -781,19 +598,15 @@ class _MediaDetailScreenState extends State final metadata = result.item; final onDeckEpisode = result.onDeckEpisode; if (metadata != null) { - final refreshedMetadata = _applyLocalProgress( - _withFallbackLibrary( - metadata.copyWith(serverId: serverId, serverName: serverName ?? metadata.serverName), - _metadata, - ), + final refreshedMetadata = _withFallbackLibrary( + metadata.copyWith(serverId: serverId, serverName: serverName ?? metadata.serverName), + _metadata, ); final refreshedOnDeck = onDeckEpisode == null ? null - : _applyLocalProgress( - _withFallbackLibrary( - onDeckEpisode.copyWith(serverId: serverId, serverName: serverName ?? onDeckEpisode.serverName), - refreshedMetadata, - ), + : _withFallbackLibrary( + onDeckEpisode.copyWith(serverId: serverId, serverName: serverName ?? onDeckEpisode.serverName), + refreshedMetadata, ); setStateIfMounted(() { _fullMetadata = refreshedMetadata; @@ -1425,7 +1238,7 @@ class _MediaDetailScreenState extends State : await context.read().lookupOfflineMetadata(serverId, _metadata.id); if (!mounted) return; setState(() { - _fullMetadata = _applyLocalProgress(cachedMetadata ?? _metadata); + _fullMetadata = cachedMetadata ?? _metadata; _isLoadingMetadata = false; _hasLoadedExtras = true; _hasLoadedRelatedHubs = true; @@ -1453,7 +1266,7 @@ class _MediaDetailScreenState extends State // Truly orphaned item (server gone) — fall back to widget metadata // and let downstream loaders no-op gracefully. setState(() { - _fullMetadata = _applyLocalProgress(_metadata); + _fullMetadata = _metadata; _isLoadingMetadata = false; _hasLoadedSeasons = true; _hasLoadedEpisodes = true; @@ -1473,22 +1286,18 @@ class _MediaDetailScreenState extends State final serverId = _metadata.serverId; final serverName = _metadata.serverName; final source = metadata ?? _metadata; - final base = _applyLocalProgress( - _withFallbackLibrary( - source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName), - _metadata, - ), + final base = _withFallbackLibrary( + source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName), + _metadata, ); final onDeckWithServerId = onDeckEpisode == null ? null - : _applyLocalProgress( - _withFallbackLibrary( - onDeckEpisode.copyWith( - serverId: serverId ?? onDeckEpisode.serverId, - serverName: serverName ?? onDeckEpisode.serverName, - ), - base, + : _withFallbackLibrary( + onDeckEpisode.copyWith( + serverId: serverId ?? onDeckEpisode.serverId, + serverName: serverName ?? onDeckEpisode.serverName, ), + base, ); setState(() { @@ -1513,7 +1322,7 @@ class _MediaDetailScreenState extends State // Fallback to passed metadata on error if (!mounted) return; setState(() { - _fullMetadata = _applyLocalProgress(_metadata); + _fullMetadata = _metadata; _isLoadingMetadata = false; _hasLoadedExtras = true; _hasLoadedRelatedHubs = true; @@ -1687,8 +1496,7 @@ class _MediaDetailScreenState extends State for (final entry in seasonMap.entries) { final seasonRatingKey = entry.value.first.parentId ?? ''; final sortedEpisodes = entry.value..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); - final episodesWithProgress = sortedEpisodes.map(_applyLocalProgress).toList(); - _seasonEpisodePager.completeFirstPage(seasonRatingKey, episodesWithProgress, episodesWithProgress.length); + _seasonEpisodePager.completeFirstPage(seasonRatingKey, sortedEpisodes, sortedEpisodes.length); } final onDeckSeasonIndex = _findOnDeckSeasonIndex(seasons); @@ -1718,8 +1526,7 @@ class _MediaDetailScreenState extends State ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); setState(() { - final episodes = seasonEpisodes.map(_applyLocalProgress).toList(); - _allEpisodes = _allEpisodes.completeInitialLoad(episodes, episodes.length); + _allEpisodes = _allEpisodes.completeInitialLoad(seasonEpisodes, seasonEpisodes.length); _episodes = _allEpisodes.items; _hasLoadedEpisodes = true; }); @@ -1755,8 +1562,7 @@ class _MediaDetailScreenState extends State // demand) and clear any prior per-season loading/error state. final cached = _seasonEpisodePager.stateFor(seasonId); if (_seasonEpisodePager.hasState(seasonId) && !cached.isInitialLoading && !cached.initialLoadFailed) { - final episodes = cached.items.map(_applyLocalProgress).toList(); - _seasonEpisodePager.completeFirstPage(seasonId, episodes, cached.totalCount); + _seasonEpisodePager.completeFirstPage(seasonId, cached.items, cached.totalCount); setStateIfMounted(() { if (_isSelectedSeason(seasonIndex, seasonId)) { _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items); @@ -1789,12 +1595,11 @@ class _MediaDetailScreenState extends State final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id); final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == season.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); - final episodes = seasonEpisodes.map(_applyLocalProgress).toList(); _completeSeasonEpisodesLoad( seasonIndex: seasonIndex, seasonId: seasonId, - episodes: episodes, - total: episodes.length, + episodes: seasonEpisodes, + total: seasonEpisodes.length, generation: generation, ); } else { @@ -1822,7 +1627,7 @@ class _MediaDetailScreenState extends State _completeSeasonEpisodesLoad( seasonIndex: seasonIndex, seasonId: seasonId, - episodes: page.items.map(_applyLocalProgress).toList(), + episodes: page.items, total: page.totalCount, generation: generation, ); @@ -1870,11 +1675,10 @@ class _MediaDetailScreenState extends State if (!mounted || _showEpisodesDirectly || seasonIndex >= _seasons.length || _seasons[seasonIndex].id != seasonId) { return; } - final episodes = page.items.map(_applyLocalProgress).toList(); setStateIfMounted(() { final current = _seasonEpisodePager.stateFor(seasonId); if (_seasonEpisodePager.hasState(seasonId) && !(current.isInitialLoading && !current.hasItems)) return; - _seasonEpisodePager.completeFirstPage(seasonId, episodes, page.totalCount); + _seasonEpisodePager.completeFirstPage(seasonId, page.items, page.totalCount); if (_isSelectedSeason(seasonIndex, seasonId)) { _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items); } @@ -1929,9 +1733,13 @@ class _MediaDetailScreenState extends State size: _episodesPageSize, ); if (!mounted || generation != _episodesLoadGeneration) return; - final more = page.items.map(_applyLocalProgress).toList(); setStateIfMounted(() { - _seasonEpisodePager.completeMoreLoad(seasonId, expectedOffset: loaded, episodes: more, total: page.totalCount); + _seasonEpisodePager.completeMoreLoad( + seasonId, + expectedOffset: loaded, + episodes: page.items, + total: page.totalCount, + ); if (_isSelectedSeason(seasonIndex, seasonId)) _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items); }); } catch (e, st) { @@ -2690,9 +2498,8 @@ class _MediaDetailScreenState extends State final refreshed = await client?.fetchItem(episode.id); if (refreshed != null) { setStateIfMounted(() { - final updated = _applyLocalProgress(refreshed); - _episodes[index] = updated; - _syncEpisodeToCache(index, updated); + _episodes[index] = refreshed; + _syncEpisodeToCache(index, refreshed); }); } }, @@ -2818,9 +2625,11 @@ class _MediaDetailScreenState extends State } List _enrichPlayableEpisodes(List episodes, ServerId serverId) { - // Enrich each episode with serverId/serverName/grandparent fields — - // Jellyfin's recursive query doesn't always populate them, and the copy is - // a no-op for Plex where the mapper already does. + // Enrich each episode with serverId/serverName/parent fields — backends + // don't always populate them on recursive queries, and hierarchy-aware + // watch-state resolution needs the parentChain intact. The copy is a no-op + // where the mapper already did. + final fallbackParentId = _metadata.isSeason ? _metadata.id : null; final fallbackGrandparentId = _metadata.isSeason ? (_metadata.grandparentId ?? _metadata.parentId) : _metadata.id; final fallbackGrandparentTitle = _metadata.isSeason ? (_metadata.grandparentTitle ?? _metadata.parentTitle) @@ -2831,13 +2640,13 @@ class _MediaDetailScreenState extends State e.copyWith( serverId: serverId, serverName: _metadata.serverName ?? e.serverName, + parentId: e.parentId ?? fallbackParentId, grandparentId: e.grandparentId ?? fallbackGrandparentId, grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle, ), _metadata, ), ) - .map(_applyLocalProgress) .toList(); } @@ -2925,7 +2734,7 @@ class _MediaDetailScreenState extends State final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id); setStateIfMounted(() { - _onDeckEpisode = nextEpisode == null ? null : _applyLocalProgress(nextEpisode); + _onDeckEpisode = nextEpisode; }); if (nextEpisode != null) { @@ -2941,10 +2750,10 @@ class _MediaDetailScreenState extends State /// loaded episode is watched (keep the default S1E1 for a rewatch). void _ensureFallbackOnDeckEpisode() { if (_onDeckEpisode != null) return; - final next = firstUnwatchedEpisode(_episodes); + final next = firstUnwatchedEpisode(_freshAll(_episodes)); if (next == null) return; setStateIfMounted(() { - _onDeckEpisode = _applyLocalProgress(next); + _onDeckEpisode = next; }); } @@ -3043,8 +2852,9 @@ class _MediaDetailScreenState extends State @override Widget build(BuildContext context) { - // Use full metadata if loaded, otherwise use passed metadata - final metadata = _fullMetadata ?? _metadata; + // Session-fresh hero: server snapshot resolved against the watch-state + // store (onWatchStateChanged rebuilds on relevant events). + final metadata = _fresh(_fullMetadata ?? _metadata); final isShow = metadata.isShow; final isMobile = PlatformDetector.isMobile(context); final isTv = PlatformDetector.isTV(); @@ -3907,8 +3717,7 @@ class _MediaDetailScreenState extends State } if (hasLoadedState) { - final episodes = state.items.map(_applyLocalProgress).toList(); - _seasonEpisodePager.completeFirstPage(season.id, episodes, state.totalCount); + _seasonEpisodePager.completeFirstPage(season.id, state.items, state.totalCount); setStateIfMounted(() { _selectedSeasonIndex = seasonIndex; _episodes = List.of(_seasonEpisodePager.stateFor(season.id).items); @@ -4383,7 +4192,7 @@ class _MediaDetailScreenState extends State // For TV shows if (metadata.isShow) { if (_onDeckEpisode != null) { - final episode = _onDeckEpisode!; + final episode = _fresh(_onDeckEpisode!); // Check if episode has been partially watched if (episode.viewOffsetMs != null && episode.viewOffsetMs! > 0) { return Symbols.resume_rounded; // Resume icon