
短剧 H5 的核心手感是:一集一屏、上滑下一集、手指跟着走,松手要么切过去要么弹回。体验像信息流,工程上却不能给 80~120 集各挂一个 <video>——内存、解码器和 HLS 实例都会炸。
「CaboKit」侧在 Vue 里拆成手势、三槽、唯激活有声。第 2~4 节是原理摘录;第 5 节是一份 Vite 可直接跑的完整 DEMO(也可 下载 DEMO 压缩包)。之后再谈软/硬恢复与 WebView 坑。
1)目标体验拆成三件事
跟手
竖滑时列表随 deltaY 偏移;不足阈值回弹,超过则切集。
无缝观感
邻集滑入前尽量已有画面:DEMO / 正式都给 poster;Android/mp4 且 seek 可靠时,才再考虑邻槽静音定格(本 DEMO 不做定格)。
可控成本
同时最多上一集 · 当前 · 下一集三槽,DOM / 解码会话是常数。
虚拟列表管看见几集;播放策略管谁在出声。两边切干净,竖滑才稳。
生产可按 onTouch* / renderList / syncPlayback 边界拆模块。下文第 2~4 节先讲原理摘录,完整可运行文件在第 5 节(含压缩包下载)。
2)三槽:只渲染 current±1
数据源仍是完整 clips[],渲染只取 i-1 / i / i+1:
// 摘录 · 完整可运行文件见第 5 节
const renderList = computed(() => {
const i = currentIndex.value
const out: { clip: SwipeClip; absoluteIndex: number }[] = []
for (const j of [i - 1, i, i + 1]) {
const clip = props.clips[j]
if (!clip) continue
out.push({ clip, absoluteIndex: j })
}
return out
})
const listStyle = computed(() => ({
transform: `translate3d(0, calc(-${currentIndex.value * 100}% + ${swipeOffsetY.value}px), 0)`,
transition: isRecovery.value
? `transform ${props.transitionMs}ms ease-out`
: 'none',
}))calc(百分比 + 像素)
切集用百分比对齐整屏,跟手用像素贴手指;不依赖 SSR 时的 window.innerHeight。
transition 仅回弹时开
跟手 transition: none;松手约 320ms ease-out。全程带 transition 会发黏。
槽上挂真实 <video>,边滑边能看见邻集海报/画面。
3)手势:先认方向,再认切集
DEMO 只处理竖滑切集。正式播放器还要管点按唤出控件时,不能 touchstart 立刻当滑动:
// 摘录 · 完整见第 5 节
// 竖向主导后进入 isDragging;松手 |deltaY| >= swipeThreshold 才切集
// touchcancel 必须 swipeOffsetY = 0跟踪 ≠ 拖拽
仅竖向过约 10px、且压过横向后才 isDragging(DEMO 已实现)。
切集阈值 · 边界
默认 |deltaY| ≥ swipeThreshold(60) 才 commit;禁止方向不跟手(DEMO 已实现)。
控件命中(生产再补)
有顶栏 / 进度条时,按钮区 closest 剔除,避免吞 click / ghost click——不在本 DEMO 内。
touchcancel 必须清 swipeOffsetY(DEMO 已做)——和「媒体假死」不是一回事,见第 7 节。
4)播停:只让当前槽出声
三槽可同时挂流,但同时只应一路有声。DEMO:激活 play + 静音暂停其它 <video>;邻槽靠 poster,不对邻槽 currentTime seek(避免 iOS 原生 HLS 在部分源上 stalled)。
// 摘录 · 完整见第 5 节
// v-for :key="slot.absoluteIndex" 复用槽,勿用 clip.id
// 激活 play + silenceSiblings;邻槽 muted + pause,默认不 seek生产再补:快滑 epoch 丢弃过期起播、跟手稳定后再预热邻槽等——不在本 DEMO 内。槽位调度与媒体预热仍建议拆开,手势层勿直接碰 HLS。
5)可运行 DEMO(Vite 一整份)
前面的片段不能单独跑。请 下载 DEMO 压缩包(含 ClipSwipePlayer.vue + App.example.vue),按步骤放到 Vite 项目即可竖滑试播(sample mp4 需能访问 Google CDN):
下载
clip-swipe-demo.zip · 单独下载 .vue · App.example.vue
关键约定
三槽 :key="slot.absoluteIndex"(勿 clip.id,否则切集狂拆 <video>)。窗口滑动时边缘下标仍会进出 DOM,属预期;中间槽尽量复用。邻槽不 seek;自动播失败需用户再点。
# 可运行步骤(Vue 3 + Vite)
npm create vite@latest clip-swipe-demo -- --template vue-ts
cd clip-swipe-demo && npm i
# 下载 clip-swipe-demo.zip 并解压:
# ClipSwipePlayer.vue → src/components/ClipSwipePlayer.vue
# App.example.vue → 覆盖 src/App.vue
# (若 App 不在 components 旁,改 import 为 '@/components/ClipSwipePlayer.vue')
npm run dev
# Chrome 设备模式或真机竖滑
# sample 视频 / poster 走外网 CDN,需可访问<script setup lang="ts">
/**
* 短剧竖滑 · 可运行精简 DEMO(Vue 3)
* 博文:/blog/vue-short-drama-seamless-swipe
*
* Vite:放到 src/components/ClipSwipePlayer.vue,父页见同目录 App.example.vue
*/
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
export type SwipeClip = {
id: string | number;
src: string;
poster?: string;
title?: string;
};
const props = withDefaults(
defineProps<{
clips: SwipeClip[];
index?: number;
swipeThreshold?: number;
transitionMs?: number;
}>(),
{
index: 0,
swipeThreshold: 60,
transitionMs: 320,
},
);
const emit = defineEmits<{
"update:index": [number];
swipe: [direction: "prev" | "next", index: number];
change: [clip: SwipeClip, index: number];
}>();
const TAP_MAX_MOVE = 10;
const currentIndex = ref(props.index);
const swipeOffsetY = ref(0);
const isDragging = ref(false);
const isRecovery = ref(false);
let startX = 0;
let startY = 0;
let touchTracking = false;
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
let playTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => props.index,
(v) => {
if (v !== currentIndex.value) currentIndex.value = v;
},
);
const renderList = computed(() => {
const i = currentIndex.value;
const out: { clip: SwipeClip; absoluteIndex: number }[] = [];
for (const j of [i - 1, i, i + 1]) {
const clip = props.clips[j];
if (!clip) continue;
out.push({ clip, absoluteIndex: j });
}
return out;
});
const listStyle = computed(() => ({
transform: `translate3d(0, calc(-${currentIndex.value * 100}% + ${swipeOffsetY.value}px), 0)`,
transition: isRecovery.value
? `transform ${props.transitionMs}ms ease-out`
: "none",
}));
const slotStyle = (absoluteIndex: number) => ({
top: `${absoluteIndex * 100}%`,
height: "100%",
});
const canSwipe = (deltaY: number) => {
if (deltaY < 0) return currentIndex.value < props.clips.length - 1;
if (deltaY > 0) return currentIndex.value > 0;
return false;
};
const commitIndex = (next: number, dir: "prev" | "next") => {
if (next === currentIndex.value) return;
currentIndex.value = next;
emit("update:index", next);
emit("swipe", dir, next);
const clip = props.clips[next];
if (clip) emit("change", clip, next);
};
const onTouchStart = (e: TouchEvent) => {
const t = e.changedTouches[0];
if (!t) return;
startX = t.clientX;
startY = t.clientY;
touchTracking = true;
swipeOffsetY.value = 0;
isDragging.value = false;
};
const onTouchMove = (e: TouchEvent) => {
if (!touchTracking) return;
const t = e.changedTouches[0];
if (!t) return;
const deltaY = t.clientY - startY;
const deltaX = t.clientX - startX;
if (!isDragging.value) {
if (Math.abs(deltaY) <= TAP_MAX_MOVE) return;
if (Math.abs(deltaY) <= Math.abs(deltaX) * 1.5) return;
if (!canSwipe(deltaY)) return;
isDragging.value = true;
isRecovery.value = false;
if (recoveryTimer) clearTimeout(recoveryTimer);
}
swipeOffsetY.value = canSwipe(deltaY) ? deltaY : 0;
};
const finishGesture = () => {
const deltaY = swipeOffsetY.value;
const dragging = isDragging.value;
touchTracking = false;
isDragging.value = false;
swipeOffsetY.value = 0;
isRecovery.value = true;
if (recoveryTimer) clearTimeout(recoveryTimer);
recoveryTimer = setTimeout(() => {
isRecovery.value = false;
}, props.transitionMs);
if (!dragging || Math.abs(deltaY) < props.swipeThreshold) return;
if (deltaY < 0) commitIndex(currentIndex.value + 1, "next");
else commitIndex(currentIndex.value - 1, "prev");
};
const onTouchEnd = () => finishGesture();
const onTouchCancel = () => {
touchTracking = false;
isDragging.value = false;
swipeOffsetY.value = 0;
isRecovery.value = true;
if (recoveryTimer) clearTimeout(recoveryTimer);
recoveryTimer = setTimeout(() => {
isRecovery.value = false;
}, props.transitionMs);
};
const videoRefs = new Map<number, HTMLVideoElement>();
const setVideoRef = (absoluteIndex: number, el: Element | null) => {
if (el instanceof HTMLVideoElement) videoRefs.set(absoluteIndex, el);
else videoRefs.delete(absoluteIndex);
};
const silenceSiblings = (except: HTMLVideoElement) => {
for (const v of document.querySelectorAll("video")) {
if (v === except) continue;
v.muted = true;
if (!v.paused) {
try {
v.pause();
} catch {
/* ignore */
}
}
}
};
const syncPlayback = async () => {
if (playTimer) clearTimeout(playTimer);
playTimer = setTimeout(async () => {
await nextTick();
const active = currentIndex.value;
for (const [i, video] of videoRefs) {
if (i === active) {
video.muted = false;
try {
await video.play();
silenceSiblings(video);
} catch {
/* 自动播被拦 → 用户手势再点 */
}
} else {
video.muted = true;
video.pause();
}
}
}, props.transitionMs);
};
watch(currentIndex, () => {
void syncPlayback();
});
watch(
() => props.clips,
() => {
void syncPlayback();
},
{ deep: true },
);
onBeforeUnmount(() => {
if (recoveryTimer) clearTimeout(recoveryTimer);
if (playTimer) clearTimeout(playTimer);
for (const video of videoRefs.values()) video.pause();
videoRefs.clear();
});
void syncPlayback();
</script>
<template>
<div
class="clip-swipe"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@touchcancel.passive="onTouchCancel"
>
<div class="clip-swipe__list" :style="listStyle">
<div
v-for="slot in renderList"
:key="slot.absoluteIndex"
class="clip-swipe__slot"
:style="slotStyle(slot.absoluteIndex)"
>
<video
:ref="(el) => setVideoRef(slot.absoluteIndex, el as Element | null)"
class="clip-swipe__video"
:src="slot.clip.src"
:poster="slot.clip.poster"
playsinline
webkit-playsinline
preload="auto"
:muted="slot.absoluteIndex !== currentIndex"
/>
<div v-if="slot.clip.title" class="clip-swipe__title">
{{ slot.clip.title }}
</div>
<div class="clip-swipe__badge">
{{ slot.absoluteIndex + 1 }} / {{ clips.length }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.clip-swipe {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
touch-action: none;
user-select: none;
}
.clip-swipe__list {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
will-change: transform;
}
.clip-swipe__slot {
position: absolute;
left: 0;
width: 100%;
overflow: hidden;
background: #000;
}
.clip-swipe__video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.clip-swipe__title {
position: absolute;
left: 16px;
right: 72px;
bottom: 48px;
color: #fff;
font-size: 15px;
line-height: 1.4;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.clip-swipe__badge {
position: absolute;
right: 16px;
bottom: 48px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 12px;
pointer-events: none;
}
</style><script setup lang="ts">
import { ref } from "vue";
import ClipSwipePlayer from "./components/ClipSwipePlayer.vue";
const index = ref(0);
/**
* Google sample 直链可直接播;正式项目换成自己的地址。
* poster 用占位图演示邻槽「先有画」;生产换真实封面。
*/
const clips = [
{
id: 1,
title: "第 1 集",
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
poster: "https://picsum.photos/seed/clip1/720/1280",
},
{
id: 2,
title: "第 2 集",
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
poster: "https://picsum.photos/seed/clip2/720/1280",
},
{
id: 3,
title: "第 3 集",
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
poster: "https://picsum.photos/seed/clip3/720/1280",
},
{
id: 4,
title: "第 4 集",
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
poster: "https://picsum.photos/seed/clip4/720/1280",
},
{
id: 5,
title: "第 5 集",
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
poster: "https://picsum.photos/seed/clip5/720/1280",
},
];
</script>
<template>
<div style="height: 100dvh; margin: 0; background: #000">
<ClipSwipePlayer
:clips="clips"
v-model:index="index"
:swipe-threshold="60"
:transition-ms="320"
@change="(clip, i) => console.log('切到', i, clip.id)"
/>
</div>
</template>容器高度
外层 100dvh,否则三槽对不齐一屏。
桌面调试
用 Chrome 设备模式开触摸;真机竖滑手感更准。
上线再拆
按文件内边界拆 onTouch* / renderList / syncPlayback,并补进度条、锁集;恢复见第 6 节。
本 DEMO不含软/硬恢复(示意代码在下一节,需自接 App 前后台事件)。
6)为什么要有软恢复和硬恢复
WebView 里切后台再回前台是独立链路。管线可能「假活」:元素还在,画面与进度不动。不能只会 play(),也不能一回来就整槽 load()。
软恢复
不 load()、尽量不闪封面;优先 play。Android/Web 可近位点轻 seek;iOS 原生 HLS 软恢复勿默认 seek。
硬恢复(硬刷)
卸流 / load()(或重建 hls.js)+ 封面挡中间态;代价是闪与重缓冲。iOS 硬刷后常从片头起播,勿立刻 seek 回旧点。
为何两边都要
一律硬刷 → 短后台总闪;一律 soft → 长后台假活干等。极短后台可约定禁止立刻硬刷;软预算自 resumeAt 起算,用尽再硬刷。
软恢复赌会话还活着;硬恢复认会话废了。画面「上顶」是偏移残留,不是硬刷理由。
type Platform = 'ios' | 'android' | 'web'
type ResumeCtx = {
platform: Platform
backgroundMs: number
wasPlaying: boolean
hasFatalMediaError: boolean
resumeAt: number // Date.now() 收到前台事件时
}
const FAST_BOUNCE_MS = 10_000 // 示意,按产品调
const SOFT_BUDGET_MS = 8_000
async function handleForegroundResume(ctx: ResumeCtx, video: HTMLVideoElement) {
if (ctx.hasFatalMediaError) {
await hardRefresh(video)
return
}
const softBudgetLeft = () => SOFT_BUDGET_MS - (Date.now() - ctx.resumeAt)
if (ctx.wasPlaying && ctx.backgroundMs < FAST_BOUNCE_MS) {
await softResume(video, ctx.platform)
await probeAlive(video)
return
}
if (ctx.wasPlaying) {
await softResume(video, ctx.platform)
if (await probeAlive(video)) return
while (softBudgetLeft() > 0) {
await sleep(400)
if (await probeAlive(video)) return
}
await hardRefresh(video)
return
}
if (!(await probeAlive(video))) await hardRefresh(video)
}
async function softResume(video: HTMLVideoElement, platform: Platform) {
if (platform !== 'ios') {
const t = Math.max(0, video.currentTime - 0.05)
try { video.currentTime = t } catch {}
}
try { await video.play() } catch {}
}
async function hardRefresh(video: HTMLVideoElement) {
video.load()
await once(video, 'canplay')
try { await video.play() } catch {}
}三条线分开查
续播分级 · swipeOffsetY 复位 · 100dvh 视口钉扎——不要绑在一个 load() 里修。
纯 DEMO 可暂不做恢复;进 App WebView 后建议尽早补齐。
7)常见坑点(真机与 WebView)
排障先分手势 / 布局与媒体管线。
touchcancel → 画面「顶上去」
iOS 进多任务常只发 cancel;swipeOffsetY 不归零则三槽停在半截。易误判合成层;点一下「好了」只是新手势清了偏移。回前台再兜底清一次。
横滑 / 点空白误切集 · 过渡期 pause(生产)
有进度条时用 closest 剔除热区;跟手回缩勿当 blank tap。切集动画中旧槽 pause 勿驱动壳层控件——均需在 DEMO 之外补壳层。
弱网连滑 / 过期起播(生产)
用 epoch 丢弃过期 play();稳定后再预热邻槽;iOS 极快连滑可强制重建挂流——本 DEMO 未实现。
play() 成功但画面假死
有声自动播可被静默拦截:Promise 不 reject,currentTime 停 0。心跳豁免 checkTime > 0.1,避免误硬刷循环。
解码器假死 · 快切竞态
长后台假活靠探针升硬刷。前后台快切要 abort 在途恢复并用代次作废异步,防后台偶发出声。
双声道 · attachedCount: 2
激活起播后 mute+pause 其它 video。两路挂 src 可以是常态;仅当起播慢、双声、弱网再卡时再考虑失活卸流。
平台续播不能共用
hls.js 可 startLoad(续播点);iOS 原生 HLS 在某些分片包装下强行 currentTime 极易 stalled。邻槽预热同样分叉。
连环 load · fetch 抢缓存 · 全屏劫持
用原始入参记 attachedUrl,勿 video.src !== url。克制对原生即将播的资源做冲突 CORS fetch。H5 playsinline+ App allowsInlineMediaPlayback。
切后台勿乱 stopLoad
优先 pause + 前台分级恢复;壳用 webview-pause / webview-foreground,比只靠 Visibility 稳。
画面歪了查偏移与视口;定格不动查假活 / 自动播;确认管线坏了再 load()。
DEMO 不必一次做全;进壳优先:偏移复位、单路有声、软硬恢复、内联播放。
❓ FAQ
为什么不用 scroll-snap?
跟手未过阈值回弹、起播时序、多 video 生命周期控制弱,短剧常半道返工回三槽。
软恢复失败多久硬刷?
「进度是否真推进」探针 + 自 resume 起算的软预算;极短后台可禁立刻硬刷。
一定要 Vue 吗?
模型与框架无关;React 同是三槽状态机。
和 Feed 竖滑一样吗?
骨架类似;短剧多有序集、续播分叉、锁集与软/硬恢复。
📝 补充说明:先跑通三槽 + 跟手 + 单路有声,再补平台分叉的软/硬恢复。勿把「上顶」当硬刷,勿把 iOS / Android 续播写成同一套。
继续阅读
👇 同标签更多,或返回文章列表