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(零配置);桌面自定义滚轮 + 键盘 + 可选箭头。