Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions iosApp/iosApp/Screens/Detail/ItemDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,12 @@ private struct ItemDetailPhoneContent: View {
onToggleFavorite: { Task { await viewModel.toggleFavorite() } },
onToggleWatchlist: { Task { await viewModel.toggleWatchlist() } },
onToggleWatched: { Task { await viewModel.toggleWatched() } },
onSetSeasonWatched: { season, played in
await viewModel.setSeasonWatched(season, played: played)
},
onSetEpisodeWatched: { id, played in
await viewModel.setEpisodeWatched(contentId: id, played: played)
},
onPersonTap: { personId in
if let pid = Int(personId) {
router.navigate(to: .personDetail(personId: pid))
Expand Down
172 changes: 128 additions & 44 deletions iosApp/iosApp/Screens/Detail/ItemDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment on lines +1376 to +1377

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the season that owns the mutated episode

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 episodesBySeason entry 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 current selectedSeason; use the captured season for the post-write refresh as well.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Order concurrent season-list refreshes

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 ResponseCache and seasons, reverting the second season's displayed state even though both writes succeeded. Add a refresh generation or otherwise serialize/merge these shared season-list publications.

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 {
Expand Down
48 changes: 47 additions & 1 deletion iosApp/iosApp/Screens/Detail/Phone/PhoneEpisodeFormatting.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#if !os(tvOS)
import Foundation
import SwiftUI

/// Shared display formatting for the compact episode rail and expanded iPad
/// rows. Keeping these labels in one seam prevents the two adaptive layouts
Expand Down Expand Up @@ -52,4 +52,50 @@ enum PhoneEpisodeFormatting {
return "\(minutes)m"
}
}

/// Long-press menu shared by the compact episode card and the expanded iPad
/// row: Play, then the watched toggle. Mirrors the tvOS episode rail. The
/// caller flips its optimistic state first and rolls back only on failure.
struct PhoneEpisodeContextActions: View {
let episode: EpisodeListItem
let isPlayed: Bool
let onPlay: (() -> Void)?
let onSetWatched: ((Bool) async -> Bool)?
@Binding var playedOverride: Bool?
/// One request at a time per card. A second toggle while the first is in
/// flight could reach the server in either order.
@Binding var isMutatingWatched: Bool

var body: some View {
if let onPlay {
Button(action: onPlay) {
Label(
"Play S\(episode.seasonNumber):E\(episode.episodeNumber)",
systemImage: "play.fill"
)
}
}

if let onSetWatched {
Button {
guard !isMutatingWatched else { return }
let played = !isPlayed
isMutatingWatched = true
Task { @MainActor in
playedOverride = played
if await onSetWatched(played) == false {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
playedOverride = nil
}
isMutatingWatched = false
}
} label: {
Label(
isPlayed ? "Mark as Unwatched" : "Mark as Watched",
systemImage: isPlayed ? "circle" : "checkmark.circle"
)
}
.disabled(isMutatingWatched)
}
}
}
#endif
4 changes: 4 additions & 0 deletions iosApp/iosApp/Screens/Detail/Phone/PhoneEpisodeList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct PhoneEpisodeList: View {
let episodes: [EpisodeListItem]
let onSelect: (String) -> Void
var onPlay: ((String) -> Void)? = nil
var onSetWatched: ((_ contentId: String, _ played: Bool) async -> Bool)? = nil
var currentContentId: String? = nil

@State private var availableWidth: CGFloat = 0
Expand Down Expand Up @@ -77,6 +78,9 @@ struct PhoneEpisodeList: View {
onSelect: { onSelect(episode.contentId) },
onPlay: onPlay.map { play in
{ play(episode.contentId) }
},
onSetWatched: onSetWatched.map { setWatched in
{ played in await setWatched(episode.contentId, played) }
}
)
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down
66 changes: 50 additions & 16 deletions iosApp/iosApp/Screens/Detail/Phone/PhoneEpisodeListRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,32 @@ struct PhoneEpisodeListRow: View {
let isCurrent: Bool
let onSelect: () -> Void
let onPlay: (() -> Void)?
var onSetWatched: ((Bool) async -> Bool)? = nil

@State private var playedOverride: Bool?
@State private var isMutatingWatched = false

private let thumbnailWidth: CGFloat = 168
private var thumbnailHeight: CGFloat { thumbnailWidth * 9 / 16 }

private var isPlayed: Bool {
playedOverride ?? (episode.userData?.played == true)
}

var body: some View {
ZStack(alignment: .topLeading) {
Button(action: onSelect) {
HStack(alignment: .top, spacing: 14) {
thumbnail
metadata
Group {
if onPlay != nil || onSetWatched != nil {
rowButton.contextMenu { contextActions }
} else {
rowButton
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(
PhoneEpisodeFormatting.accessibilityDescription(
for: episode,
isCurrent: isCurrent
)
)
// Refreshed payloads carry the server's answer; drop the
// optimistic state so a rejected change cannot linger.
.onChange(of: episode.userData) { _, _ in
playedOverride = nil
}

if let onPlay {
Button(action: onPlay) {
Expand All @@ -52,6 +56,36 @@ struct PhoneEpisodeListRow: View {
}
}

private var rowButton: some View {
Button(action: onSelect) {
HStack(alignment: .top, spacing: 14) {
thumbnail
metadata
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(
PhoneEpisodeFormatting.accessibilityDescription(
for: episode,
isCurrent: isCurrent
)
)
}

private var contextActions: some View {
PhoneEpisodeContextActions(
episode: episode,
isPlayed: isPlayed,
onPlay: onPlay,
onSetWatched: onSetWatched,
playedOverride: $playedOverride,
isMutatingWatched: $isMutatingWatched
)
}

private var thumbnail: some View {
ZStack(alignment: .bottom) {
AsyncImageView(
Expand All @@ -64,7 +98,7 @@ struct PhoneEpisodeListRow: View {
.clipped()
.accessibilityHidden(true)

if episode.userData?.played == true {
if isPlayed {
Color.black.opacity(0.3)
}

Expand All @@ -80,7 +114,7 @@ struct PhoneEpisodeListRow: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}

if episode.userData?.played == true {
if isPlayed {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.black)
Expand Down
Loading
Loading