From fd6a97ce5c5a92eedb0519528a683e70f7b3b7da Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 14 Oct 2026 08:22:58 +0200 Subject: [PATCH] Keep the video info carousel aligned across window resizes The carousel's lazy stack kept the old widths of cards it had laid out off-screen, so after a resize (tab bar to split view sidebar on iPad, rotation) every later card sat at a stale offset and the current card landed beside the viewport. The stack now takes a new identity per card width, and the existing re-snap aligns the current card. Swapping between the tab bar and the sidebar also rebuilds the page, which reset the carousel to the video it was opened on. The page's position and continuation-loaded videos now live on its queue context, fresh for every push, so a rebuilt page resumes where it was. --- Yattee/Models/Player/VideoQueueContext.swift | 13 +++++++++++++ Yattee/Services/Navigation/NavigationRouter.swift | 7 +++++++ Yattee/Views/Video/VideoInfoView.swift | 15 +++++++++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) 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 }