refactor(detail): drop hand-patched watch state for store resolution

Delete the per-event list patching, parent-count adjustment, and the
screen-local progress overlay; rows and the hero resolve against
WatchStateStore at build. Also backfill parentId on playable-descendant
pages so hierarchy resolution covers season detail pages.
This commit is contained in:
edde746
2026-06-10 05:30:52 +02:00
parent 3753967caf
commit 903b8eced8
+70 -261
View File
@@ -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 = <MediaItem>[];
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<MediaDetailScreen>
bool _showEpisodesDirectly = false;
MediaItem? _fullMetadata;
MediaItem? _onDeckEpisode;
final Map<String, int> _localProgressById = {};
bool _isLoadingMetadata = true;
List<MediaItem>? _extras;
List<MediaHub> _relatedHubs = [];
@@ -379,25 +363,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@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<MediaDetailScreen>
// 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<MediaItem> 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<WatchStateStore>().apply(item);
} on ProviderNotFoundException {
return item;
}
}
List<MediaHub> _patchWatchedInHubs(
List<MediaHub> hubs,
bool Function(MediaItem item) test,
bool isWatched, {
required bool clearWatchedProgress,
}) {
var changed = false;
final updatedHubs = <MediaHub>[];
for (final hub in hubs) {
var hubChanged = false;
final items = List<MediaItem>.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<MediaItem> _freshAll(List<MediaItem> items) {
try {
return context.read<WatchStateStore>().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<String> 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<MediaDetailScreen>
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<MediaDetailScreen>
: await context.read<DownloadProvider>().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<MediaDetailScreen>
// 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<MediaDetailScreen>
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<MediaDetailScreen>
// 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<MediaDetailScreen>
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<MediaDetailScreen>
..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<MediaDetailScreen>
// 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<MediaDetailScreen>
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<MediaDetailScreen>
_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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
}
List<MediaItem> _enrichPlayableEpisodes(List<MediaItem> 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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
/// 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<MediaDetailScreen>
@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<MediaDetailScreen>
}
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<MediaDetailScreen>
// 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