diff --git a/Yattee/Models/Player/VideoQueueContext.swift b/Yattee/Models/Player/VideoQueueContext.swift index b6d1b7fc..9ebe255e 100644 --- a/Yattee/Models/Player/VideoQueueContext.swift +++ b/Yattee/Models/Player/VideoQueueContext.swift @@ -19,6 +19,16 @@ struct MediaBrowserPlaybackInfo: Equatable, Hashable { let allFilesInFolder: [MediaFile] } +/// Where a video info page has got to in its queue. A layout swap (tab bar ↔ +/// split view sidebar) rebuilds the page from its path entry, so this lives on +/// the entry's context rather than in the page's view state. +/// `NavigationRouter.push` gives every pushed page its own instance. +final class VideoInfoPageState { + var currentVideoIndex: Int? + /// Videos appended through continuation loading, past the original list. + var extendedVideoList: [Video] = [] +} + /// Context information for playing a video with queue support. /// Used when navigating from list views (subscriptions, search, etc.) to video info pages. struct VideoQueueContext { @@ -51,6 +61,9 @@ struct VideoQueueContext { /// Position requested by an opened timed link (`?t=`). Unlike `startTime` /// (a resume position), it is honored as-is and skips the resume prompt. var linkStartTime: TimeInterval? = nil + + /// Page position kept across rebuilds; ignored by equality and hashing. + var pageState = VideoInfoPageState() /// Whether this context has valid queue information var hasQueueInfo: Bool { diff --git a/Yattee/Services/Navigation/NavigationRouter.swift b/Yattee/Services/Navigation/NavigationRouter.swift index 931b40e1..e0a6ada8 100644 --- a/Yattee/Services/Navigation/NavigationRouter.swift +++ b/Yattee/Services/Navigation/NavigationRouter.swift @@ -67,6 +67,13 @@ final class NavigationRouter { /// Pushes a destination onto the selected section's stack. func push(_ destination: NavigationDestination) { + var destination = destination + // A row hands out the same context on every tap until it re-renders, + // so a page pushed again must not inherit the earlier page's position. + if case .video(let source, var queueContext?) = destination { + queueContext.pageState = VideoInfoPageState() + destination = .video(source, queueContext: queueContext) + } stacks[selection.id, default: []].append(destination) } diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 418883e2..bff0b11b 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -348,9 +348,11 @@ struct VideoInfoView: View { #if !os(tvOS) VideoInfoPlayThumbnailTip.isToolbarLayout = showsThumbnailPlayTip #endif - // Initialize current index from queue context + // Initialize current index from queue context, resuming where this + // page was before a layout swap rebuilt it if currentVideoIndex == nil, let context = videoQueueContext { - currentVideoIndex = context.videoIndex + extendedVideoList = context.pageState.extendedVideoList + currentVideoIndex = context.pageState.currentVideoIndex ?? context.videoIndex } // Load initial video data (only if video is already loaded) @@ -387,6 +389,8 @@ struct VideoInfoView: View { bookmarkSaveTask?.cancel() } .onChange(of: currentVideoIndex) { oldValue, newValue in + videoQueueContext?.pageState.currentVideoIndex = newValue + // Skip if this is the initial value setting (handled by onAppear) guard oldValue != nil else { return } @@ -1004,6 +1008,12 @@ struct VideoInfoView: View { } } .scrollTargetLayout() + // The lazy stack keeps the old widths of cards it laid out + // off-screen, so after a resize (split view sidebar appearing, + // rotation, Duo unfold) every later card sits at a stale offset + // and the re-snap below lands between cards. A new identity per + // width drops those cached sizes. + .id(cardWidth) } // Pin the viewport to the page width so paging stays aligned even // when the scroll view extends under a horizontal safe area (iPhone @@ -2745,6 +2755,7 @@ struct VideoInfoView: View { let videosToAdd = Array(newVideos.prefix(remainingCapacity)) extendedVideoList.append(contentsOf: videosToAdd) + videoQueueContext?.pageState.extendedVideoList = extendedVideoList } catch { loadMoreError = error.localizedDescription }