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 SubscribeCancelableOperation.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).
Why it came back
Section titled “Why it came back”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.
Two layers of defence
Section titled “Two layers of defence” ┌──────────────────────────────────────────────┐ 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, anddisposeMediaResourcesbumps 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.
- Pinned by
- App gate (lease). This keeps the app safe even when someone checks out a
player branch without the guard. The gated
preparerefuses to hand back media for a released lease, so the nativeplaynever 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…”).
- Pinned by
The lease API
Section titled “The lease API”final lease = player.claim([media]); // start, and own, the shared playerlease.isActive; // still the current owner?lease.release(); // stop — only if still the owner; idempotentThe 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.
Route awareness
Section titled “Route awareness”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.
Adopting it in another screen
Section titled “Adopting it in another screen”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:
- Keep a
PlaybackLease? _lease, and start with_lease = player.claim([...]). - Stop with
_lease?.release(); _lease = null;. Never callstopPlaybackIfNotPipfrom a screen that holds a lease. - Replace “is this my media?” URL checks with
_lease?.isActive ?? false. - If the screen can be covered, mix in
RouteAwareand subscribe toplaybackRouteObserverasSingleContentScreendoes. - Add a test like
single_content_screen_test.dart’s “the trailer” group: a slowPlaybackAuthResolver, cover the screen, answer late, and expect noplaycall.