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 守著):
- 付費條(
item.locked)優先 —— 試看片段播完顯付費牆、不推進,不會把使用者從牆上帶走 - 強制觀看期不繞過 —— 資訊流廣告條 / 入口貼片倒數未歸零時不推進
- 末條 ——
finished為真時觸發reach-end(業務自行收尾,如 toast + 返回上層); 為假時靜默停住。連播與分頁刻意不耦合,靠loadMoreThreshold提前拉好下一頁
極簡播放控制(抖音風)
每條播放核內建抖音式極簡控制(無需設定):點擊畫面暫停 / 播放、點擊 / hover 喚出可拖動進度條(拖動時顯示目前 / 總時長)、播放中在進度條上橫向起手可直接拖動(無需先點畫面;縱向起手仍是切條,fw#372)、右下角靜音按鈕。位置 / 尺寸全部走 CSS 變數微調(見 CSS 變數)。
比例裁剪(fillThreshold)
影片與視口寬高比相似度 ≥ fillThreshold 時裁剪鋪滿(object-fit: cover,頂部對齊 + 水平置中);預設 false(不裁,留黑邊 / contain)。
相似度 = min(影片比, 容器比) / max(影片比, 容器比)。建議取 0.8:
| 情境 | 相似度 | 0.9 | 0.8 |
|---|---|---|---|
| 規範 9:16 素材 @ 16:9 舊機 | 1.000 | ✂ 裁滿 | ✂ 裁滿 |
| 規範 9:16 素材 @ 19.5:9(主流) | 0.822 | ❌ 仍留黑邊 | ✂ 裁滿 |
| 規範 9:16 素材 @ 20:9 | 0.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(命中 ESLintno-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-indicatorslot 自渲染({ 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-height | 100dvh | feed 高度(放進定高父容器可改成 100% 等) |
--sw-video-feed-mute-right | 1rem | 靜音按鈕距右 |
--sw-video-feed-mute-bottom | 2.5rem | 靜音按鈕距底(右下角單手夠得著;業務側圖示列放其上方) |
--sw-video-feed-progress-inset | 0.5rem | 進度條距左右(防拖到螢幕邊緣誤觸手勢) |
--sw-video-feed-progress-bottom | 0 | 進度條距底 |
--sw-video-feed-refresh-z | 6 | 下拉重新整理指示器 z-index(壓業務 overlay、低於付費牆 / 貼片) |
--sw-video-feed-refresh-top | 1rem | 下拉重新整理指示器距頂 |
--sw-video-feed-refresh-bg / -color | 黑40% / 白90% | 預設指示器圓底 / 圖示色 |
--sw-video-feed-loading-z | 7 | 載入 / 緩衝指示器 z-index(壓業務 overlay 與箭頭,低於付費牆 / 貼片) |
--sw-video-feed-loading-size | 2.5rem | 指示器尺寸 |
--sw-video-feed-loading-bg / -color | 黑40% / 白90% | 預設指示器圓底 / 圖示色 |
--sw-video-feed-arrow-right | 1rem | PC 箭頭距右 |
--sw-video-feed-arrow-gap | 1.25rem | PC 上下箭頭間距 |
--sw-video-feed-arrow-size | 2.5rem | PC 箭頭尺寸 |
--sw-video-feed-arrow-bg | rgb(0 0 0 / 20%) | 箭頭背景(rest) |
--sw-video-feed-arrow-bg-hover | rgb(0 0 0 / 30%) | 箭頭背景(hover) |
--sw-video-feed-arrow-color | rgb(255 255 255 / 80%) | 箭頭圖示色 |
--sw-video-feed-overlay-z | 4 | 業務疊層(#overlay)層級 |
--sw-video-feed-arrows-z | 5 | PC 切換箭頭層級(chrome,預設壓在業務 overlay 之上) |
--sw-video-feed-paywall-z | 8 | 付費牆層(#paywall)層級 |
--sw-video-feed-preroll-z | 10 | 入口貼片層(#preroll-ad)層級(全螢幕接管,最高) |
--sw-video-feed-loading-color | rgb(255 255 255 / 90%) | 載入指示器顏色 |
--sw-video-feed-refresh-color | rgb(255 255 255 / 90%) | 下拉重新整理指示器顏色 |
--sw-video-feed-preroll-enter-bg | rgb(255 255 255 / 20%) | 入口貼片「進入」按鈕底色 |
--sw-video-feed-mute-bg | rgb(0 0 0 / 40%) | 靜音鈕底色(FeedVideo 內) |
--sw-video-feed-mute-color | rgb(255 255 255) | 靜音鈕圖示色(FeedVideo 內) |
--sw-video-feed-progress-track | rgb(255 255 255 / 30%) | 進度條軌道色(FeedVideo 內) |
--sw-video-feed-progress-fill | rgb(255 255 255 / 85%) | 進度條已播段顏色 |
--sw-video-feed-progress-fill-active | rgb(255 255 255) | 拖動態的已播段顏色(兩態手勢展開時) |
--sw-video-feed-progress-handle | rgb(255 255 255) | 進度條拖曳把手顏色 |
--sw-video-feed-seek-bg | rgb(0 0 0 / 55%) | 拖動時的時間氣泡底色 |
--sw-video-feed-seek-color | rgb(255 255 255) | 時間氣泡裡的當前時間色 |
--sw-video-feed-seek-total-color | rgb(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
| 名稱 | 型別 | 必填 | 預設值 | 說明 |
|---|---|---|---|---|
items | VideoFeedItem[] | ✅ | — | 短影片陣列(見資料格式) |
initialIndex | number | 0 | 進場定位到第幾條(無動畫) | |
windowSize | number | 1 | 播放池半徑(目前 ±N 掛真實播放核,其餘只顯封面) | |
preload | boolean | number | true | 預載後面 N 條影片各前 5 秒:缺省/true = 2 條、數字 = 自定條數、false/0 = 不預載(滑到位後封面兜底,最省 CDN 流量)。廣告條跳過不佔名額、尾部自然截斷;秒數精控僅 hls.js 分支,iOS Safari 原生 HLS 盡力而為 | |
blockHijackingBrowsers | boolean | true | 攔截劫持型瀏覽器(UC / 夸克 / QQ / 百度)。這些瀏覽器用自己的播放器接管 <video>,我們的元素從未被使用 ⇒ 播放策略全部失效;而它們這麼做是為了給使用者提供下載 / 轉存網盤,對內容方是淨損失 ⇒ 即使能播也不該播。命中時渲染換瀏覽器提示替代播放器(iOS 建議 Safari / 安卓建議 Chrome),可用 #hijacking-browser slot 整塊自訂。微信有意不攔(標準 WKWebView + 最大流量來源) | |
loadMoreThreshold | number | 5 | 剩餘 < 此值觸發 @load-more | |
finished | boolean | false | 父層告知無更多(true 後不再觸發 @load-more) | |
muted | boolean | true | 初始靜音;false=嘗試進場帶聲、被攔自動退回靜音 | |
linkCode | string | undefined | 預設線路(item.link_code 優先) | |
sensitivity | { wheel?: number } | undefined | 滾輪 / 觸控板靈敏度倍率(預設 1) | |
ioThreshold | number | 0.6 | IntersectionObserver 判定目前條的可見比例閾值 | |
fillThreshold | number | false | false | 比例相似度 ≥ 此值時裁剪鋪滿;false=不裁留黑邊 | |
arrows | boolean | true | PC 上下箭頭(僅 hover 裝置顯示、端點禁用、行動端不顯) | |
prerollAd | boolean | false | 開啟入口貼片(一次性,進場第一條正片前;內容走 #preroll-ad、與 items 解耦) | |
prerollAdDuration | number | 5000 | 入口貼片倒數時長(毫秒,須 >0) | |
prerollAdAutoClose | boolean | false | 入口貼片倒數歸零自動進正片;false 時歸零顯示「進入」等點擊 | |
prerollAdForceWatch | boolean | false | 入口貼片強制觀看(opt-in):倒數期間鎖住翻條、歸零自動解鎖;slot 據 canSkip 渲染 ✕ / 倒數 | |
adSkipAfter | number | 0 | 資訊流廣告條「強制觀看時長」feed 級兜底(毫秒),0=不強制;逐條用 item.skipAfter 覆蓋 | |
track | VideoFeedTrackProp | undefined | WebSDK 播放行為埋點(opt-in),見下「播放行為埋點」 | |
rememberProgress | boolean | true | 工作階段內續看:滑走某條記進度、滑回 remount 續看(按播放 URL)。跨工作階段歷史走業務 @timeupdate 自報 | |
pullToRefresh | boolean | false | 下拉重新整理(opt-in,僅 touch):首條頂部下拉超閾值觸發 @refresh | |
refreshing | boolean | false | 受控重新整理中態:收到 @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(兩邊都判斷何時隱藏 = 退回自建計時器的老問題) |
activeIndex | Ref<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(零設定);桌面自訂滾輪 + 鍵盤 + 可選箭頭。