Skip to content

Use AVPlayerLayer for no-controls; fix KVO crash#6987

Merged
alexrepty merged 7 commits into
mainfrom
alex/fix-avfoundation-kvo-crash
Jun 15, 2026
Merged

Use AVPlayerLayer for no-controls; fix KVO crash#6987
alexrepty merged 7 commits into
mainfrom
alex/fix-avfoundation-kvo-crash

Conversation

@alexrepty

@alexrepty alexrepty commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids - n/a, this is an iOS bug

Motivation

Resolves PWENG-112
Resolves #6985

Paywalls V2 looping video backgrounds crash in production (NSInternalInconsistencyException on currentItem.status, ~3.9k events / ~3.5k users). The crash is the well-known AVFoundation/AVKit bug: an AVPlayerLooper-driven AVQueuePlayer swaps currentItem without KVO notifications, so AVPlayerViewController's internal AVPlayerController crashes when removing those observers on teardown.

Description

The robust fix is to never hand an AVPlayerLooper-driven AVQueuePlayer to AVPlayerViewController:

  • No-controls path (backgrounds — the crashing case): rendered via a new AVPlayerLayer-backed view, so AVKit's AVPlayerController (and its currentItem.* observers) never exists. This mirrors the existing macOS AVPlayerLayer approach.
  • Controls path: still uses AVPlayerViewController for the playback UI, but loops manually via didPlayToEndTimeNotification + seek(to:.zero) instead of AVPlayerLooper, plus allowsVideoFrameAnalysis = false (iOS 16+) and deterministic teardown as defense-in-depth.

Player lifecycle, audio-session save/restore, and lifecycle pause/resume now live on each representable's Coordinator. iOS-only internal change — VideoComponentView, public API, and Obj-C surface are untouched.

allowsVideoFrameAnalysis = false alone is not sufficient: the production stack trace is the media-selection KVO variant (reloadCurrentMediaSelectionsAsynchronouslycurrentItem.status), not video-frame-analysis.

Verified by reproducing teardown on a video-backed paywall (rapid dismiss / background-foreground) on iOS 16+; the manual-loop controls path and looping background both play correctly.


Note

Medium Risk
Touches AVFoundation/AVKit lifecycle on a high-traffic paywall path, but changes are iOS-internal to RevenueCatUI with targeted tests and no public API changes.

Overview
Fixes production crashes when looping no-controls paywall video backgrounds tear down (currentItem.status / AVPlayerLooper + AVPlayerViewController KVO issue #6985).

On iOS, VideoPlayerView now branches: controls still use VideoPlayerUIView (AVPlayerViewController); backgrounds use new VideoPlayerLayerView (AVPlayerLayer), so AVKit’s internal player controller never observes a looper-driven queue player. The layer path can keep AVPlayerLooper; the controls path loops with didPlayToEndTime + seek on a single AVPlayer, sets allowsVideoFrameAnalysis = false (iOS 16+), and tears down deterministically in dismantleUIViewController.

Player setup, audio session restore, and lifecycle pause/resume move into each representable’s Coordinator. VideoAutoplayHandler.invalidate() stops foreground resume after teardown; unit tests cover invalidate and which player type each path uses.

Reviewed by Cursor Bugbot for commit 58ba665. Bugbot is set up for automated code reviews on this repo. Configure here.

Add PlayerLayerBackedView and VideoPlayerLayerView to render looping/auto-playing videos via AVPlayerLayer for the no-controls path, avoiding AVKit's internal AVPlayerController KVO observers. Update VideoPlayerView to choose between AVPlayerViewController when controls are shown and the new AVPlayerLayer path when hidden. Refactor VideoPlayerUIView to move player lifecycle into its Coordinator, implement manual looping for the AVPlayerViewController path (to avoid AVPlayerLooper-driven KVO issues), disable video frame analysis on iOS 16+, manage/restore audio session category, and deterministically tear down players and observers during dismantle/deinit.
@facumenzella

Copy link
Copy Markdown
Member

Nice fix, this is basically the same shape I landed on independently. One ask before merge: there's no automated coverage, and the core invariant here is cheap to pin so it can't silently regress.

Drafted the tests in #6988 (stacked on this branch, verified 5/5 passing locally). The key one, testControlsLoopingVideoDoesNotUseQueuePlayer, encodes the actual crash root cause (no AVQueuePlayer ever handed to AVPlayerViewController); the rest guard the no-controls AVPlayerLayer path. They assert through makeCoordinator() so they also cover the ownership seam you moved the player into.

Happy to fold them straight into this PR instead of stacking if you prefer.

Comment thread RevenueCatUI/Templates/V2/Components/Video/VideoPlayerView.swift Outdated
@alexrepty

Copy link
Copy Markdown
Contributor Author

Nice fix, this is basically the same shape I landed on independently. One ask before merge: there's no automated coverage, and the core invariant here is cheap to pin so it can't silently regress.

Drafted the tests in #6988 (stacked on this branch, verified 5/5 passing locally). The key one, testControlsLoopingVideoDoesNotUseQueuePlayer, encodes the actual crash root cause (no AVQueuePlayer ever handed to AVPlayerViewController); the rest guard the no-controls AVPlayerLayer path. They assert through makeCoordinator() so they also cover the ownership seam you moved the player into.

Happy to fold them straight into this PR instead of stacking if you prefer.

That's a great call, I'm fine with either option. Feel free to just merge into this branch if you like, otherwise we can easily stack.

Add deterministic teardown for layer-backed players and simplify the controls path.

- Added VideoPlayerLayerUIView to the Xcode project and call coordinator.tearDown() from dismantleUIView so playback (audio and rendering) stops promptly (e.g. on carousel navigation). Coordinator.tearDown() now pauses the AVPlayer.
- Simplified the AVPlayerViewController-backed UIView path: removed the showControls flag and always enable playback controls; keep user interaction enabled when controls are shown. Added doc note explaining AVPlayerViewController is only used for the controls path to avoid looper-related KVO issues in the layer-backed path.
- Updated project.pbxproj to include the new source file in the target and group.
@alexrepty alexrepty requested a review from a team as a code owner June 12, 2026 15:47
Add PlayerLayerBackedView and VideoPlayerLayerView to render looping/auto-playing videos via AVPlayerLayer for the no-controls path, avoiding AVKit's internal AVPlayerController KVO observers. Update VideoPlayerView to choose between AVPlayerViewController when controls are shown and the new AVPlayerLayer path when hidden. Refactor VideoPlayerUIView to move player lifecycle into its Coordinator, implement manual looping for the AVPlayerViewController path (to avoid AVPlayerLooper-driven KVO issues), disable video frame analysis on iOS 16+, manage/restore audio session category, and deterministically tear down players and observers during dismantle/deinit.
Add deterministic teardown for layer-backed players and simplify the controls path.

- Added VideoPlayerLayerUIView to the Xcode project and call coordinator.tearDown() from dismantleUIView so playback (audio and rendering) stops promptly (e.g. on carousel navigation). Coordinator.tearDown() now pauses the AVPlayer.
- Simplified the AVPlayerViewController-backed UIView path: removed the showControls flag and always enable playback controls; keep user interaction enabled when controls are shown. Added doc note explaining AVPlayerViewController is only used for the controls path to avoid looper-related KVO issues in the layer-backed path.
- Updated project.pbxproj to include the new source file in the target and group.
@alexrepty alexrepty force-pushed the alex/fix-avfoundation-kvo-crash branch from 870b3e1 to b4575c1 Compare June 12, 2026 15:50

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 870b3e1. Configure here.

Add invalidate() to VideoAutoplayHandler to stop observing lifecycle events (clear cancellables and reset wasPlayingBeforeBackground) so a torn-down player can't be resumed later. Call invalidate() from VideoPlayerLayerView.tearDown and VideoPlayerUIView.tearDown; add isTornDown guard in VideoPlayerUIView's loop notification handler to avoid resurrecting playback. Add a unit test (testDoesNotResumeAfterInvalidate) to verify playback does not resume after invalidation.
@alexrepty alexrepty requested a review from a team as a code owner June 12, 2026 15:58
- controls path (AVPlayerViewController) never builds an AVQueuePlayer,
  so an AVPlayerLooper-driven queue player is never handed to AVKit's
  internal AVPlayerController (the #6985 teardown crash)
- no-controls path uses an AVQueuePlayer via AVPlayerLayer, safe because
  there is no AVPlayerController
- PlayerLayerBackedView is backed by AVPlayerLayer

Tests assert through makeCoordinator(), covering the player-ownership seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@facumenzella

Copy link
Copy Markdown
Member

Pushed the tests straight onto this branch (58ba665) and closed #6988. Adjusted for your showControls removal, registered the file in RevenueCat.xcodeproj for the Danger check, and re-verified 5/5 passing against the latest commit. Thanks for adding the dismantle-pause + autoplay-resume guard, those cover the teardown concerns I had.

@facumenzella facumenzella left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I didn't test it manually, but looks good. Great one @alexrepty !

@alexrepty

Copy link
Copy Markdown
Contributor Author

I didn't test it manually, but looks good. Great one @alexrepty !

I tested manually using Paywalls Tester on device, both looping and non-looping videos.

@alexrepty alexrepty merged commit 82f85f5 into main Jun 15, 2026
18 of 20 checks passed
@alexrepty alexrepty deleted the alex/fix-avfoundation-kvo-crash branch June 15, 2026 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants