From b9af0f968aba9a1156893027fb18dab4ac856e81 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 24 Aug 2026 14:35:56 -0500 Subject: [PATCH] Defer gallery video playback until the page transition ends KSPlayerLayer autoplays on construction (KSOptions.isAutoPlay defaults to true), and UIPageViewController instantiates the adjacent page's hosting controller as soon as a drag begins. The next video's player was therefore built - and its audio started - mid-swipe. The existing onChange(of: isSelected) pause never caught it: for a neighbor page isSelected starts false and never changes. - Gate KSVideoPlayer construction on isSelected, which is only set from onPageChanged (didFinishAnimating). No player exists until the page has fully settled. loadVideo() stays outside the gate so neighbors still download and cache. - Fire onPageChanged from the programmatic setViewControllers completion so preview-strip jumps also wait for the transition to finish. - GalleryView's galleryIndex observer now only moves the pager; activation comes back through onPageChanged, removing the duplicate early activation. - Log player state changes and selection changes. Claude-Session: https://claude.ai/code/session_01BY1s2iDX7qsCjnhSseR55f --- .../Views/Media/Gallery/GalleryView.swift | 3 ++- .../Media/Gallery/VerticalPagerView.swift | 11 +++++++++- .../Media/Video/VideoContainerView.swift | 21 ++++++++++--------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/swiftchan/Views/Media/Gallery/GalleryView.swift b/swiftchan/Views/Media/Gallery/GalleryView.swift index 18d98b73..53e49bb9 100644 --- a/swiftchan/Views/Media/Gallery/GalleryView.swift +++ b/swiftchan/Views/Media/Gallery/GalleryView.swift @@ -68,8 +68,9 @@ struct GalleryView: View { .onChange(of: state.galleryIndex) { _, newValue in guard selection != newValue, viewModel.media.indices.contains(newValue) else { return } + // Only move the pager here. Activation (and video playback) is + // driven by onPageChanged, which fires when the transition ends. selection = newValue - updateActiveMedia(to: newValue) } .onChange(of: viewModel.media) { _, newMedia in guard newMedia.indices.contains(selection) else { diff --git a/swiftchan/Views/Media/Gallery/VerticalPagerView.swift b/swiftchan/Views/Media/Gallery/VerticalPagerView.swift index 5f5268ff..202131fa 100644 --- a/swiftchan/Views/Media/Gallery/VerticalPagerView.swift +++ b/swiftchan/Views/Media/Gallery/VerticalPagerView.swift @@ -50,10 +50,15 @@ struct VerticalPagerView: UIViewControllerRepresentable { let target = context.coordinator.controller(for: clampedSelection) else { return } let direction: UIPageViewController.NavigationDirection = clampedSelection >= context.coordinator.currentIndex ? .forward : .reverse + let animated = abs(clampedSelection - context.coordinator.currentIndex) == 1 context.coordinator.isSettingViewController = true - uiViewController.setViewControllers([target], direction: direction, animated: abs(clampedSelection - context.coordinator.currentIndex) == 1) { _ in + uiViewController.setViewControllers([target], direction: direction, animated: animated) { _ in context.coordinator.isSettingViewController = false context.coordinator.currentIndex = clampedSelection + // Report the change only once the programmatic transition has fully + // ended, so media activation (and therefore video playback) never + // begins mid-animation. + context.coordinator.notifyPageChanged(clampedSelection) } } } @@ -72,6 +77,10 @@ extension VerticalPagerView { super.init() } + func notifyPageChanged(_ index: Int) { + parent.onPageChanged?(index) + } + func update(parent: VerticalPagerView, controller: UIPageViewController) { self.parent = parent self.pageViewController = controller diff --git a/swiftchan/Views/Media/Video/VideoContainerView.swift b/swiftchan/Views/Media/Video/VideoContainerView.swift index b375c2f4..e836c1ea 100644 --- a/swiftchan/Views/Media/Video/VideoContainerView.swift +++ b/swiftchan/Views/Media/Video/VideoContainerView.swift @@ -36,11 +36,18 @@ struct VideoContainerView: View { var body: some View { ZStack { - if let fileURL { + // Only build the player once this page is the settled, active one. + // KSPlayerLayer autoplays on construction (KSOptions.isAutoPlay), and + // UIPageViewController instantiates the adjacent page as soon as the + // drag begins — constructing eagerly leaks the next video's audio + // mid-swipe. Gating on isSelected (set in didFinishAnimating) means no + // player exists until the page transition has fully ended. + if let fileURL, isSelected { KSVideoPlayer(coordinator: coordinator, url: fileURL, options: ksOptions()) .onStateChanged { playerLayer, state in // Defer state updates to avoid "Publishing changes from within view updates" DispatchQueue.main.async { + debugPrint("🎬 state=\(state) selected=\(isSelected) \(url.lastPathComponent)") switch state { case .readyToPlay: // Guard against resurrecting an orphaned player: this block can @@ -102,18 +109,12 @@ struct VideoContainerView: View { await loadVideo() } .onChange(of: isSelected) { _, selected in + debugPrint("🎬 isSelected=\(selected) \(url.lastPathComponent)") if !selected { + // Tearing down the KSVideoPlayer above dismantles the layer, but + // pause first so audio stops on the same runloop tick as the swipe. coordinator.playerLayer?.pause() isPlaying = false - } else if fileURL != nil { - // Debounce play to avoid triggering during drag - Task { - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - if isSelected, lifecycle.isActive { - coordinator.playerLayer?.play() - isPlaying = true - } - } } } .onAppear {