SwVideoFeed

SwVideoFeed 是抖音 / TikTok 式的直向短影片流元件:一屏一條、上下滑動逐條切換。行動端走瀏覽器原生直向 scroll-snap(手指滑動 + 慣性吸附原生白送,真機順滑);桌面端用自訂滾輪 / 觸控板翻條(snappy 翻頁動畫)+ 鍵盤 ↑ / ↓ 翻條、Space 切目前條播放 / 暫停 + 可選 PC 上下箭頭。

內部是虛擬化 + 裸 hls 播放池:所有條目各渲一個全螢幕 slide 撐捲動高度,但只有視窗內(目前 ±windowSize)掛真實播放核,視窗外只顯封面 —— 長列表也不會爆記憶體。配合 @load-more 按需載入、finished 控邊界,做無限流。

業務 UI 不內建:按讚 / 作者 / 側欄圖示列 / 追蹤按鈕等業務疊層一律走 #overlay 插槽自渲,framework 只負責「捲動 + 播放 + 極簡控制」骨架。

靜音自動播放是預設:瀏覽器策略下「帶聲音的自動播放」需要使用者手勢。muted 預設 true(靜音播 + 靜音按鈕可解);傳 muted="false" 是「嘗試進場帶聲」,詳見下方 進場帶聲音。

HLS 簽名過期恢復

hls.js 路徑下,分片或金鑰首次出現 403 / 無回應時,播放器重新載入同一播放位址,並從目前進度恢復;每次掛載最多重建一次;播放推進或超過 30 秒都不會恢復這個次數,後續 fatal 交由業務兜底。播放位址必須是可重新簽發的穩定入口,直接傳入已過期的 CDN 簽名位址無法靠重載續期。Feed 僅恢復原本正在播放且仍為 active 的項目,預載和暫停狀態不會自動起播。分片/金鑰逾時(包括 keyLoadTimeOut)、404 和 5xx 不觸發這條恢復,繼續交由 hls.js 處理;fatal 仍透過 error 通知業務。預載階段(或先前作為目前條時)遇到 fatal 的項目,在成為目前條時會依目前位址重建一次,線路短暫抖動可自癒;重建後仍失敗會再次觸發 error,之後不自動循環重試(滑走再滑回會再試一次)。

開發環境主控台的 HLS recovery 記錄本次錯誤類型、HTTP 狀態、重建前後進度及結果;進度超過恢復點才記錄 progress,不把清單載入成功當作續播成功。診斷不包含簽名 URL,也不上報正式環境後台。原生 HLS 不包含此恢復能力。

本集可取消歷史恢復

業務先校驗帳號、作品與集歸屬,再給影片條目傳 resume: { key, time }。key 標識本次觀看工作階段;僅目前活動條目顯示五秒實際播放倒數,可立即恢復或取消。暫停、手動 seek、換條或換源撤銷候選;背景、緩衝與廣告不消耗計時。預載條目不恢復,滑回已有工作階段位置及明確 seek_time(含 0)優先。

透過 resume-pending(index, item, pending) 按條目 ID 暫緩週期、暫停與離頁歷史寫入。私人歷史未讀完或讀取失敗時,業務也必須獨立阻止寫入;只有成功讀取才解除。框架不請求歷史、不跨集導覽,也不繞過系統自動播放限制。明確點擊繼續觀看時使用 seek_time,一般入口使用 resume。

可取消恢復示例

加载中...

基礎用法

items 傳短影片陣列,每條至少含封面 img + 播放線路 play_links。業務疊層放進 #overlay 插槽(接收 { item, index, active })。元件預設佔滿全螢幕(100dvh),放進定高容器時用 CSS 變數 --sw-video-feed-height 改成 100%。

直向短影片流(手機尺寸盒子內)

加载中...
<SwVideoFeed :items="list">
  <template #overlay="{ item, index, active }">
    <div class="feed-overlay">
      <p class="author">@{{ item.meta?.author }}</p>
      <button class="like">♥ {{ item.meta?.likes }}</button>
    </div>
  </template>
</SwVideoFeed>

業務疊層(#overlay)

業務 UI(按讚 / 留言 / 分享圖示列 + 作者 / 描述)全部寫進 #overlay 插槽自渲,framework 不內建。插槽容器 pointer-events: none(影片區可點可滑),只給直接子元素兜底 pointer-events: auto 且零特異度,更深層靠繼承——所以在插槽內任意層級寫 pointer-events: none 都能生效並向下繼承(隱藏的操作列不會吃點擊),不必用 visibility: hidden 繞。

抖音式右側圖示列 + 左下作者描述

加载中...

觸控端:互動控件與滑動的取捨

行動端 feed 走原生捲動,瀏覽器必須在 touchmove 當下判定「這是滑動還是點擊」, 一旦判成滑動就會取消該次 click,且無法反悔。所以在捲動容器裡,同一塊區域 「既能可靠點擊、又能從它起手滑動」二者不可兼得 —— 只能逐元素取捨。

不取捨的後果(真機實測):直式單手拇指點右側收藏鈕時指尖天然帶弧形位移,超過瀏覽器的 touch slop 後一次誤觸同時吃掉兩件事 —— 收藏沒成功(click 被取消)+ 被動量吸到下一條。

framework 已替你做好預設取捨,applier 零改動:.slide 內的 button / a / [role="button"] 自動帶 touch-action: none,這些控件讓給點擊;影片體、封面、文案段落 以及各插槽的定位容器都不受影響,螢幕絕大部分照舊可以起手滑動。

唯一要守的一條:插槽裡別把整螢幕做成 <a> / <button>(含滿螢幕的 SwLink / SwButton),否則那一條從任何位置都滑不動。滿螢幕級的點擊層請用 <div @click> —— 元件自己的 「點影片暫停」正是這麼實作的。

成組互動區要加固,正解是把標籤併進按鈕 —— 別給容器打 touch-action。

touch-action: none 只作用在元素自身及其後代。右側操作列這類成組區域裡,按鈕之間的間隙、 按鈕下方的文字標籤、整組周圍的空白都不是按鈕,觸點落在那兒理論上仍會被判成滑動。

要加固就把文字標籤併進 <button>(圖示 + 標籤同屬一個按鈕):標籤成為按鈕的後代後自動被上面 那條規則覆蓋,順帶還擴大了點擊熱區 —— 本來就是更好的做法。

不要給容器整塊打 touch-action: none。標籤若是按鈕的兄弟節點,整列劃出去之後標籤區會變成 「既沒反應、又滑不動」的死區,比不加更糟(消費方實測後據此棄用)。

「點按鈕沒反應 / 點了就滑走」的第一步是問瀏覽器這個座標上到底是誰,別直接往 touch-action 上 使勁 —— 這個位置最常見的真兇是層疊遮擋(framework 0.111.1 就修過一例:隱形的翻頁箭頭壓在業務 操作列上,點到它當場翻頁,業務 handler 從未執行)。

const r = document.querySelector('.share-btn').getBoundingClientRect()
console.log(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2))
// 命中自己 → 才是「滑動 vs 點擊」的判定問題;命中別的元素 → 是層疊遮擋,方向完全不同

另外,判斷「click 到底有沒有觸發」要挑必然產生可見回饋的那顆按鈕下手(handler 裡無論成敗都 彈提示、或未登入必彈框)——「沒反應」既可能是 click 被吞,也可能是 handler 自己靜默失敗。

副作用是按鈕 / 連結正上方不能起手滑動,這是上述硬約束的必然代價:那些位置本就是用來點的。 若某個連結面積很大(例如整塊「繼續觀看」卡片)又確實希望它可滑,把它改成 <div @click> 即可 (代價是失去連結語義與 SEO,自行權衡)。

⚠️ feed 內不支援巢狀橫向捲動

.feed-viewport 宣告了 touch-action: pan-y(fw#354)—— 目的是封死橫軸手勢面: overflow-y: auto 會讓另一軸的 visible 被規範強制計算成 auto,於是縱向流平白帶了一條橫軸捲動性, iPhone 真機上手指能橫向拉動整個 feed(elastic 回彈、畫面跟手;桌面重現不了)。

代價:touch-action 是祖先層級限制、後代無法恢復 —— 按規範手勢能力沿「觸點 → 實施捲動的容器」 這條鏈取交集,被祖先禁掉的手勢子元素寫不回來(在 #overlay 裡的元素上寫 touch-action: pan-x 無效)。 所以往 #overlay 裡放橫排推薦位 / 可橫滑標籤條這類東西會滑不動。

這是有意取捨:feed 語義就是整屏縱向流,橫滑內容與上面那條「整屏可點/可滑區互搶」本就衝突。 真有需求請提 framework issue 評估加 prop,別在 applier 側硬繞(繞不動)。

拖動進度條 seek 不受影響(fw#372 起兩態)—— 進度條展開態宣告 touch-action: none(按下即拖、 任意方向跟手);收起態宣告 pan-y:縱向起手交給瀏覽器原生捲動 feed,橫向起手(約 10px 且橫向佔優) 進入拖動 —— 所以播放中也能直接在進度條上橫向起手拖動,不必先點一下畫面。

資料格式(VideoFeedItem)

items 是判別聯合:影片條(kind 省略或 'video',複用播放器 PlayLink 線路)+ 廣告條(kind: 'ad',全螢幕資訊流廣告,見下方資訊流廣告條)。

// 影片條(kind 省略即影片條,舊用法零改動)
interface VideoFeedVideoItem {
  kind?: 'video'
  img: string // 封面圖(cover 兜底,canplay 前 + 未進視窗的 slide 顯示)
  play_links: PlayLink[] // m3u8 多線路
  link_code?: string // 選哪條線路(對應 PlayLink.code,優先於元件級 linkCode)
  seek_time?: number // 起播位置(秒),loadedmetadata 後自動 seek。兩用:歷史進度續看 / 付費解鎖續播(resume_offset)
  resume?: { key: string | number; time: number } | null // Ownership-validated, cancellable history candidate
  locked?: boolean // 付費牆鎖定態(路徑 A):true=付費內容且無權觀看;試看片段播完顯 #paywall(見下「付費牆」)
  id?: string | number // 業務標識(埋點 / v-for key)
  meta?: Record<string, unknown> // 透傳給 #overlay 的業務資料
}

// 廣告條(全螢幕資訊流廣告,創意走 #in-feed-ad slot)
interface VideoFeedAdItem {
  kind: 'ad'
  id?: string | number // 業務標識(埋點 / key)
  skipAfter?: number // 強制觀看時長(秒);缺省回退 props.adSkipAfter(預設 0 = 不強制)
  meta?: Record<string, unknown> // 透傳給 #in-feed-ad slot 的廣告資料(圖 / 連結等)
}

type VideoFeedItem = VideoFeedVideoItem | VideoFeedAdItem

interface PlayLink {
  code: string // 線路標識
  id: number | string // 業務 id
  lid: number | string // 業務 lid
  m3u8_url: string // 實際播放位址(.m3u8 走 hls.js,其它走原生 <video>)
}
const list: VideoFeedItem[] = [
  {
    id: 1,
    img: 'https://cdn.example.com/cover1.jpg',
    play_links: [{ code: 'line1', id: 1, lid: 1, m3u8_url: 'https://cdn.example.com/1.m3u8' }],
    meta: { author: 'alice', likes: 1234 },
  },
  // 後端插入的廣告條(每 N 條一條):全螢幕可滑、點擊開連結
  { kind: 'ad', id: 'ad-1', skipAfter: 4000, meta: { image: 'https://cdn.example.com/ad.jpg', link: '/promo' } },
]

按需載入(無限流)

目前條進入「剩餘不足 loadMoreThreshold 條」時觸發 @load-more,父層拉下一頁並追加到 items(不要整體替換,會丟已掛載播放核)。沒有更多時把 finished 置 true,元件不再觸發 @load-more;使用者滑到底再上滑會觸發一次 @reach-end。

按需載入(追加到 12 條封頂)

加载中...

事件逃生艙(change/play/pause/timeupdate)

加载中...
<SwVideoFeed
  :items="list"
  :finished="finished"
  :load-more-threshold="5"
  @load-more="loadNext"
  @reach-end="onReachEnd"
>
  <template #overlay="{ item }">…</template>
</SwVideoFeed>

<script setup>
const list = ref([])
const finished = ref(false)
let loading = false
async function loadNext() {
  if (loading || finished.value) return
  loading = true
  const page = await api.fetchFeed(cursor)
  if (page.length === 0) finished.value = true
  else list.value.push(...page) // 追加,不替換
  loading = false
}
</script>

進場定位與虛擬化

  • initialIndex 指定進場定位到第幾條(如從封面列表點進來的索引),預設 0(無動畫直接定位)。
  • windowSize 是播放池半徑:預設 1(目前 ±1 = 3 條掛真實播放核),其餘只顯封面。
  • 預載策略:預設預載後面 2 條影片各前 5 秒(起播存糧,變 active 後緩衝上限自動抬回 30s),覆蓋連滑兩次仍秒播;傳數字自定條數(如 :preload="3"),:preload="false" 完全關閉——滑到位後先封面兜底再載入,用秒播體感換 CDN 流量。廣告條(kind:'ad',零影片流量)跳過且不佔預載名額,列表尾部不足時自然截斷;掛載視窗自動前伸覆蓋預載集。上一條不預載(剛劃過的舊當前條已載入,劃回仍即時)。注意秒數精控僅 hls.js 分支生效;iPhone 一律走原生 HLS(iOS 只有 ManagedMediaSource,而部分國產瀏覽器的 MMS 只推進時間軸、不渲染畫面 ⇒ 框架統一改走原生,fw#365),其緩衝由瀏覽器自決、盡力而為。
  • ioThreshold 是 IntersectionObserver 判定「目前條」的可見比例閾值,預設 0.6。
<!-- 從第 3 條進場,前後各預掛 2 條 -->
<SwVideoFeed :items="list" :initial-index="3" :window-size="2" />

進場帶聲音(muted = false)

muted 預設 true(靜音自動播放,符合瀏覽器策略,永遠不會被攔)。傳 muted="false" 是嘗試「進場帶聲音播」:借使用者「點進 feed」那次手勢的 activation —— SPA 路由進入時首條通常可帶聲(PC / H5 通用);若被瀏覽器攔截(如整頁重新整理無手勢),元件自動退回靜音並顯示靜音按鈕供使用者主動解除,不會黑屏。使用者一旦切換靜音態,會在整個 feed 持續生效(對目前條及之後所有條)。

<!-- 預設靜音播(最穩,行動端絕不被攔) -->
<SwVideoFeed :items="list" />

<!-- 嘗試進場帶聲(攔截則自動退回靜音) -->
<SwVideoFeed :items="list" :muted="false" />

桌面互動與靈敏度

  • 滾輪 / 觸控板:累積 deltaY 翻條(一次手勢翻一條,帶冷卻防慣性多翻)。sensitivity.wheel 是靈敏度倍率(乘到累積量,預設 1,調大更靈敏)。
  • 鍵盤(容器聚焦時):↑ / ↓ 翻條,Space 切目前條播放 / 暫停。
  • arrows(預設 true):PC 上下箭頭,僅 hover 裝置顯示、端點禁用、行動端不顯。
<SwVideoFeed :items="list" :sensitivity="{ wheel: 1.5 }" :arrows="false" />

播完行為(endMode)

一條播完後做什麼,預設 'loop'(循環重播 —— 短影片主 feed 的性格)。

<!-- 劇集專輯 / 短劇合集:一集播完自動下一集 -->
<SwVideoFeed :items="episodes" end-mode="next" />

為什麼需要這個 prop 而不能自己接 ended:'loop' 檔下元件把 <video loop> 打開, 而 loop=true 的影片到末尾按 HTML 規範直接繞回、不派發 ended —— 業務側連事件都收不到。 endMode="next" 會同時關掉 loop 並接管推進。

三條邊界(都有 e2e 守著):

  1. 付費條(item.locked)優先 —— 試看片段播完顯付費牆、不推進,不會把使用者從牆上帶走
  2. 強制觀看期不繞過 —— 資訊流廣告條 / 入口貼片倒數未歸零時不推進
  3. 末條 —— finished 為真時觸發 reach-end(業務自行收尾,如 toast + 返回上層); 為假時靜默停住。連播與分頁刻意不耦合,靠 loadMoreThreshold 提前拉好下一頁

極簡播放控制(抖音風)

每條播放核內建抖音式極簡控制(無需設定):點擊畫面暫停 / 播放、點擊 / hover 喚出可拖動進度條(拖動時顯示目前 / 總時長)、播放中在進度條上橫向起手可直接拖動(無需先點畫面;縱向起手仍是切條,fw#372)、右下角靜音按鈕。位置 / 尺寸全部走 CSS 變數微調(見 CSS 變數)。

比例裁剪(fillThreshold)

影片與視口寬高比相似度 ≥ fillThreshold 時裁剪鋪滿(object-fit: cover,頂部對齊 + 水平置中);預設 false(不裁,留黑邊 / contain)。

相似度 = min(影片比, 容器比) / max(影片比, 容器比)。建議取 0.8:

情境相似度0.90.8
規範 9:16 素材 @ 16:9 舊機1.000✂ 裁滿✂ 裁滿
規範 9:16 素材 @ 19.5:9(主流)0.822❌ 仍留黑邊✂ 裁滿
規範 9:16 素材 @ 20:90.800❌ 仍留黑邊✂ 裁滿
3:4 舊素材0.616留黑邊留黑邊
16:9 橫片直放0.260留黑邊留黑邊

0.9 只能吃掉細黑邊,覆蓋不到主流高螢幕手機;0.8 讓規範直式素材在任何主流機型上滿螢幕(最多裁 20% 寬,與業界安全區量級一致),而真正比例不符的素材照樣安全留黑邊。

因頂部對齊,影片比容器更高時裁的全在底部 —— 片源常在底部壓硬字幕的話,酌情調高閾值或不開。

下方左右對比:測試流是橫屏 16:9,放進直盒預設留上下黑邊(contain);開 fillThreshold 後接近比例的影片裁剪鋪滿。

預設留黑邊 vs fillThreshold 裁剪鋪滿

加载中...
<!-- 直屏短影片為主,接近直屏的裁滿鋪平 -->
<SwVideoFeed :items="list" :fill-threshold="0.8" />

資訊流廣告條(In-Feed Ad)

後端可把廣告資料插進 items 陣列(業界稱 In-Feed Ad,抖音 / TikTok 資訊流裡的全螢幕廣告)。元件按 kind 區分:kind: 'ad' 的條不掛播放核,改走 #in-feed-ad 作用域插槽由業務渲染圖片創意 + 點擊開連結 + 曝光埋點;它仍是普通 slide,可上下滑、@change 照常透傳。

#in-feed-ad 作用域 { item, index, active, countdown, canSkip }:

<SwVideoFeed :items="feed" :ad-skip-after="4000">
  <template #in-feed-ad="{ item, active, countdown, canSkip }">
    <!-- 廣告創意:圖片 + 連結 + 曝光埋點全由業務渲染 -->
    <SwAdNative :items="[toAdItem(item.meta)]" slot-key="feed_inline" />
    <!-- 強制觀看倒數提示 -->
    <span v-if="active && !canSkip" class="skip-hint">{{ countdown }}s 後可滑走</span>
  </template>
</SwVideoFeed>

強制觀看 N 秒(forced-view):廣告條成為目前條後可鎖住「滑走」N 秒再放開(業界可跳過插播 / 插屏的「倒數後才能跳過」約定)。生效時長 = item.skipAfter ?? props.adSkipAfter,預設 0 = 不強制、自由滑走:

  • >0 時:廣告條 active → 鎖滑走(桌面攔 next/prev/滾輪、行動端凍結原生捲動)+ 倒數;歸零解鎖、canSkip 轉 true。
  • 逐條用 item.skipAfter 覆蓋、feed 級用 adSkipAfter 兜底。

埋點歸業務:feed 不替廣告條埋點,業務在 #in-feed-ad 裡用 SwAd*(自帶曝光 / 點擊)或 useAdTrack。feed 只管「時機 + 位置 + 強制觀看鎖」。

入口貼片廣告(preroll)

整個播放列表開始前展示一次的前貼片廣告(與單影片播放器的 SwVideoPlayer 前貼片 同款 API)。開關 prerollAd,貼片內容走 #preroll-ad 插槽,資料與 items 解耦(業務自備,不進列表)。

  • 進場蓋住整 feed、壓住第一條正片(照常預 buffer、不播不出聲);skip() 或倒數歸零進正片。
  • 一次性:skip 後永不再現。預設不鎖捲動:貼片期上滑自動 skip() 進列表(不強制等廣告)。
  • 強制觀看(opt-in):開 prerollAdForceWatch 後,倒數期間鎖住翻條(滾輪 / 鍵盤 ↑↓ / 觸控 / 導航均不划走)、歸零自動解鎖(與資訊流廣告條 adSkipAfter 同口徑);slot 據 canSkip 條件渲染 ✕ / 倒數。
  • prerollAdAutoClose=true 時倒數歸零自動進正片;false 時歸零顯示預設「進入」按鈕(或業務 skip())。

#preroll-ad 作用域 { countdown, skip, canSkip }(canSkip=當前能否跳過,強制觀看鎖期為 false,與 #in-feed-ad 同口徑):

<!-- preroll-ad-force-watch:倒數期間鎖翻條(canSkip=false 期間別渲染 ✕);
     preroll-ad-auto-close:歸零自動進正片(false 時歸零顯示「進入」按鈕) -->
<SwVideoFeed
  :items="videos"
  preroll-ad
  :preroll-ad-duration="5000"
  preroll-ad-force-watch
  :preroll-ad-auto-close="true"
>
  <template #preroll-ad="{ countdown, skip, canSkip }">
    <SwAdPreroll :items="playAdsToSwAdItems(feedPreroll)" slot-key="feed_preroll" @empty="skip()" />
  </template>
</SwVideoFeed>

週期性 popover 廣告(@change)

「每播 N 個彈一次 body 級彈窗廣告」不內建——元件只出 @change 翻條事件,業務計數自彈 SwAdPopup(內建會綁死頻率策略,違背「feed 不綁業務 UI」)。@change 透傳 item,業務可據 item.kind 把廣告條排除出計數:

<SwVideoFeed :items="feed" @change="onChange" />
<SwAdPopup ref="popRef" :items="popupAds" slot-key="feed_interval" />

<script setup>
const popRef = ref()
let played = 0
function onChange(_index, item) {
  if (item.kind === 'ad') return // 廣告條不計數
  if (++played % 5 === 0) popRef.value?.open() // 每播 5 個影片彈一次
}
</script>

付費牆(試看牆 paywall)

付費內容試看片段播完到點顯示購買門(變現,不是廣告,不進 SwAd* 家族)。機制走路徑 A:後端只下發試看片段(完整正片不下發到客戶端,從根上防扒源),片段播完自然 ended → 顯 #paywall 插槽。給影片條傳 locked: true(業務從後端付費欄位算,如 is_vip && !has_access,framework 不認欄位名):

  • 試看完顯牆:locked 條試看片段播完(播放核對 locked 條不循環、停末幀)→ 顯 #paywall 插槽 + 觸發 @paywall(index, item)(掛「牆曝光」轉換埋點)。
  • 不鎖捲動、無關閉 X:付費牆是內容鎖定態、不是可關閉彈層 —— 可上滑 / skipToNext()(跳過此影片)走人;退出只有「解鎖 / 跳過」兩條路。
  • 解鎖續播:購買成功後業務改本 item —— 換 play_links 為正片源 + 設 seek_time 為後端 resume_offset + 清 locked;元件自動換源並 seek 到續播點(複用 seek_time 起播)。framework 不提供 unlock()(拉正片源是業務的事)。

#paywall 作用域 { item, index, active, skipToNext }:

<SwVideoFeed :items="list" @paywall="onPaywallExposure">
  <template #paywall="{ item, skipToNext }">
    <div class="paywall">
      <p>試看已結束</p>
      <button @click="unlock(item)">開通會員解鎖</button>
      <button @click="skipToNext()">跳過此影片</button>
    </div>
  </template>
</SwVideoFeed>

<script setup>
async function unlock(item) {
  await purchase() // 業務支付
  const full = await fetchFullSource(item.id) // 拉正片 m3u8 + resume_offset
  item.play_links = full.play_links
  item.seek_time = full.resume_offset ?? 0 // 續播起點(缺省 0 從頭)
  item.locked = false // 清鎖 → 牆隱 + 自動換源續播
}
</script>

resume_offset 由後端給(試看=正片前 N 秒→N / 中段精彩片段→0 / 缺省→0 從頭);前端不靠試看時長推算(試看未必是正片開頭)。付費 / 播放路徑頁面須 ssr: false(命中 ESLint no-isr-on-paid-paths)。

工作階段內續看(rememberProgress)

虛擬化會卸載滑出視窗的播放核、丟失播放進度。rememberProgress(預設 true)讓元件按播放 URL 記一份工作階段內進度:滑走某條 → 滑回 remount 時從原處續看(而非從頭)。

  • 工作階段內(滑走 → 滑回,同一次瀏覽):元件自動記 + 續,無需設定。此進度丟失是虛擬化卸載造成、由框架兜底。
  • 跨工作階段歷史(明天再來):元件不碰後端 —— 業務監聽 @timeupdate 節流上報後端、一般入口給 resume 候選,明確繼續才給 seek_time,並依 resume-pending 保護寫入。
  • 與付費解鎖續播零衝突:工作階段進度按 URL 做 key,付費換源後新 URL 不在表 → 自動回退 item.seek_time(=resume_offset)。
<!-- 關閉工作階段續看(每條總從頭 / seek_time 起播) -->
<SwVideoFeed :items="list" :remember-progress="false" />

<!-- 跨工作階段歷史:業務監聽 timeupdate 自報後端 -->
<SwVideoFeed :items="list" @timeupdate="throttledReportProgress" />

下拉重新整理(pullToRefresh)

短影片 feed 站標配互動:首條 + 視口頂部時手指下拉超閾值、鬆手觸發 @refresh(pullToRefresh opt-in,預設關)。業務收到後拉新資料(通常重置 items 回第一頁——與 @load-more 的尾部追加對稱),並經受控 refreshing 收尾:置 true 保持指示器、拉完置 false 收起。

  • 僅 touch:桌面滾輪 / 鍵盤不觸發(桌面重新整理有 F5 / 按鈕)。
  • 不與內部機制打架:僅在「首條 + 頂部 + 非鎖定態(無 preroll 貼片 / 無廣告強制觀看)」才接管手勢,其餘場景完全放行原生捲動;指示器懸浮頂部、不位移內容。
  • 指示器走 #refresh-indicator slot 自渲染({ pulling, progress, refreshing },按 progress 做跟手品牌動畫);不提供時有極簡預設(箭頭拉滿翻轉 → 重新整理轉圈,尊重 prefers-reduced-motion)。

下拉重新整理(觸屏 / 裝置模擬下體驗)

加载中...

劫持型瀏覽器攔截(#hijacking-browser slot)

加载中...
<SwVideoFeed :items="list" pull-to-refresh :refreshing="refreshing" @refresh="onRefresh">
  <template #refresh-indicator="{ progress, refreshing }">
    <BrandWave :progress="progress" :active="refreshing" />
  </template>
</SwVideoFeed>
const refreshing = ref(false)
async function onRefresh() {
  refreshing.value = true
  try {
    list.value = await api.feed({ page: 1 })
    page.value = 1
  } finally {
    refreshing.value = false
  }
}

載入與緩衝回饋

影片首屏等首幀和播放中途卡頓時都會顯示一個緩衝指示器 —— 元件內建、預設開啟,applier 零改動。

為什麼預設開:卡住時沒有任何回饋,使用者分不清「正在載入」和「這條壞了」。讓每個消費方各寫一遍是壞預設,所以收進元件。

防閃爍:waiting 常常只持續幾十毫秒(seek、網路抖動),指示器延遲 300ms 才現身,短暫卡頓不會閃一下。這只影響 UI —— @waiting 事件是立即派發的,業務埋點的時間戳不受影響。

指示器不會動封面和畫面:中途卡頓時當前畫面原樣留著(只是疊一個轉圈),不會跳回封面圖。

自訂外觀

三檔,按需要選:

想做的事怎麼做
調色 / 調尺寸改 --sw-video-feed-loading-* CSS 變數
完全自渲染#loading slot
徹底關掉傳空 slot
<SwVideoFeed :items="list">
  <!-- 自渲染:外層已經按緩衝態決定顯隱,這裡只管畫 -->
  <template #loading>
    <MyBrandSpinner />
  </template>
</SwVideoFeed>
<SwVideoFeed :items="list">
  <!-- 關掉:容器仍在但為空,不可見、也不攔手勢 -->
  <template #loading />
</SwVideoFeed>

CSS 變數

容器高度與極簡控制的位置 / 尺寸 / 配色都可透過 CSS 變數微調(寫在容器或父級):

變數預設值說明
--sw-video-feed-height100dvhfeed 高度(放進定高父容器可改成 100% 等)
--sw-video-feed-mute-right1rem靜音按鈕距右
--sw-video-feed-mute-bottom2.5rem靜音按鈕距底(右下角單手夠得著;業務側圖示列放其上方)
--sw-video-feed-progress-inset0.5rem進度條距左右(防拖到螢幕邊緣誤觸手勢)
--sw-video-feed-progress-bottom0進度條距底
--sw-video-feed-refresh-z6下拉重新整理指示器 z-index(壓業務 overlay、低於付費牆 / 貼片)
--sw-video-feed-refresh-top1rem下拉重新整理指示器距頂
--sw-video-feed-refresh-bg / -color黑40% / 白90%預設指示器圓底 / 圖示色
--sw-video-feed-loading-z7載入 / 緩衝指示器 z-index(壓業務 overlay 與箭頭,低於付費牆 / 貼片)
--sw-video-feed-loading-size2.5rem指示器尺寸
--sw-video-feed-loading-bg / -color黑40% / 白90%預設指示器圓底 / 圖示色
--sw-video-feed-arrow-right1remPC 箭頭距右
--sw-video-feed-arrow-gap1.25remPC 上下箭頭間距
--sw-video-feed-arrow-size2.5remPC 箭頭尺寸
--sw-video-feed-arrow-bgrgb(0 0 0 / 20%)箭頭背景(rest)
--sw-video-feed-arrow-bg-hoverrgb(0 0 0 / 30%)箭頭背景(hover)
--sw-video-feed-arrow-colorrgb(255 255 255 / 80%)箭頭圖示色
--sw-video-feed-overlay-z4業務疊層(#overlay)層級
--sw-video-feed-arrows-z5PC 切換箭頭層級(chrome,預設壓在業務 overlay 之上)
--sw-video-feed-paywall-z8付費牆層(#paywall)層級
--sw-video-feed-preroll-z10入口貼片層(#preroll-ad)層級(全螢幕接管,最高)
--sw-video-feed-loading-colorrgb(255 255 255 / 90%)載入指示器顏色
--sw-video-feed-refresh-colorrgb(255 255 255 / 90%)下拉重新整理指示器顏色
--sw-video-feed-preroll-enter-bgrgb(255 255 255 / 20%)入口貼片「進入」按鈕底色
--sw-video-feed-mute-bgrgb(0 0 0 / 40%)靜音鈕底色(FeedVideo 內)
--sw-video-feed-mute-colorrgb(255 255 255)靜音鈕圖示色(FeedVideo 內)
--sw-video-feed-progress-trackrgb(255 255 255 / 30%)進度條軌道色(FeedVideo 內)
--sw-video-feed-progress-fillrgb(255 255 255 / 85%)進度條已播段顏色
--sw-video-feed-progress-fill-activergb(255 255 255)拖動態的已播段顏色(兩態手勢展開時)
--sw-video-feed-progress-handlergb(255 255 255)進度條拖曳把手顏色
--sw-video-feed-seek-bgrgb(0 0 0 / 55%)拖動時的時間氣泡底色
--sw-video-feed-seek-colorrgb(255 255 255)時間氣泡裡的當前時間色
--sw-video-feed-seek-total-colorrgb(255 255 255 / 65%)時間氣泡裡的總時長色
<!-- 嵌進定高容器 + 靜音按鈕上移 -->
<div style="height: 600px">
  <SwVideoFeed :items="list" style="--sw-video-feed-height: 100%; --sw-video-feed-mute-bottom: 4rem" />
</div>

命令式控制

透過樣板 ref 呼叫暴露方法:

<script setup>
const feedRef = ref()
</script>

<template>
  <SwVideoFeed ref="feedRef" :items="list" />
  <button @click="feedRef.next()">下一條</button>
  <button @click="feedRef.scrollToIndex(0)">回到第一條</button>
  <button @click="feedRef.setMuted(false)">取消靜音</button>
</template>

倍速與線路切換(設定選單場景)

feed 不內建設定選單 UI(抖音式 feed 無此形態是有意設計),但提供命令式 API + #overlay 作用域,業務可自渲染設定面板(如 PC 播放站從 artplayer 遷移時的倍速 / 線路能力平替):

  • setPlaybackRate(rate):對目前條及後續條持續生效(翻條 remount 不丟);rate 須為正數,非法值忽略。
  • switchLine(code):切換播放線路(play_links 裡的 code),重掛目前條並保留播放進度(進度遷移到新線路的會話進度表,remount 後續播)。
  • #overlay 作用域透出 { playbackRate, lineCode }(響應式),設定面板據此顯示目前態。
<SwVideoFeed ref="feedRef" :items="list" link-code="line1">
  <template #overlay="{ playbackRate, lineCode }">
    <div class="settings">
      <button @click="feedRef.setPlaybackRate(playbackRate === 1 ? 2 : 1)">倍速 {{ playbackRate }}x</button>
      <button @click="feedRef.switchLine(lineCode === 'line1' ? 'line2' : 'line1')">線路 {{ lineCode }}</button>
    </div>
  </template>
</SwVideoFeed>

Props

名稱型別必填預設值說明
itemsVideoFeedItem[]✅—短影片陣列(見資料格式)
initialIndexnumber0進場定位到第幾條(無動畫)
windowSizenumber1播放池半徑(目前 ±N 掛真實播放核,其餘只顯封面)
preloadboolean | numbertrue預載後面 N 條影片各前 5 秒:缺省/true = 2 條、數字 = 自定條數、false/0 = 不預載(滑到位後封面兜底,最省 CDN 流量)。廣告條跳過不佔名額、尾部自然截斷;秒數精控僅 hls.js 分支,iOS Safari 原生 HLS 盡力而為
blockHijackingBrowsersbooleantrue攔截劫持型瀏覽器(UC / 夸克 / QQ / 百度)。這些瀏覽器用自己的播放器接管 <video>,我們的元素從未被使用 ⇒ 播放策略全部失效;而它們這麼做是為了給使用者提供下載 / 轉存網盤,對內容方是淨損失 ⇒ 即使能播也不該播。命中時渲染換瀏覽器提示替代播放器(iOS 建議 Safari / 安卓建議 Chrome),可用 #hijacking-browser slot 整塊自訂。微信有意不攔(標準 WKWebView + 最大流量來源)
loadMoreThresholdnumber5剩餘 < 此值觸發 @load-more
finishedbooleanfalse父層告知無更多(true 後不再觸發 @load-more)
mutedbooleantrue初始靜音;false=嘗試進場帶聲、被攔自動退回靜音
linkCodestringundefined預設線路(item.link_code 優先)
sensitivity{ wheel?: number }undefined滾輪 / 觸控板靈敏度倍率(預設 1)
ioThresholdnumber0.6IntersectionObserver 判定目前條的可見比例閾值
fillThresholdnumber | falsefalse比例相似度 ≥ 此值時裁剪鋪滿;false=不裁留黑邊
arrowsbooleantruePC 上下箭頭(僅 hover 裝置顯示、端點禁用、行動端不顯)
prerollAdbooleanfalse開啟入口貼片(一次性,進場第一條正片前;內容走 #preroll-ad、與 items 解耦)
prerollAdDurationnumber5000入口貼片倒數時長(毫秒,須 >0)
prerollAdAutoClosebooleanfalse入口貼片倒數歸零自動進正片;false 時歸零顯示「進入」等點擊
prerollAdForceWatchbooleanfalse入口貼片強制觀看(opt-in):倒數期間鎖住翻條、歸零自動解鎖;slot 據 canSkip 渲染 ✕ / 倒數
adSkipAfternumber0資訊流廣告條「強制觀看時長」feed 級兜底(毫秒),0=不強制;逐條用 item.skipAfter 覆蓋
trackVideoFeedTrackPropundefinedWebSDK 播放行為埋點(opt-in),見下「播放行為埋點」
rememberProgressbooleantrue工作階段內續看:滑走某條記進度、滑回 remount 續看(按播放 URL)。跨工作階段歷史走業務 @timeupdate 自報
pullToRefreshbooleanfalse下拉重新整理(opt-in,僅 touch):首條頂部下拉超閾值觸發 @refresh
refreshingbooleanfalse受控重新整理中態:收到 @refresh 置 true 保持指示器、拉完置 false 收起
endMode'loop' | 'next''loop'一條播完後的行為:loop 循環重播(短影片性格);next 自動推進到下一條並關掉 loop,詳見上文「播完行為」小節

播放行為埋點(:track.video)

feed 是多條流,track.video 傳映射函式 (item, index) => MediaTrackVideoSpec(回傳 undefined = 該條不上報):

<SwVideoFeed
  :items="videos"
  :track="{ video: (item, i) => ({
    video_id: String(item.id), video_title: item.title ?? '',
    video_content_type: 'short_video',
    recommend_trace_id: item.trace_id ?? '',
  }) }"
/>
  • 僅當前條計入埋點(窗口內鄰條的 play/pause 噪音自動過濾);切條自動重置行為門閂。
  • 行為詞表同 SwVideoPlayer 播放行為埋點;feed 預設 loop 循環播放,無自然 ended,完播由 play_progress_100 承擔。
  • 逃生艙:新增原生透傳事件 @play(index, item) / @pause(index, item) / @timeupdate(index, currentTime, duration),自有打點 SDK 直接消費。

Events

事件名載荷觸發時機
change(index: number, item: VideoFeedItem)目前條變化
load-more()剩餘不足 loadMoreThreshold,請父層拉下一頁
reach-end()finished 後仍滑到底
canplay(index: number, item: VideoFeedItem)目前條可播放
error(index: number, item: VideoFeedItem, err: unknown)目前條出錯。只報致命錯誤 —— 非致命(分片 404 / 網路抖動)由 hls.js 自行重試,不再打擾業務
paywall(index: number, item: VideoFeedItem)locked 條試看片段播完、付費牆顯示時(掛牆曝光轉換埋點)
play(index: number, item: VideoFeedItem)目前條開始播放(原生透傳,自有打點逃生艙)
pause(index: number, item: VideoFeedItem)目前條暫停(原生透傳)
timeupdate(index: number, currentTime: number, duration: number)播放進度更新(原生透傳)
resume-pending(index: number, item: VideoFeedItem, pending: boolean)本集恢復待決狀態,供業務保護歷史寫入。
waiting(index: number, item: VideoFeedItem)該條開始等資料(原生 waiting 透傳)。UI 回饋已由內建指示器覆蓋,本事件留給業務埋點(卡頓率統計);立即派發,不受指示器的防閃爍延遲影響
autoplay-muted(index: number, item: VideoFeedItem)非靜音自動播放被瀏覽器攔截、已自動退回靜音重試——muted=false 不保證有聲,業務據此顯示「開啟聲音」提示
refresh()下拉重新整理超閾值鬆手觸發:業務重置 items + 受控 refreshing 收尾
ended(index: number, item: VideoFeedItem)該條播放結束。⚠️ endMode='loop'(預設)下一般條 loop=true,依 HTML 規範不派發 ended;要完播訊號請用 endMode='next',或聽 timeupdate 自算里程碑
seeked(index: number, currentTime: number)定位完成:使用者拖進度條 或 外部呼叫 seek()。timeupdate 區分不出「拖曳」與「正常播放」,記跳轉行為只能用這條
controls-show / controls-hide(index: number)使用者活動期的開始 / 結束。業務用它讓 #overlay 裡的自訂 UI 跟控制列一起淡出淡入。只轉發目前這條;事件名與 SwVideoPlayer 一致。行動端與桌面都可直接消費,不必自建計時器(fw#350):行動端是「點一下喚出、播放中 3s 收、暫停常亮」(fw#372 起:播放中在進度條上橫向起手拖動同樣開啟活動期——拖動會觸發一對 controls-show / controls-hide,業務 chrome 跟隨閃現屬預期行為不是 bug),桌面是「滑鼠一動即亮、靜止 3s 收」——兩套心智由同一個狀態機驅動,因此你的 overlay 與控制列天然同步(自建計時器做不到這點)。滑鼠活動只是活動訊號、不碰播放狀態(點擊才 toggle 播放);觸控產生的 pointermove 被刻意擋掉,以免衝掉既有的 tap 語意。監聽掛在 feed 根上 ⇒ 滑鼠在你自己的 #overlay UI 上移動同樣算活動。🔴 別按字面理解成「控制項顯隱」:本組件走裸 hls + <video>,沒有 artplayer 那種 chrome,活動期實際只驅動進度條的展開態(變粗 + 出拖曳把手 + 可拖),進度條本身永遠可見、從不隱藏,靜音鈕由 muted 決定、不受本事件控制(fw#351 訂正)。浮層若掛在 feed 外(頁面 DOM,非 #overlay slot),滑鼠在其上移動餵不到本組件 ⇒ 用 markActivity() 續命,見下

Slots

插槽名作用域參數說明
overlay{ item, index, active, playbackRate, lineCode }每條業務疊層(點讚 / 作者 / 側欄 / 關注 / 設定選單等),framework 不內建這些 UI;playbackRate / lineCode 配合倍速與線路切換
paywall{ item, index, active, skipToNext }付費牆內容(item.locked 條試看完顯示):業務渲染購買 UI(解鎖 / 跳過此影片,無關閉 X);skipToNext()=切下一條
cover{ item }自訂封面(預設取 item.img 做背景圖,鋪滿裁剪;僅影片條)
in-feed-ad{ item, index, active, countdown, canSkip }資訊流廣告條(kind:'ad')創意:業務渲染圖片 + 連結 + 曝光埋點;countdown=強制觀看剩餘秒、canSkip=是否可滑走
preroll-ad{ countdown, skip, canSkip }入口貼片層內容:countdown=剩餘秒、skip()=提前進正片、canSkip=當前能否跳過(強制觀看鎖期為 false);不提供時預設渲染倒數 +「進入」
loading{ item, index }載入 / 緩衝指示器:外層已按緩衝態決定顯隱,slot 內只管畫。不提供時給預設極簡轉圈;傳空 slot(<template #loading />)即關閉
refresh-indicator{ pulling, progress, refreshing }下拉重新整理指示器(懸浮頂部不位移內容):按 progress 做跟手品牌動畫;不提供時預設箭頭 / 轉圈
hijacking-browser{ browser, suggested }劫持型瀏覽器命中時的整塊提示內容(取代播放器,不是蓋在上面)。browser 是命中的瀏覽器識別(uc / qq / baidu),suggested 是建議改用的瀏覽器名(iOS→Safari / 其他→Chrome)

暴露方法(defineExpose)

成員簽章說明
next() => void翻到下一條(到底且 finished 時觸發 reach-end)
prev() => void翻到上一條
scrollToIndex(i: number, opts?: { immediate?: boolean }) => void捲到指定條(immediate 無動畫)
setMuted(b: boolean) => void設定 feed 級靜音態
skip() => void結束入口貼片進正片(等價 slot 的 skip())
setPlaybackRate(rate: number) => void設定播放倍速(目前及後續條持續生效,翻條不丟;非正數忽略)
switchLine(code: string) => void切換播放線路(重掛目前條、保留播放進度)
play() => void播放目前這條(該條無播放核時靜默 no-op)
pause() => void暫停目前這條
togglePlay() => void切換目前這條播放/暫停
seek(time: number) => void目前這條跳到指定秒(越界自動夾到 [0, duration])
getVideo() => HTMLVideoElement | null逃生艙:取目前這條的 <video> 元素(對齊 SwVideoPlayer.getPlayer())。⚠️ 隨虛擬化卸載失效,別快取、每次現取
markActivity() => void延長「使用者活動期」(fw#351):進度條保持展開、controls-hide 往後推 3s。用於浮層掛在 feed 外(滑鼠在其上移動餵不到本組件)或進場寬限——hover 期間週期性呼叫即可。🔴 只能延長、不能否決隱藏:隱藏決策單一歸本組件,別去攔 controls-hide(兩邊都判斷何時隱藏 = 退回自建計時器的老問題)
activeIndexRef<number>目前條索引

控件配色 Token

播放控件(播放圖標 / 拖動時長 / 靜音鈕 / 進度條)配色經 --sw-video-feed-* token 覆蓋(fallback 即預設媒體語境黑白值):

--sw-video-feed-play-icon-color(白 60%) · -seek-bg(黑 55%) · -seek-color(白) · -seek-total-color(白 65%) · -mute-bg(黑 40%) · -mute-color(白) · -progress-track(白 30%) · -progress-fill(白 85%) · -progress-fill-active(白) · -progress-handle(白)

注意事項

  • #overlay 自渲業務 UI:按讚 / 作者 / 追蹤 / 側欄圖示列等都在 #overlay 裡寫,framework 不內建;插槽容器 pointer-events: none(影片區可點可滑),只給直接子元素兜底 pointer-events: auto 且零特異度,更深層靠繼承——所以在插槽內任意層級寫 pointer-events: none 都能生效並向下繼承(隱藏的操作列不會吃點擊),不必用 visibility: hidden 繞。
  • @load-more 用追加不用替換:父層拿到新頁 items.value.push(...page),整體替換會卸載已掛載的播放核、丟進度。
  • 定高容器必設 --sw-video-feed-height:元件預設 100dvh 全螢幕;嵌進卡片 / 文件盒子時給容器固定高度 + 設 --sw-video-feed-height: 100%。
  • 靜音自動播放是預設:行動端「帶聲自動播」永遠被瀏覽器攔,muted="false" 只在有進場手勢(SPA 導航)時可能帶聲,被攔自動退回靜音。
  • 三端:行動 / 平板原生 scroll-snap(零設定);桌面自訂滾輪 + 鍵盤 + 可選箭頭。