Back to notes
Tech shareVueIntersectionObserverHLS.js

滑動自動播放 — 直播影片列表

仿 TikTok 直播列表捲動自動播放:捲動時只播放一支影片、避免快速滑動時閃爍切換,離開視窗即時釋放資源。

Updated 4 min19 viewsArticle language

仿 TikTok 風格的直播卡片列表自動播放。使用者滑動時,同一時間只有一部影片在播放,切換流暢不閃爍。


問題

一個直播列表(雙欄網格、無限滾動),每張卡片播放一個 HLS 串流。需求如下:

  1. 卡片滑到畫面中間時自動播放
  2. 卡片滑出畫面時自動停止
  3. 快速滑動時不能瘋狂切換
  4. 當多張卡片同時進入播放區時,只能播放一個
  5. 暫停時釋放網路資源,但重新進入畫面時要能快速恢復播放

架構

每張卡片(StreamVideo)自己用兩個 IntersectionObserver 盯著自己的位置,有變化就通知 Vuex。要播哪一張,統一由 streamController 決定,卡片之間不互相溝通。播放區、trigger 線和 300ms 計時器的細節在下面第 2、3 點。

元件架構:卡片回報位置,Vuex 決定誰播 · 在 Miro 開啟 · 底圖為 LINE 購物直播大廳實際畫面(2026 年 10 月)

關鍵設計決策

1. 兩個 IntersectionObserver,各自負責不同的事

// StreamVideo.vue(節錄)
// #1 看整張卡片:露出不到 20% 就暫停
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 只看卡片裡那條 1px 的 trigger 線:進出播放區就通知 store
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)

刻意把兩者分開:停止條件(寬鬆、滑走時要快速反應)跟啟動條件(精確、只在畫面 20%~50% 那一段)需要不同的門檻與 margin。合併在一起會把不相關的邏輯耦合在一起。

2. Viewport 分區與 trigger 線的位置

三個常數精確控制卡片什麼時候有資格播放:

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 分區與 trigger 線 · 在 Miro 開啟 · 底圖為 LINE 購物直播大廳實際畫面(2026 年 10 月)

實際運作的意思是:

當卡片的 trigger 線(卡片往下 30% 處)完全進入播放區(畫面頂部 20% 到 50% 之間的區段)時,這張卡片就會變成播放候選。換句話說:

  • 播放區位在畫面的上半部,而不是正中間。這是因為使用者在手機上實際的閱讀習慣——手指從下方滑動時,注意力會集中在畫面上方。
  • Trigger 點設在卡片的上三分之一處,不是卡片正中央。搭配偏上方的播放區,代表當卡片明顯進入畫面、但還在中點稍微偏上的位置時,就會成為候選——正好是使用者視線自然會落在上面的那一刻。
  • **下方 50% 是死區。**只在畫面下方露出一半的卡片永遠不會觸發播放,避免卡片剛剛冒出畫面就意外開始播放。

為什麼不直接在卡片本身用 threshold?

IntersectionObserver 的 threshold 量測的是「被觀察元素」本身可見的比例,不是它在畫面上的位置。要偵測「在 viewport 中的位置」,需要用 rootMargin——它會把 root 的判定範圍縮小,排除掉死區。Trigger 線(卡片內剛好在 30% 位置的 1px 子元素)就是錨點;在這條線上設 threshold: 1,代表它必須 100% 進入縮小後的 root 範圍才會觸發。這樣就能精確控制觸發位置,不需要任何 scroll 事件的數學運算。


3. Playlist + 300ms debounce(處理快速滑動)

快速滑動時,短短幾毫秒內可能有多張卡片同時進出播放區。每次事件都馬上切換的話會造成閃爍,也浪費網路請求。

解法:每次進入/離開都會更新 _playlist,但要等 300ms 沒有新事件才會選出贏家。每次新事件都會重設計時器(_playlistOpenTimer)。滑動停下來之後,store 會從目前的候選名單裡一次性選出一個 stream。

快速滑動時的 debounce · 在 Miro 開啟

4. 隨機選擇,不是先到先播

當多張卡片同時符合條件時,會隨機選一個,而不是永遠選最上面那張。

理由:永遠選第一張卡片會讓播放偏向列表最上方,不管使用者實際在看哪裡。「離 viewport 中心最近的卡片」才是真正想要的語意,但在滑動當下精確計算這個值,成本高又跟裝置效能有關。隨機選擇可以避免位置偏誤,又不用付出那個計算成本,使用者也幾乎感覺不出差異。

5. 目前正在播放的 stream 有優先權

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

正在播的那部只有一個停止條件:離開畫面(Observer #1 判定露出不到 20%),這時 STOP_STREAM_IF_PLAYING 才會清空 currentlyPlayingStreamId。在那之前,就算有新卡片的 trigger 線進了播放區、被加進 _playlist,也搶不走。開始跟停止分別交給兩個 observer,所以「有新的進來」不等於「舊的要停」,看到一半不會突然被換台。

6. HLS 延遲初始化 + 暫停時用 stopLoad(),不是 destroy()

時機動作原因
第一次觸發播放new Hls() + hls.loadSource()延遲初始化——沒用到就不建立 HLS
暫停hls.stopLoad() + video.pause()停止網路請求;保留 instance
恢復hls.startLoad() + video.play()快速恢復——不需要重新初始化
Component beforeDestroyhls.destroy()完全釋放記憶體

NOTE

取捨: 播過的卡片會把 HLS instance 留在記憶體裡,直到 component 被銷毀,所以越往下滑,留著的 instance 越多。但暫停時已經 stopLoad(),這些卡片不會再拉流,累積的只有 hls.js 實例本身的成本。換來的是滑回來時能立刻恢復播放,這筆帳划算。

7. Dev 模式視覺化 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,
  }"
/>

Trigger 區的邊界數值(viewportStart、viewportEnd)透過同一個常數物件,同時被 CSS overlay 跟 observer 的 rootMargin 共用。畫面上看到的視覺效果永遠跟實際行為一致——對 QA 跟設計 review 很有用。


檔案

檔案職責
StreamVideo.vue影片播放器——HLS 生命週期、雙 IntersectionObserver、播放狀態
streamController.jsVuex module——playlist、debounce、隨機選擇、播放中的 ID
importHLS.js動態 import hls.js(SSR-safe,Webpack code-split)

流程圖

完整流程:上半是開始播放,下半是滑出畫面 · 在 Miro 開啟

Comments(0)

No comments yet. Be the first to share your thoughts!