短剧 H5 三槽竖滑播放器示意

短剧 H5 无缝竖滑:Vue 三槽虚拟列表怎么搭

作者 CaboKit|创建于 |标签 Web技术

短剧 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

摘录 · renderList / listStyle(完整见第 5 节)ts
// 摘录 · 完整可运行文件见第 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 节)ts
// 摘录 · 完整见第 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 节)ts
// 摘录 · 完整见第 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;自动播失败需用户再点。

步骤bash
# 可运行步骤(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,需可访问
完整组件 ClipSwipePlayer.vuevue
<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>
父页 App.example.vuevue
<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 起算,用尽再硬刷。

软恢复赌会话还活着;硬恢复认会话废了。画面「上顶」是偏移残留,不是硬刷理由。

示意 · 软/硬分支(未并进可运行 DEMO,需自补 probeAlive/sleep/once)ts
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 续播写成同一套。

继续阅读

👇 同标签更多,或返回文章列表

全部文章|Web技术

下一篇:iOS H5 HLS 点播:AVPlayer 接管后的 Seek、Service Worker 与起播差异