Skip to content

12. Playback ownership: who may start the shared player

12. Playback ownership: who may start the shared player

Section titled “12. Playback ownership: who may start the shared player”

The app has one ChorkiPlayerController, built in main.dart and shared by every screen: the content-page trailer, the full-screen player, AirPlay and cast hand-offs. When one player has several would-be users, two questions come up every time a screen appears or disappears:

  • May this screen stop the player? Only if what is playing is still its own media.
  • May a load that started earlier still begin playing? Only if the screen that asked for it still wants it.

This doc covers how the app answers both, the bug that made them necessary, and how a new screen should use the player.

The bug: a trailer behind the Subscribe page

Section titled “The bug: a trailer behind the Subscribe page”

A free viewer on a slow network opens a title. The trailer shows its spinner, the viewer taps Subscribe, and the trailer starts playing behind the Subscribe page.

content page ottstack_player KMS / native
──────────── ─────────────── ────────────
claim trailer ───────────────▶ playNewMedia
play(null) ─────────────────▶ stop
_loadMediaEntry
await prepare ─────────────▶ headersFor() … slow …
tap Subscribe
page covered → stop ─────────▶ disposeMediaResources
mediaLoader.cancel() ◀── cancels the *wrapper* only
… headers arrive
prepare returns
kController.play(trailer) ──▶ ▶ PLAYS behind Subscribe

CancelableOperation.cancel() does not stop the future it wraps. The _loadMediaEntry call was still waiting on prepare (the KMS header fetch in refreshKmsHeadersOnLoad). When the headers arrived, it carried on and started the trailer. A second, smaller gap behaves the same way: a stop that lands while playNewMedia is still awaiting its first play(null).

The library fix, a load generation counter, was first written on ottstack branch fix/player-stability. The app was later built against ottstack fix/ima-ad-state, which forked from upstream without that fix. ottstack_player and kvideo are path dependencies on a local checkout (see pubspec.yaml), so changing branch in those repos changes the app’s player without touching a single file in this repo. Nothing in the app’s tests drove the real controller through the race, so nothing turned red.

┌──────────────────────────────────────────────┐
screen ────▶ │ ChorkiPlayerController.claim(medias) │ app (this repo)
│ → PlaybackLease │
│ each media's prepare is wrapped in a gate: │
│ "is my lease still current?" else throw │
└──────────────────┬───────────────────────────┘
│ playNewMedia
┌──────────────────▼───────────────────────────┐
│ OSPlayerController │ ottstack_player
│ _loadGeneration: every stop / new load │
│ bumps it; a load that wakes up to a newer │
│ generation drops itself before play() │
└──────────────────┬───────────────────────────┘
│ play(Media)
kVideo → ExoPlayer / AVPlayer
  • Library guard (ottstack). This is where the bug actually is. A load checks its generation after every await, and disposeMediaResources bumps the generation.
    • Pinned by test/contract/player_lib_load_guard_test.dart, which uses the raw ottstack API. If it goes red, the linked ottstack has lost the guard: restore it there, and don’t weaken the test.
  • App gate (lease). This keeps the app safe even when someone checks out a player branch without the guard. The gated prepare refuses to hand back media for a released lease, so the native play never happens.
    • Pinned by test/presentation/widgets/player/playback_lease_test.dart.
    • Both test files were checked by mutation. Removing either layer turns its own tests red; removing both brings the bug back in single_content_screen_test.dart (“a page pushed over it while it loads…”).
final lease = player.claim([media]); // start, and own, the shared player
lease.isActive; // still the current owner?
lease.release(); // stop — only if still the owner; idempotent

The lease life cycle:

claim() ──▶ ACTIVE ──┬── release() ──▶ RETIRED (player stopped)
├── another claim() ──▶ RETIRED (newer lease owns it)
├── plain playNewMedia() elsewhere ──▶ RETIRED (e.g. AirPlay, full player)
└── stop from elsewhere ──▶ RETIRED (disposeMediaResources)
RETIRED.release() → no-op: a page tearing down late can't stop someone else's media
RETIRED's prepare → gate throws, no native play; the error is hidden from `error`

Ownership is a token, not a URL comparison. The trailer used to ask “is activeMediaEntry.url my trailer’s url?”, which is ambiguous while prepare runs and when two screens use the same media.

playbackRouteObserver (lib/presentation/widgets/player/playback_route_observer.dart) is registered on the root navigator in main.dart. A screen that owns playback mixes in RouteAware:

  • didPushNext (anything covers the page: a route, a bottom sheet, a dialog) → release, except while the trailer’s own full-screen route is open.
  • didPopNext (the page is uncovered again) → claim again.

A NavigatorObserver can only be attached to one navigator. For that reason playbackNavigatorObservers() adds the shared observer only while it isn’t attached anywhere yet, which keeps a future nested AutoRouter from asserting.

The page’s older check, ModalRoute.isCurrentOf inside build, is still there as a second signal.

Today only the content-page trailer uses leases. Code that hasn’t moved over yet still calls playNewMedia / stopPlaybackIfNotPip, and that keeps working: those calls simply retire whatever lease is current. To move a screen over:

  1. Keep a PlaybackLease? _lease, and start with _lease = player.claim([...]).
  2. Stop with _lease?.release(); _lease = null;. Never call stopPlaybackIfNotPip from a screen that holds a lease.
  3. Replace “is this my media?” URL checks with _lease?.isActive ?? false.
  4. If the screen can be covered, mix in RouteAware and subscribe to playbackRouteObserver as SingleContentScreen does.
  5. Add a test like single_content_screen_test.dart’s “the trailer” group: a slow PlaybackAuthResolver, cover the screen, answer late, and expect no play call.