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