Flutter autoplay architecture

Play one video. Keep the neighbors warm.

A reliable social feed needs more than a visibility percentage. It needs one stable playback owner, an ordered handoff, bounded controllers, and a lifecycle rule that stops media when the screen is no longer active.

The ownership rule

Treat scroll_spy's `primaryId` as the only item allowed to play. When the ID changes, pause every outgoing handle before starting the incoming one. Do not let every card independently call play when its visible fraction crosses a threshold. Two cards can cross at nearly the same time.

primary changespause outgoingprepare windowplay winner

1. Select one stable primary item

A center attention zone is a practical starting point for a vertically scrolling feed. Hysteresis makes a challenger move far enough ahead before it replaces the current winner. Minimum duration prevents rapid changes during a fast gesture.

feed.dart
ScrollSpyScope<int>(
  controller: spy,
  scrollController: scroll,
  region: const ScrollSpyRegion.zone(
    anchor: ScrollSpyAnchor.fraction(0.5),
    extentPx: 220,
  ),
  policy: const ScrollSpyPolicy.closestToAnchor(),
  stability: const ScrollSpyStability(
    hysteresisPx: 24,
    minPrimaryDuration: Duration(milliseconds: 150),
  ),
  child: ListView.builder(
    controller: scroll,
    itemCount: items.length,
    itemBuilder: (context, index) => ScrollSpyItem<int>(
      id: index,
      builder: (context, focus, child) => FeedCard(
        index: index,
        isPrimary: focus.isPrimary,
      ),
    ),
  ),
);

2. Put playback handoff outside the cards

`ScrollSpyPrimaryListener` is a side-effect boundary. It does not require rebuilding the list when the primary ID changes. Give the player pool the new ID and let it serialize platform commands.

feed.dart
ScrollSpyPrimaryListener<int>(
  controller: spy,
  onChanged: (previous, current) {
    unawaited(videoPool.setPrimary(current));
  },
  child: feed,
);

3. Bound the player pool

Retain the primary controller plus a small neighbor radius. With radius 1, the pool owns at most three controllers: previous, primary, and next. On a transition it should:

  1. Pause every handle except the requested target.
  2. Dispose handles outside the desired neighbor window.
  3. Initialize the target first, then preload retained neighbors.
  4. Check that the target is still current after asynchronous initialization.
  5. Pause non-target handles again, then play only the initialized target.

4. Stop playback with the route and app lifecycle

Pause the pool when the app leaves `AppLifecycleState.resumed` or when a modal route covers the feed. Resume reconciliation only when both are active. Close the pool when the page is disposed.

lifecycle.dart
void syncPlayback() {
  unawaited(videoPool.setActive(appIsActive && routeIsActive));
}

void didChangeAppLifecycleState(AppLifecycleState state) {
  appIsActive = state == AppLifecycleState.resumed;
  syncPlayback();
}

void dispose() {
  unawaited(videoPool.close());
  spy.dispose();
  scroll.dispose();
  super.dispose();
}

5. Test policy without the video plugin

Put a small interface around `video_player` and inject fake handles into the pool. Assert the behavior that protects production feeds: never more than one playing item, no more than the configured controller bound, outgoing pause before incoming play, stale async requests ignored, and all handles paused or disposed on lifecycle changes.

Read the pool Read the tests

See the complete handoff.

The bundled video demo runs without a remote media dependency and exposes the live pool state.

Run the demo Add scroll_spy