-
Notifications
You must be signed in to change notification settings - Fork 17
feat(detail): mark a season watched from its chip on iOS and tvOS #270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1290,68 +1290,152 @@ class ItemDetailViewModel { | |
| } | ||
|
|
||
| /// Series overview action: mutate the season currently selected in the | ||
| /// pill row, not the whole series. The server already fans a season | ||
| /// mutation out to its episodes; refreshing the season + episode payloads | ||
| /// keeps every checkmark and next-up calculation consistent afterward. | ||
| /// pill row, not the whole series. | ||
| func toggleSelectedSeasonWatched() async { | ||
| guard let selectedSeason, | ||
| let seriesId = seriesContentId else { return } | ||
| guard let selectedSeason else { return } | ||
| _ = await setSeasonWatched( | ||
| selectedSeason, | ||
| played: !(selectedSeason.userData?.played ?? false) | ||
| ) | ||
| } | ||
|
|
||
| let played = !(selectedSeason.userData?.played ?? false) | ||
| /// Mark one season watched or unwatched. The server already fans a season | ||
| /// mutation out to its episodes; refreshing the season + episode payloads | ||
| /// keeps every checkmark and next-up calculation consistent afterward. | ||
| /// Works for any season, so chip context menus can target a season that | ||
| /// is not the selected page. Returns false when the server rejected the | ||
| /// change so callers can roll back an optimistic UI state. | ||
| func setSeasonWatched(_ season: Season, played: Bool) async -> Bool { | ||
| guard let seriesId = seriesContentId else { return false } | ||
| // The route can change while the request is in flight. Caches are | ||
| // invalidated regardless; only route-scoped UI publication is skipped. | ||
| let route = watchedMutationRoute() | ||
| do { | ||
| try await SiloAPI.shared.setWatched( | ||
| contentId: selectedSeason.contentId, | ||
| contentId: season.contentId, | ||
| played: played | ||
| ) | ||
| invalidateRelatedCaches( | ||
| contentId: selectedSeason.contentId, | ||
| seriesId: seriesId, | ||
| seasonNumber: selectedSeason.seasonNumber | ||
| } catch { | ||
| // Leave the server-provided state untouched on failure. | ||
| return false | ||
| } | ||
| invalidateRelatedCaches( | ||
| contentId: season.contentId, | ||
| seriesId: seriesId, | ||
| seasonNumber: season.seasonNumber | ||
| ) | ||
| guard route == watchedMutationRoute() else { return true } | ||
|
|
||
| await refreshSeasonList(seriesId: seriesId, route: route) | ||
| guard route == watchedMutationRoute() else { return true } | ||
| let refreshed = seasons.first(where: { | ||
| $0.contentId == season.contentId | ||
| || $0.seasonNumber == season.seasonNumber | ||
| }) ?? season | ||
| if selectedSeason?.seasonNumber == refreshed.seasonNumber { | ||
| await selectSeason( | ||
| refreshed, | ||
| forceRefresh: true, | ||
| coalescesMetadataRequest: false | ||
| ) | ||
|
|
||
| await loadSeasons( | ||
| } else { | ||
| // Refresh the route-scoped page for a non-selected season so a | ||
| // later chip tap or page swipe paints the new checkmarks. This | ||
| // must not touch the selected page's load generation or its | ||
| // loading flag. | ||
| await refreshCachedEpisodePage( | ||
| seriesId: seriesId, | ||
| autoSelectInitial: false, | ||
| coalescesMetadataRequest: false | ||
| seasonNumber: refreshed.seasonNumber, | ||
| route: route | ||
| ) | ||
| if let refreshed = seasons.first(where: { | ||
| $0.contentId == selectedSeason.contentId | ||
| || $0.seasonNumber == selectedSeason.seasonNumber | ||
| }) { | ||
| await selectSeason( | ||
| refreshed, | ||
| forceRefresh: true, | ||
| coalescesMetadataRequest: false | ||
| ) | ||
| } | ||
| } catch { | ||
| // Leave the server-provided state untouched on failure. | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| func setEpisodeWatched(contentId: String, played: Bool) async -> Bool { | ||
| let route = watchedMutationRoute() | ||
| // Captured up front: the caches to invalidate belong to the route that | ||
| // issued the mutation, even if the user has left it by the time the | ||
| // server answers. | ||
| let seriesId = seriesContentId | ||
| let seasonNumber = selectedSeason?.seasonNumber | ||
| do { | ||
| try await SiloAPI.shared.setWatched(contentId: contentId, played: played) | ||
| if contentId == detail?.contentId { | ||
| isWatched = played | ||
| } | ||
| invalidateRelatedCaches( | ||
| contentId: contentId, | ||
| seriesId: seriesContentId, | ||
| seasonNumber: selectedSeason?.seasonNumber | ||
| ) | ||
| if let seriesId = seriesContentId, let seasonNumber = selectedSeason?.seasonNumber { | ||
| await loadEpisodes( | ||
| seriesId: seriesId, | ||
| seasonNumber: seasonNumber, | ||
| refreshFavoriteStates: false, | ||
| coalescesMetadataRequest: false | ||
| ) | ||
| } | ||
| return true | ||
| } catch { | ||
| return false | ||
| } | ||
| invalidateRelatedCaches( | ||
| contentId: contentId, | ||
| seriesId: seriesId, | ||
| seasonNumber: seasonNumber | ||
| ) | ||
| guard route == watchedMutationRoute() else { return true } | ||
| if contentId == detail?.contentId { | ||
| isWatched = played | ||
| } | ||
| if let seriesId, let seasonNumber = selectedSeason?.seasonNumber { | ||
| await loadEpisodes( | ||
| seriesId: seriesId, | ||
| seasonNumber: seasonNumber, | ||
| refreshFavoriteStates: false, | ||
| coalescesMetadataRequest: false | ||
| ) | ||
| // A single episode can complete or reopen its season, so the | ||
| // season-level watched state must follow. | ||
| await refreshSelectedSeasonUserData(seriesId: seriesId, route: route) | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| /// Identity of the detail route a watched mutation started on. Unlike | ||
| /// `detailGeneration`, which advances on every metadata write to the | ||
| /// same page, this changes only when the page shows a different item. | ||
| private struct WatchedMutationRoute: Equatable { | ||
| let contentId: String? | ||
| let seriesId: String? | ||
| } | ||
|
|
||
| private func watchedMutationRoute() -> WatchedMutationRoute { | ||
| WatchedMutationRoute(contentId: detail?.contentId, seriesId: seriesContentId) | ||
| } | ||
|
|
||
| /// Reload the season list and re-point `selectedSeason` at its refreshed | ||
| /// payload without changing the selection or reloading its episodes. | ||
| private func refreshSelectedSeasonUserData(seriesId: String, route: WatchedMutationRoute) async { | ||
| let selectedId = selectedSeason?.contentId | ||
| await refreshSeasonList(seriesId: seriesId, route: route) | ||
| guard route == watchedMutationRoute(), | ||
| let selectedId, | ||
| let refreshed = seasons.first(where: { $0.contentId == selectedId }), | ||
| refreshed != selectedSeason else { return } | ||
| selectedSeason = refreshed | ||
| } | ||
|
|
||
| /// Fresh season list for a watched mutation. Publishes only while the | ||
| /// route that started the mutation is still on screen. | ||
| private func refreshSeasonList(seriesId: String, route: WatchedMutationRoute) async { | ||
| guard let response = try? await SiloAPI.shared.seasons(seriesId: seriesId) else { return } | ||
| ResponseCache.shared.set(response, for: CacheKey.itemSeasons(seriesId)) | ||
| guard route == watchedMutationRoute() else { return } | ||
| seasons = response.seasons.sortedForDisplay() | ||
|
Comment on lines
+1417
to
+1420
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When watched mutations run concurrently for two different seasons, their follow-up season requests can return out of order. For example, an older response captured before the second mutation can arrive after the newer response and overwrite both Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /// Refresh one non-selected season's cached page in the background. It | ||
| /// deliberately stays outside `loadEpisodes`, whose shared generation and | ||
| /// loading flag belong to the selected page. | ||
| private func refreshCachedEpisodePage(seriesId: String, seasonNumber: Int, route: WatchedMutationRoute) async { | ||
| guard let response = try? await SiloAPI.shared.episodes( | ||
| seriesId: seriesId, | ||
| seasonNumber: seasonNumber | ||
| ) else { return } | ||
| ResponseCache.shared.set(response, for: CacheKey.itemEpisodes(seriesId: seriesId, seasonNumber: seasonNumber)) | ||
| guard route == watchedMutationRoute() else { return } | ||
| let sorted = response.episodes.sorted(by: { $0.episodeNumber < $1.episodeNumber }) | ||
| episodesBySeason[seasonNumber] = sorted | ||
| if selectedSeason?.seasonNumber == seasonNumber { | ||
| // The user moved onto this season while it refreshed. | ||
| episodes = sorted | ||
| } | ||
| } | ||
|
|
||
| func setEpisodeFavorite(contentId: String, isFavorite: Bool) async -> Bool { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an episode watched request is in flight and the user switches seasons, this reloads the newly selected season rather than the season containing the mutated episode. The originating season's
episodesBySeasonentry therefore remains stale, and returning to it takes the in-memory fast path without fetching the cache entry that was invalidated. The fresh evidence after the cache-invalidation fix is that invalidation uses the captured season number, but this reload still reads the currentselectedSeason; use the captured season for the post-write refresh as well.Useful? React with 👍 / 👎.