From 55722f6942d9d2d516fe7e4fe45d0cf766c5a49b Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 2 Oct 2026 21:00:05 +0200 Subject: [PATCH] Keep the video info carousel out of the re-identified content subtree The content VStack was re-identified with currentVideoIndex, so every page change recreated the thumbnail carousel and reset its offset to 0 while the index already pointed at the next video. On the iPhone Duo exterior display the recreated scroll view could not restore its position, leaving the first card dimmed at peek opacity. Only the sections below the header are re-identified now, and the carousel pages use an explicit width instead of containerRelativeFrame so paging stays aligned when the scroll view extends under a horizontal safe area. --- Yattee/Views/Video/VideoInfoView.swift | 167 +++++++++++++------------ 1 file changed, 88 insertions(+), 79 deletions(-) diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index cbb44d50..d64d86ca 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -552,91 +552,96 @@ struct VideoInfoView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { - // Header with thumbnail, title, and channel + // Header with thumbnail, title, and channel. Kept outside the + // re-identified subtree below: recreating the carousel on every + // page change resets its scroll offset (visible on the iPhone + // Duo exterior display, where the restored position was lost). headerSection(cardWidth: geometry.size.width) - // Action buttons (inline layout only; toolbar layout puts them in the nav bar) - if !usesToolbarActions { - actionButtons - } else { - Spacer().frame(height: 12) - } + VStack(alignment: .leading, spacing: 0) { + // Action buttons (inline layout only; toolbar layout puts them in the nav bar) + if !usesToolbarActions { + actionButtons + } else { + Spacer().frame(height: 12) + } + + // Bookmark details section (only show if bookmarked) + if isBookmarked, let bookmark = currentBookmark { + Divider() + .padding(.horizontal) + + bookmarkDetailsSection(bookmark) + } + + // Details fetch failed - show error with retry instead of + // silently hiding the description/stats sections + if shouldShowDetailsErrorSection { + Divider() + .padding(.horizontal) + + detailsErrorSection + } + + // Description section (collapsible) - only show if description available + if shouldShowDescriptionSection { + Divider() + .padding(.horizontal) + + descriptionSection + } + + // Original title section - only show when DeArrow title is active + if shouldShowOriginalTitleSection { + Divider() + .padding(.horizontal) + + originalTitleSection + } + + // Stats section (collapsible) - only show if meaningful stats exist + if shouldShowStatsSection { + Divider() + .padding(.horizontal) + + statsSection + } - // Bookmark details section (only show if bookmarked) - if isBookmarked, let bookmark = currentBookmark { Divider() .padding(.horizontal) - bookmarkDetailsSection(bookmark) + // Comments section (collapsible) + commentsSection + + // Related videos section (collapsible, only shown if videos exist) + if let relatedVideos = displayedVideo?.relatedVideos, !relatedVideos.isEmpty { + Divider() + .padding(.horizontal) + + relatedVideosSection(relatedVideos) + } + + + // Watch history section (only show if entry exists) + if let entry = watchEntry { + Divider() + .padding(.horizontal) + + watchHistorySection(entry) + } + + #if !os(tvOS) + // Download section (only show if downloaded) + if let download = download, download.status == .completed { + Divider() + .padding(.horizontal) + + downloadSection(download) + } + #endif } - - // Details fetch failed - show error with retry instead of - // silently hiding the description/stats sections - if shouldShowDetailsErrorSection { - Divider() - .padding(.horizontal) - - detailsErrorSection - } - - // Description section (collapsible) - only show if description available - if shouldShowDescriptionSection { - Divider() - .padding(.horizontal) - - descriptionSection - } - - // Original title section - only show when DeArrow title is active - if shouldShowOriginalTitleSection { - Divider() - .padding(.horizontal) - - originalTitleSection - } - - // Stats section (collapsible) - only show if meaningful stats exist - if shouldShowStatsSection { - Divider() - .padding(.horizontal) - - statsSection - } - - Divider() - .padding(.horizontal) - - // Comments section (collapsible) - commentsSection - - // Related videos section (collapsible, only shown if videos exist) - if let relatedVideos = displayedVideo?.relatedVideos, !relatedVideos.isEmpty { - Divider() - .padding(.horizontal) - - relatedVideosSection(relatedVideos) - } - - - // Watch history section (only show if entry exists) - if let entry = watchEntry { - Divider() - .padding(.horizontal) - - watchHistorySection(entry) - } - - #if !os(tvOS) - // Download section (only show if downloaded) - if let download = download, download.status == .completed { - Divider() - .padding(.horizontal) - - downloadSection(download) - } - #endif + .id(currentVideoIndex) // Force re-render when video changes } - .id(currentVideoIndex) // Force re-render when video changes } #if !os(tvOS) .scrollDismissesKeyboard(.interactively) @@ -1004,7 +1009,7 @@ struct VideoInfoView: View { let titleVideo = video.hasPlaceholderTitle ? (detailedVideo ?? video) : video videoCard(for: titleVideo, cardWidth: cardWidth, thumbnailFrom: detailedVideo, authorFrom: detailedVideo, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent) .opacity(isCurrent ? 1.0 : peekOpacity) - .containerRelativeFrame(.horizontal) + .frame(width: cardWidth) .id(index) .onTapGesture { if !isCurrent { @@ -1020,13 +1025,17 @@ struct VideoInfoView: View { if videoQueueContext?.canLoadMore == true { videoCardPlaceholder(isLoading: isLoadingMoreVideos, cardWidth: cardWidth) .opacity(peekOpacity) - .containerRelativeFrame(.horizontal) + .frame(width: cardWidth) .id("load-more-placeholder") } } } .scrollTargetLayout() } + // Pin the viewport to the page width so paging stays aligned even + // when the scroll view extends under a horizontal safe area (iPhone + // Duo exterior status bar column, landscape notch). + .frame(width: cardWidth) .scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne)) .scrollPosition(id: $carouselScrollPosition) .onScrollPhaseChange { oldPhase, newPhase in