記事一覧に戻る
技術記事VueIntersectionObserverHLS.js

Auto-Play on Scroll — Live Stream List

TikTok-style auto-play for a live stream list: exactly one video plays while scrolling, no flicker on fast swipes, and resources are released as soon as a card leaves the viewport.

更新日 4 min19 回表示記事の言語

TikTok-style auto-play for a live stream card list. As the user scrolls, exactly one video plays at a time, with smooth transitions and no flicker.


Problem

A live stream list (2-column grid, infinite scroll) where each card plays an HLS stream. Requirements:

  1. Auto-play when a card scrolls into the middle of the viewport
  2. Auto-stop when a card scrolls off-screen
  3. No rapid switching during fast scrolling
  4. When multiple cards are in the play zone simultaneously, only one plays
  5. Release network resources when paused, but resume quickly on re-entry

Architecture

Each card (StreamVideo) watches its own position with two IntersectionObservers and reports changes to Vuex. Which card plays is decided in one place, streamController. Cards never talk to each other. The play zone, the trigger line and the 300ms timer are covered in points 2 and 3 below.

Component architecture: cards report their position, Vuex picks who plays · Open in Miro · Background: the real LINE Shopping live hall (October 2026)

Key Design Decisions

1. Two IntersectionObservers with separate responsibilities

// StreamVideo.vue (excerpt)
// #1 watches the whole card: pause once less than 20% is visible
const _visibilityObserver = new IntersectionObserver((entries) => {
  if (!entries[0].isIntersecting) {
    this.pause()
    this.$store.dispatch(STOP_STREAM_IF_PLAYING, this.id)
  }
}, { threshold: 0.2, root })
_visibilityObserver.observe(this.$refs.streamVideo)

// #2 watches only the 1px trigger line: tell the store when it enters or leaves the play zone
const { viewportStart, viewportEnd } = AUTO_PLAY_ON_SCROLL_TRIGGER_CONDITION_SETTING
const rootMargin = `-${viewportStart} 0px -${viewportEnd} 0px` // '-20% 0px -50% 0px'
const _positionObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    this.$store.dispatch(STREAM_ENTER_PLAY_AREA, this.id)
  } else {
    this.$store.dispatch(STREAM_LEAVE_PLAY_AREA, this.id)
  }
}, { threshold: 1, root, rootMargin })
_positionObserver.observe(this.$refs.triggerPlayLine)

Keeping them separate is intentional: the stop condition (generous, reacts quickly when scrolling away) and the start condition (precise, only the 20%–50% band) need different thresholds and margins. Merging them would couple unrelated logic.

2. Viewport zones and trigger line position

Three constants control exactly when a card becomes eligible to play:

export const AUTO_PLAY_ON_SCROLL_TRIGGER_CONDITION_SETTING = {
  viewportStart: '20%',       // top dead zone  (rootMargin top)
  viewportEnd: '50%',         // bottom dead zone (rootMargin bottom)
  triggerLinePosition: '30%', // where the trigger <div> sits inside the card
}
Viewport zones and the trigger line · Open in Miro · Background: the real LINE Shopping live hall (October 2026)

What this means in practice:

A card becomes a playback candidate when its trigger line (30% down the card) fully enters the play zone (the band between 20% and 50% from the top of the screen). In other words:

  • The play zone sits in the upper half of the screen, not centered. This accounts for how users actually read on mobile — attention gravitates toward the upper portion while a finger scrolls from below.
  • The trigger fires at the upper third of the card, not the card's center. Combined with the upper-biased play zone, this means a card becomes a candidate when it is prominently in view but still slightly above the midpoint — the moment the user's eye would naturally land on it.
  • The bottom 50% is a dead zone. Cards that are only half-visible at the bottom of the screen never trigger playback, avoiding accidental starts when a card is barely peeking into view.

Why not just use threshold on the card itself?

IntersectionObserver threshold measures what fraction of the observed element is visible, not where it sits on screen. To detect position-in-viewport, you need rootMargin — which effectively shrinks the root rect to exclude the dead zones. The trigger line (a 1px child element at exactly 30%) is the anchor point; threshold: 1 on that line means it must be 100% inside the shrunken root rect to fire. This gives precise, sub-pixel control over the trigger position without any scroll event math.


3. Playlist + 300ms debounce (handles fast scrolling)

During fast scrolling, multiple cards can enter and leave the play zone within milliseconds. Switching immediately on each event causes flicker and wastes network requests.

Solution: every enter/leave updates _playlist, but the winner is not chosen until 300ms of silence. Each new event resets the timer (_playlistOpenTimer). Once scrolling settles, the store picks one stream from the current candidates in a single pass.

Debounce during fast scrolling · Open in Miro

4. Random selection, not first-come, first-served

When multiple cards qualify simultaneously, one is chosen at random rather than always picking the topmost.

Rationale: always picking the first card would bias toward the top of the list regardless of what the user is actually looking at. "The card closest to the center of the viewport" is the intended semantic, but computing that precisely at scroll time is expensive and device-dependent. Random selection avoids the positional bias without that cost, and the difference is imperceptible to users.

5. Currently playing stream has priority

// _RANDOMLY_PICK_STREAM mutation in streamController.js
if (state.currentlyPlayingStreamId) {
  return  // something is already playing, don't switch
}

The playing stream has exactly one stop condition: leaving the screen (Observer #1 sees less than 20% of it). Only then does STOP_STREAM_IF_PLAYING clear currentlyPlayingStreamId. Until that happens, a new card whose trigger line enters the play zone gets added to _playlist, but it can't take over. Starting and stopping belong to two different observers, so "a new card showed up" never means "the old one has to stop". Nobody gets switched away mid-watch.

6. Lazy HLS init + stopLoad() on pause, not destroy()

MomentActionReason
First play triggerednew Hls() + hls.loadSource()Lazy init — no HLS until needed
Pausehls.stopLoad() + video.pause()Stop network requests; keep instance alive
Resumehls.startLoad() + video.play()Fast resume — no re-initialization
Component beforeDestroyhls.destroy()Full memory release

NOTE

Trade-off: Every card that has played keeps its HLS instance in memory until the component is destroyed, so the count grows as you scroll. But paused cards have already called stopLoad(), so none of them pull the stream anymore. What piles up is only the cost of the hls.js instances themselves. In exchange, scrolling back resumes instantly. Worth it.

7. Dev mode visual overlay

<div
  class="infiniteLiveCardList-triggerArea"
  :class="{ devMode }"
  :style="{
    '--viewportStart': AUTO_PLAY_ON_SCROLL_TRIGGER_CONDITION_SETTING.viewportStart,
    '--viewportEnd': AUTO_PLAY_ON_SCROLL_TRIGGER_CONDITION_SETTING.viewportEnd,
  }"
/>

The trigger zone boundary values (viewportStart, viewportEnd) are shared between the CSS overlay and the observer rootMargin via a single constants object. The visual always matches actual behavior — useful for QA and design review.


Files

FileRole
StreamVideo.vueVideo player — HLS lifecycle, dual IntersectionObserver, play state
streamController.jsVuex module — playlist, debounce, random pick, playing ID
importHLS.jsDynamic import of hls.js (SSR-safe, Webpack code-split)

Flow Diagram

Full flow: starting playback on top, scrolling away at the bottom · Open in Miro

コメント(0)

まだコメントはありません。最初のコメントを書いてみませんか?