智能轮播Swiper:视频图片混播

文章目录

  • 分析
    • 二、文件结构
    • 三、代码
      • [3.1 `<template>` 模板部分](#3.1 <template> 模板部分)
      • [3.2 `<script setup lang="ts">` 逻辑部分](#3.2 <script setup lang="ts"> 逻辑部分)
        • [3.2.1 导入依赖](#3.2.1 导入依赖)
        • [3.2.2 定义数据类型](#3.2.2 定义数据类型)
        • [3.2.3 定义组件接收的参数(props)](#3.2.3 定义组件接收的参数(props))
        • [3.2.4 定义响应式变量](#3.2.4 定义响应式变量)
        • [3.2.5 Swiper 模块配置](#3.2.5 Swiper 模块配置)
        • [3.2.6 数据标准化函数](#3.2.6 数据标准化函数)
        • [3.2.7 存储视频 DOM 引用](#3.2.7 存储视频 DOM 引用)
        • [3.2.8 图片自动切换定时器](#3.2.8 图片自动切换定时器)
        • [3.2.9 Slide 切换时的核心调度(最重要的函数)](#3.2.9 Slide 切换时的核心调度(最重要的函数))
        • [3.2.10 视频播放事件处理](#3.2.10 视频播放事件处理)
        • [3.2.11 加载数据和生命周期](#3.2.11 加载数据和生命周期)
      • [3.3 `<style scoped>` 样式部分](#3.3 <style scoped> 样式部分)
    • 四、完整运行流程(图文示例)
    • 五、为什么要这样设计?
      • [为什么不用 Swiper 自带的 autoplay?](#为什么不用 Swiper 自带的 autoplay?)
      • [为什么需要 isVideoPlaying 锁?](#为什么需要 isVideoPlaying 锁?)
    • 六、常⻅问题
      • [Q: 为什么不直接让用户手动滑动?](#Q: 为什么不直接让用户手动滑动?)
      • [Q: 视频为什么静音(muted)?](#Q: 视频为什么静音(muted)?)
      • [Q: `setTimeout` 时间准吗?](#Q: setTimeout 时间准吗?)
      • [Q: 视频播放失败怎么办?](#Q: 视频播放失败怎么办?)
    • 七、源代码

一、需求

有一个轮播区域,可以自动轮播图片和视频

  • 如果当前是一张图片 → 停留 3 秒,然后自动切换到下一个
  • 如果当前是一个视频等视频播放完,再自动切换到下一个
  • 用户可以混合排列图片和视频,顺序任意

分析


二、文件结构

只涉及两个文件:

文件 作用
src/components/TopCarousel.vue 轮播组件的代码文件(模板 + 逻辑 + 样式都在这里)
src/utils/constants.ts 常量文件,存放轮播间隔时间等配置

三、代码

3.1 <template> 模板部分

vue 复制代码
<template>
  <div class="carousel-wrapper">
    <div class="carousel-inner">
      <Swiper
        ref="swiperRef"
        :modules="modules"
        :loop="true"
        :allow-touch-move="false"
        @slide-change="onSlideChange"
        class="carousel-swiper"
      >
        <SwiperSlide v-for="(item, index) in mediaList" :key="index">
          <!-- 视频 -->
          <video
            v-if="item.type === 'video'"
            :src="item.url"
            :ref="el => setVideoRef(index, el)"
            muted
            playsinline
            preload="auto"
            class="carousel-media"
            @playing="onVideoPlaying"
            @ended="onVideoEnded"
          />
          <!-- 图片 -->
          <img
            v-else
            :src="item.url"
            :alt="item.alt || '轮播图片'"
            class="carousel-media"
          />
        </SwiperSlide>
      </Swiper>
    </div>
  </div>
</template>

解释:

代码 含义
<div class="carousel-wrapper"> 最外层容器,控制整个轮播区域的位置和大小
<div class="carousel-inner"> 内层容器,限定轮播的可视区域尺寸(956×543)
<Swiper> 轮播库 Swiper 的根组件,它提供了滑动/切换的能力
ref="swiperRef" 给 Swiper 取个名字,方便在 JS 代码里控制它(比如调用 slideNext()
:modules="modules" 注册 Swiper 的插件模块(这里只用到了分页器 Pagination)
:loop="true" 开启循环模式,最后一张之后回到第一张
:allow-touch-move="false" 禁止用户用手滑动切换,只能由程序自动控制
@slide-change="onSlideChange" 每次切换完成时触发 onSlideChange 函数
<SwiperSlide v-for="..."> 循环渲染每一个轮播项
v-if="item.type === 'video'" 如果是视频类型,渲染 <video> 标签
:ref="el => setVideoRef(index, el)" 把当前视频的 DOM 元素存起来,方便后面控制播放/暂停
muted 视频静音(浏览器自动播放策略要求)
playsinline 在 iPhone 上不自动全屏播放
preload="auto" 提前加载视频
@playing="onVideoPlaying" 视频开始播放时触发
@ended="onVideoEnded" 视频播放完毕时触发
v-else 否则就是图片,渲染 <img> 标签

3.2 <script setup lang="ts"> 逻辑部分

3.2.1 导入依赖
ts 复制代码
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import type { Swiper as SwiperClass } from 'swiper/types'
import { Pagination } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/pagination'

import request from '../utils/request'
import { API, CAROUSEL_INTERVAL } from '../utils/constants'
导入 是什么
ref Vue 的"响应式数据",数据变了页面会自动更新
onMounted Vue 的生命周期钩子,组件挂载到页面后执行
onBeforeUnmount 组件销毁前执行,用来清理定时器
Swiper, SwiperSlide Swiper 轮播库的两个核心组件
SwiperClass Swiper 的 TypeScript 类型定义
Pagination Swiper 的分页器插件(底部小圆点)
CAROUSEL_INTERVAL 常量,值为 3000,表示图片停留 3 秒

3.2.2 定义数据类型
ts 复制代码
interface MediaItem {
  type: 'video' | 'image'
  url: string
  alt?: string
}

这是 TypeScript 的接口,规定了每条轮播数据必须有的字段:

  • type:只能是 'video''image'
  • url:媒体资源的地址(字符串)
  • alt?:可选的图片描述文字

3.2.3 定义组件接收的参数(props)
ts 复制代码
const props = defineProps({
  mediaData: {
    type: Array as () => MediaItem[] | null,
    default: null
  }
})

defineProps 声明这个组件可以从外部接收什么数据。

这里允许外部(比如通过 WebSocket)传一个新的轮播列表进来替换当前列表。


3.2.4 定义响应式变量
ts 复制代码
const swiperRef = ref<{ $el: { swiper: SwiperClass } } | null>(null)
const mediaList = ref<MediaItem[]>([])
const videoRefs = ref<Record<number, HTMLVideoElement | null>>({})
let imageTimer: ReturnType<typeof setTimeout> | null = null
let isVideoPlaying = false
变量 类型 作用
swiperRef 引用 Swiper 实例 用来调用 swiper.slideNext() 等方法
mediaList 数组 存储所有轮播项(图片+视频)
videoRefs 字典对象 { 0: <video元素>, 1: <video元素> },存每个视频的 DOM 引用
imageTimer 定时器 ID 图片自动切换的计时器
isVideoPlaying 布尔值 开关锁true 表示正在放视频,禁止定时器切换

3.2.5 Swiper 模块配置
ts 复制代码
const modules = [Pagination]

告诉 Swiper 只启用分页器插件(底部小圆点),不启用 autoplay,因为我们要手动控制切换时机。


3.2.6 数据标准化函数
ts 复制代码
function normalizeItem(item: string | MediaItem): MediaItem {
  // 情况1:传入的是纯字符串(URL)
  if (typeof item === 'string') {
    const path = item.split('?')[0].split('#')[0]      // 去掉 URL 参数和锚点
    const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path) // 判断扩展名是否为视频格式
    return { type: isVideo ? 'video' : 'image', url: item }
  }
  // 情况2:传入的是对象但没有 type 字段
  if (!item.type) {
    const path = item.url.split('?')[0].split('#')[0]
    const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
    item.type = isVideo ? 'video' : 'image'
  }
  return item
}

为什么需要这个函数?

因为接口返回的数据可能是以下两种格式:

ts 复制代码
// 格式1:纯 URL 字符串数组
['https://xxx.com/1.jpg', 'https://xxx.com/2.mp4']

// 格式2:对象数组
[{ url: '...', type: 'video' }, { url: '...' }]

normalizeItem 将两种情况统一为 { url, type } 格式。

判断方法很简单:看 URL 的文件后缀 ,以 .mp4/.webm/.ogg/.mov 结尾的就是视频,否则是图片。

split('?')[0] 是为了去掉 URL 后面的查询参数(如 ?x-oss=xxx),防止干扰后缀判断。


3.2.7 存储视频 DOM 引用
ts 复制代码
function setVideoRef(index: number, el: any) {
  if (el) {
    videoRefs.value[index] = el as HTMLVideoElement
  }
}

当 Vue 渲染 <video> 时,会把 video 的 DOM 元素传给这个函数。

videoRefs 最终长这样:

ts 复制代码
{
  0: <video元素>,   // 第 0 个 slide 的视频
  2: <video元素>    // 第 2 个 slide 的视频
}

只存视频(图片没有 :ref),且 index 只在视频项有值。

为什么要存? 因为后面需要手动调用 video.play()video.pause() ------ 必须拿到视频的 DOM 元素才能操作。


3.2.8 图片自动切换定时器
ts 复制代码
function scheduleImageNext() {
  stopImageTimer()                                  // 先清除上一次的定时器
  imageTimer = setTimeout(() => {                   // 设置新的定时器
    if (isVideoPlaying) return                      // 如果正在播放视频,就不切换
    const swiper = swiperRef.value?.$el?.swiper
    if (swiper) {
      swiper.slideNext()                            // 切换到下一个 slide
    }
  }, CAROUSEL_INTERVAL)                             // 3000ms = 3秒
}

function stopImageTimer() {
  if (imageTimer) {
    clearTimeout(imageTimer)                        // 清除定时器
    imageTimer = null
  }
}

执行流程:

复制代码
调用 scheduleImageNext()
  → 清除旧的定时器(防止重复)
  → 启动新定时器
  → 3 秒后:
      → 检查 isVideoPlaying:
          → 如果 true(视频播放中)→ 跳过,不切换
          → 如果 false → 调用 swiper.slideNext() 切换到下一张

为什么要先清除旧的? 防止快速切换时多个定时器同时存在,导致时间混乱。


3.2.9 Slide 切换时的核心调度(最重要的函数)
ts 复制代码
function onSlideChange(swiper: SwiperClass) {
  const currentIndex = swiper.realIndex            // 当前 slide 的索引
  const item = mediaList.value[currentIndex]        // 当前 slide 的数据

  // 第一步:暂停所有正在播放的视频
  Object.values(videoRefs.value).forEach(video => {
    if (video && !video.paused) video.pause()
  })

  // 第二步:根据类型做不同处理
  if (item && item.type === 'video') {
    // ====== 视频分支 ======
    stopImageTimer()                                // 取消图片定时器(视频不需要定时器)
    const videoEl = videoRefs.value[currentIndex]   // 拿到当前视频的 DOM
    if (videoEl) {
      videoEl.currentTime = 0                       // 从头开始播放
      videoEl.play().catch((err: any) => {
        console.warn('[Carousel] 视频自动播放失败:', err.message)
        // 如果播放失败(被浏览器拦截等),5秒后备自动切换
        setTimeout(() => {
          swiper.slideNext()
        }, 5000)
      })
    }
  } else {
    // ====== 图片分支 ======
    isVideoPlaying = false                          // 解除视频播放锁定
    scheduleImageNext()                             // 启动3秒定时器
  }
}

完整逻辑流程图:

复制代码
用户打开页面 / 切换 slide
        │
        ▼
onSlideChange 被触发
        │
        ├── 1. 暂停所有视频(防止上一个视频还在后台播放)
        │
        ├── 2. 判断当前 slide 的类型
        │
        ├── 如果是【视频】:
        │      │
        │      ├── 取消图片定时器(stopImageTimer)
        │      ├── 把视频进度归零(currentTime = 0)
        │      └── 播放视频
        │             ├── 成功 → 等待 @ended 事件
        │             └── 失败 → 5秒后备切换
        │
        └── 如果是【图片】:
               │
               ├── 解除视频锁定(isVideoPlaying = false)
               └── 启动3秒定时器(scheduleImageNext)
                      └── 3秒后 → 检查 isVideoPlaying
                             ├── false → 切换下一张
                             └── true  → 不切换(视频正在播放)

3.2.10 视频播放事件处理
ts 复制代码
function onVideoPlaying() {
  isVideoPlaying = true
}

function onVideoEnded() {
  isVideoPlaying = false
  const swiper = swiperRef.value?.$el?.swiper
  if (swiper) {
    swiper.slideNext()
  }
}

这两个函数由 <video> 标签的 @playing@ended 事件触发。

onVideoPlaying --- 视频开始播放时:

复制代码
视频开始播放
  → 触发 <video> 的 @playing 事件
  → 调用 onVideoPlaying()
  → isVideoPlaying = true(锁住,不让定时器切换)

onVideoEnded --- 视频播放完毕时:

复制代码
视频播放到最后一帧
  → 触发 <video> 的 @ended 事件
  → 调用 onVideoEnded()
  → isVideoPlaying = false(解除锁)
  → swiper.slideNext() 切换到下一张

注意:slideNext() 会再次触发 onSlideChange,从而进入下一个循环(图片或视频)。


3.2.11 加载数据和生命周期
ts 复制代码
async function fetchCarouselData() {
  mediaList.value = ([
    'https://www.dkifly.com/ifly-api/admin-api/infra/file/25/get/20260518/怀远楼l_1779069971962.jpg',
    'https://zkdk.oss-cn-hangzhou.aliyuncs.com/20260528/input1_1779518143479_merged_1779954141584.mp4?xxx',
    'https://www.dkifly.com/ifly-api/admin-api/infra/file/25/get/20260518/石跳桥l_1779069989230.jpg'
  ] as unknown as MediaItem[]).map(normalizeItem)
}

onMounted(() => {
  fetchCarouselData()     // 组件挂载后立即加载数据
})

onBeforeUnmount(() => {
  stopImageTimer()        // 组件销毁前清理定时器,防止内存泄漏
})
  • onMounted:组件第一次出现在页面上时执行,用来初始化数据
  • onBeforeUnmount:组件被销毁(比如用户离开页面)时执行,用来清理资源
  • fetchCarouselData 目前使用的是静态示例数据 ,通过 .map(normalizeItem) 标准化为 { type, url } 格式

标准化后的数据长这样:

ts 复制代码
[
  { type: 'image', url: '...怀远楼l_xxx.jpg' },
  { type: 'video', url: '...merged_xxx.mp4?...' },
  { type: 'image', url: '...石跳桥l_xxx.jpg' }
]

3.3 <style scoped> 样式部分

css 复制代码
.carousel-wrapper {
  width: 100%;
  height: 600px;
  overflow: hidden;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
}

.carousel-inner {
  width: 956px;
  height: 543px;
  border-radius: 4px;
  overflow: hidden;
}

.carousel-swiper {
  width: 100%;
  height: 100%;
}

.carousel-media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

scoped 表示这些样式只对这个组件生效,不会影响页面的其他部分。

布局示意:

复制代码
┌───────────────────────────────────┐  ← .carousel-wrapper (100%宽度, 600px高)
│                                   │     水平垂直居中
│   ┌─────────────────────────┐     │  ← .carousel-inner (956×543, 圆角)
│   │                         │     │
│   │   轮播内容 (图片/视频)    │     │  ← .carousel-media (填满容器)
│   │                         │     │     object-fit: cover 保证内容不变形裁剪填充
│   └─────────────────────────┘     │
└───────────────────────────────────┘

四、完整运行流程(图文示例)

假设数据为:[图片A, 视频B, 图片C]

复制代码
页面加载
  │
  ▼
fetchCarouselData() → 加载 [图片A, 视频B, 图片C]
  │
  ▼
Swiper 展示第 0 张(图片A)
  │
  ▼
onSlideChange 被触发
  ├── 暂停所有视频(无)
  └── 图片分支 → isVideoPlaying = false → scheduleImageNext()
                                                  │
                                                  ▼
                                          3 秒后 → slideNext()
                                                      │
                                                      ▼
                                              ─────────────────
                                              展示第 1 张(视频B)
                                              │
                                              ▼
                                            onSlideChange
                                              ├── 暂停所有视频
                                              └── 视频分支
                                                    ├── stopImageTimer()
                                                    ├── video.play()
                                                    └── @playing → isVideoPlaying = true
                                                    
                                                    视频播放中...
                                                    视频播放中...
                                                    视频播放到最后一帧
                                                          │
                                                          ▼
                                                        @ended → onVideoEnded()
                                                          ├── isVideoPlaying = false
                                                          └── slideNext()
                                                                    │
                                                                    ▼
                                                            ─────────────────
                                                            展示第 2 张(图片C)
                                                            
                                                            onSlideChange
                                                            └── 图片分支 → 3 秒后 slideNext()
                                                                              │
                                                                              ▼
                                                                        回到第 0 张(loop 循环)

五、为什么要这样设计?

为什么不用 Swiper 自带的 autoplay?

Swiper 的 autoplay 只能设置固定间隔(比如每 3 秒切一次),但视频的时长是不固定的:

  • 有的视频 5 秒
  • 有的视频 30 秒
  • 无法提前知道

所以不能用一个固定定时器覆盖所有情况。解决方案是:

类型 切换触发方式
图片 手动 setTimeout 定时器(固定间隔)
视频 原生 @ended 事件触发(时长由视频决定)

为什么需要 isVideoPlaying 锁?

在极少数情况下,可能出现这样的时序问题

复制代码
第 0 秒:切换到视频,启动播放
第 2 秒:一个旧的图片定时器刚好到时间了(因为 setTimeout 是异步的)
         → 如果没有锁,它会直接切换,打断视频播放

isVideoPlaying 这面"锁"就是用来挡这种意外情况的。当它 true 时,任何定时器的回调都会提前 return,不会执行切换。


六、常⻅问题

Q: 为什么不直接让用户手动滑动?

A: 本项目是一个公共展示屏/广告机场景,轮播是自动播放的,不需要交互。

Q: 视频为什么静音(muted)?

A: 浏览器(尤其是 Chrome)规定,不允许自动播放有声视频,必须静音才能自动播放。

Q: setTimeout 时间准吗?

A: 不太准(JavaScript 是单线程),但对 3 秒切换来说,几十毫秒的误差不影响体验。

Q: 视频播放失败怎么办?

A: 在 video.play().catch() 中做了 5 秒后备切换,不会卡死。


七、源代码

javascript 复制代码
<template>
  <!-- 顶部轮播区域 -->
  <div class="carousel-wrapper">
    <div class="carousel-inner">
      <Swiper
        ref="swiperRef"
        :modules="modules"
        :loop="true"
        :allow-touch-move="false"
        @slide-change="onSlideChange"
        class="carousel-swiper"
      >
        <SwiperSlide v-for="(item, index) in mediaList" :key="index">
          <!-- 视频 -->
          <video
            v-if="item.type === 'video'"
            :src="item.url"
            :ref="el => setVideoRef(index, el)"
            muted
            playsinline
            preload="auto"
            class="carousel-media"
            @playing="onVideoPlaying"
            @ended="onVideoEnded"
          />
          <!-- 图片 -->
          <img
            v-else
            :src="item.url"
            :alt="item.alt || '轮播图片'"
            class="carousel-media"
          />
        </SwiperSlide>
      </Swiper>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import type { Swiper as SwiperClass } from 'swiper/types'
import { Pagination } from 'swiper/modules'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css/pagination'

import request from '../utils/request'
import { API, CAROUSEL_INTERVAL } from '../utils/constants'

interface MediaItem {
  type: 'video' | 'image'
  url: string
  alt?: string
}

const props = defineProps({
  // 允许外部通过 WebSocket 推送更新轮播列表
  mediaData: {
    type: Array as () => MediaItem[] | null,
    default: null
  }
})

const swiperRef = ref<{ $el: { swiper: SwiperClass } } | null>(null)
const mediaList = ref<MediaItem[]>([])
const videoRefs = ref<Record<number, HTMLVideoElement | null>>({})
let imageTimer: ReturnType<typeof setTimeout> | null = null
let isVideoPlaying = false

// Swiper 模块(不启用 autoplay,全部手动控制切换)
const modules = [Pagination]

/** 将 URL 字符串标准化为 { type, url } 对象 */
function normalizeItem(item: string | MediaItem): MediaItem {
  if (typeof item === 'string') {
    const path = item.split('?')[0].split('#')[0]
    const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
    return { type: isVideo ? 'video' : 'image', url: item }
  }
  // 已经是对象格式,补充缺省 type
  if (!item.type) {
    const path = item.url.split('?')[0].split('#')[0]
    const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
    item.type = isVideo ? 'video' : 'image'
  }
  return item
}

/** 存储视频 DOM 引用 */
function setVideoRef(index: number, el: any) {
  if (el) {
    videoRefs.value[index] = el as HTMLVideoElement
  }
}

/** 图片自动轮播:延迟后切到下一张(视频播放中不切换) */
function scheduleImageNext() {
  stopImageTimer()
  imageTimer = setTimeout(() => {
    if (isVideoPlaying) return
    const swiper = swiperRef.value?.$el?.swiper
    if (swiper) {
      swiper.slideNext()
    }
  }, CAROUSEL_INTERVAL)
}

function stopImageTimer() {
  if (imageTimer) {
    clearTimeout(imageTimer)
    imageTimer = null
  }
}

/** Slide 切换时的处理 */
function onSlideChange(swiper: SwiperClass) {
  const currentIndex = swiper.realIndex
  const item = mediaList.value[currentIndex]

  // 暂停所有视频
  Object.values(videoRefs.value).forEach(video => {
    if (video && !video.paused) video.pause()
  })

  if (item && item.type === 'video') {
    // 视频:取消图片定时器,播放视频
    stopImageTimer()
    const videoEl = videoRefs.value[currentIndex]
    if (videoEl) {
      videoEl.currentTime = 0
      videoEl.play().catch((err: any) => {
        console.warn('[Carousel] 视频自动播放失败:', err.message)
        // 视频播放失败则 5 秒后自动切到下一张
        setTimeout(() => {
          swiper.slideNext()
        }, 5000)
      })
    }
  } else {
    // 图片:解除视频锁定,启动计时器
    isVideoPlaying = false
    scheduleImageNext()
  }
}

/** 视频开始播放,锁定禁止自动切换 */
function onVideoPlaying() {
  isVideoPlaying = true
}

/** 视频播放结束,解除锁定并切换到下一个 */
function onVideoEnded() {
  isVideoPlaying = false
  const swiper = swiperRef.value?.$el?.swiper
  if (swiper) {
    swiper.slideNext()
  }
}

/** 从接口加载轮播数据 */
async function fetchCarouselData() {
  // 默认示例数据
  mediaList.value = ([
    'https://62.jpg',
    'https://V.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260529T012706Z&X-Amz-SignedHeaders=host&X-Amz-Credential=LTAI5t94A2wGtZdZZx4n9BZD%2F20260529%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Expires=86400&X-Amz-Signature=df43e28b08df7a53ec32801bc4911078584d660fd588720ec4734298b13440f4',
    'https://30.jpg'
  ] as unknown as MediaItem[]).map(normalizeItem)
  // try {
  //   const res = await request.get<{ data: MediaItem[] }>({ url: API.CAROUSEL_LIST })
  //   if (res && Array.isArray(res.data)) {
  //     mediaList.value = res.data.map(normalizeItem)
  //   }
  // } catch (err: any) {
  //   console.warn('[Carousel] 接口加载失败,使用默认数据:', err.message)
  // }
}

/** 监听外部传入的媒体数据(WebSocket 更新) */
function updateMediaList(data: MediaItem[]) {
  if (Array.isArray(data)) {
    mediaList.value = data.map(normalizeItem)
  }
}

onMounted(() => {
  fetchCarouselData()
})

onBeforeUnmount(() => {
  stopImageTimer()
})

// 对外暴露更新方法
defineExpose({ updateMediaList })
</script>

<style scoped>
.carousel-wrapper {
  width: 100%;
  height: 600px;
  overflow: hidden;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
}

.carousel-inner {
  width: 956px;
  height: 543px;
  border-radius: 4px;
  overflow: hidden;
}

.carousel-swiper {
  width: 100%;
  height: 100%;
}

.carousel-media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
</style>

组件封装+优化

js 复制代码
<template>
  <!-- 顶部轮播区域 -->
  <div class="carousel-wrapper">
    <div class="carousel-inner">
      <Swiper
        ref="swiperRef"
        :modules="modules"
        :loop="true"
        :allow-touch-move="false"
        :autoplay="{ delay: CAROUSEL_INTERVAL, disableOnInteraction: false, pauseOnMouseEnter: false }"
        @slide-change="onSlideChange"
        class="carousel-swiper"
      >
        <SwiperSlide v-for="(item, index) in mediaList" :key="index">
          <!-- 视频 -->
          <video
            v-if="item.type === 'video'"
            :src="item.url"
            :ref="el => setVideoRef(index, el)"
            muted
            playsinline
            preload="auto"
            class="carousel-media"
            @playing="onVideoPlaying"
            @ended="onVideoEnded"
          />
          <!-- 图片 -->
          <img
            v-else
            :src="item.url"
            :alt="item.alt || '轮播图片'"
            class="carousel-media"
          />
        </SwiperSlide>
      </Swiper>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import type { Swiper as SwiperClass } from 'swiper/types'
import { Autoplay, Pagination } from 'swiper/modules'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css/pagination'

import { CAROUSEL_INTERVAL } from '../utils/constants'
import type { MediaItem } from '../types/media'

const props = defineProps({
  // 由父组件传入的轮播列表数据
  mediaData: {
    type: Array as () => (string | MediaItem)[] | null,
    default: null
  }
})

const swiperRef = ref<{ $el: { swiper: SwiperClass } } | null>(null)
const mediaList = ref<MediaItem[]>([])
const videoRefs = ref<Record<number, HTMLVideoElement | null>>({})
let isVideoPlaying = false

// Autoplay 模块驱动图片轮播,视频播放时暂停 autoplay
const modules = [Autoplay, Pagination]

/** 将 URL 字符串标准化为 { type, url } 对象 */
function normalizeItem(item: string | MediaItem): MediaItem {
  if (typeof item === 'string') {
    const path = item.split('?')[0].split('#')[0]
    const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
    return { type: isVideo ? 'video' : 'image', url: item }
  }
  if (!item.type) {
    const path = item.url.split('?')[0].split('#')[0]
    const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
    item.type = isVideo ? 'video' : 'image'
  }
  return item
}

/** 父组件数据变化时自动同步 */
watch(() => props.mediaData, (val) => {
  if (Array.isArray(val)) {
    mediaList.value = val.map(normalizeItem)
  }
}, { immediate: true })

/** 获取 Swiper 实例 */
function getSwiper(): SwiperClass | null {
  return swiperRef.value?.$el?.swiper ?? null
}

/** 启停 autoplay */
function stopAutoplay() {
  getSwiper()?.autoplay?.stop()
}

function startAutoplay() {
  getSwiper()?.autoplay?.start()
}

/** 存储视频 DOM 引用 */
function setVideoRef(index: number, el: any) {
  if (el) {
    videoRefs.value[index] = el as HTMLVideoElement
  }
}

/** Slide 切换时的处理 */
function onSlideChange(swiper: SwiperClass) {
  const currentIndex = swiper.realIndex
  const item = mediaList.value[currentIndex]

  // 暂停所有正在播放的视频
  Object.values(videoRefs.value).forEach(video => {
    if (video && !video.paused) video.pause()
  })

  if (item && item.type === 'video') {
    stopAutoplay()
    isVideoPlaying = true
    const videoEl = videoRefs.value[currentIndex]
    if (videoEl) {
      videoEl.currentTime = 0
      videoEl.play().catch(() => {
        // 视频播放失败,恢复 autoplay 继续轮播
        isVideoPlaying = false
        startAutoplay()
      })
    } else {
      isVideoPlaying = false
      startAutoplay()
    }
  } else {
    isVideoPlaying = false
    startAutoplay()
  }
}

function onVideoPlaying() {
  // autoplay 已在 onSlideChange 中停止
}

function onVideoEnded() {
  isVideoPlaying = false
  const swiper = getSwiper()
  if (swiper) {
    swiper.slideNext()
  }
  startAutoplay()
}

/** 外部(WebSocket)推送更新 */
function updateMediaList(data: (string | MediaItem)[]) {
  if (Array.isArray(data)) {
    mediaList.value = data.map(normalizeItem)
  }
}

onBeforeUnmount(() => {
  stopAutoplay()
})

defineExpose({ updateMediaList })
</script>

<style scoped>
.carousel-wrapper {
  width: 100%;
  height: 600px;
  overflow: hidden;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
}

.carousel-inner {
  width: 956px;
  height: 543px;
  border-radius: 4px;
  overflow: hidden;
}

.carousel-swiper {
  width: 100%;
  height: 100%;
}

.carousel-media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
</style>
相关推荐
oort1231 小时前
VLStream 全开源决策式 AI 视频平台 技术视角完整说明
大数据·开发语言·人工智能·经验分享·python·开源·音视频
木斯佳14 小时前
鸿蒙开发入门指南:前端开发者快速理解视频编码概念——输入模式
华为·音视频·harmonyos
EasyDSS19 小时前
私有化音视频系统/视频直播点播/高清点播/云点播/云直播EasyDSS优化升级重塑智慧文旅直播运营新体系
音视频
CV实验室20 小时前
Remote Sensing 29个SITS基准数据集综述:多模态遥感分类的新起点
人工智能·深度学习·计算机视觉·音视频
EasyDSS1 天前
安全可控、全场景适配:私有化音视频系统/视频直播点播EasyDSS一站式云平台重构视频协作新模式
安全·重构·音视频
superantwmhsxx1 天前
Seedance 2.0 初探:从文生视频到可控创作的 AI 视频工作流
人工智能·计算机视觉·音视频
EasyDSS1 天前
私有化视频会议系统/企业级融媒体平台EasyDSS优化升级打造轻量化高效视频协作场景
网络·音视频·媒体
searchforAI1 天前
网盘视频转文字后,如何高效做笔记并长期归档?
人工智能·笔记·学习·ai·音视频·语音识别·网盘
兆。1 天前
LangChain语音音频集成指南:面向多媒体开发者
langchain·音视频