仿 TikTok 風格的直播卡片列表自動播放。使用者滑動時,同一時間只有一部影片在播放,切換流暢不閃爍。
問題
一個直播列表(雙欄網格、無限滾動),每張卡片播放一個 HLS 串流。需求如下:
- 卡片滑到畫面中間時自動播放
- 卡片滑出畫面時自動停止
- 快速滑動時不能瘋狂切換
- 當多張卡片同時進入播放區時,只能播放一個
- 暫停時釋放網路資源,但重新進入畫面時要能快速恢復播放
架構
每張卡片(StreamVideo)自己用兩個 IntersectionObserver 盯著自己的位置,有變化就通知 Vuex。要播哪一張,統一由 streamController 決定,卡片之間不互相溝通。播放區、trigger 線和 300ms 計時器的細節在下面第 2、3 點。
關鍵設計決策
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
}
實際運作的意思是:
當卡片的 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。
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 beforeDestroy | hls.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.js | Vuex module——playlist、debounce、隨機選擇、播放中的 ID |
importHLS.js | 動態 import hls.js(SSR-safe,Webpack code-split) |


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