uni-app APP端视频播放解决方案

uni-app 在 APP-PLUS(即 App 端)的视频播放,本质是原生组件方案——底层用 ijkplayer 库实现,而不是 H5 的<video>标签。所以”怎么播”只是入口,”怎么解决原生组件层级 / 全屏横屏 / 直播流”才是真正的工程难点。下面按三种典型场景给出可落地的方案。
方案一:常规点播 —— 直接用 <video> 组件
这是 90% 场景的首选。App 端<video>是原生组件,打包时必须勾选 manifest.json -> App 模块权限配置 -> VideoPlayer 模块(该模块体积较大,非默认内置)。
<template>
<video
id="myVideo"
:src="videoSrc"
controls
:autoplay="false"
@error="onVideoError"
@timeupdate="onTimeUpdate">
</video>
</template>
<script>
export default {
data() {
return { videoSrc: 'https://example.com/video.mp4' }
},
onReady() {
this.videoCtx = uni.createVideoContext('myVideo', this)
},
methods: {
play() { this.videoCtx.play() },
pause() { this.videoCtx.pause() },
seek(t) { this.videoCtx.seek(t) }, // 跳转,单位秒
setRate(r) { this.videoCtx.playbackRate(r) } // 倍速:0.5/0.8/1.0/1.25/1.5
}
}
</script>
关键约束:
- App 端 <video> 使用 ijkplayer 库实现,比腾讯视频等商业 SDK 仍有差距,无法满足需求时可考虑插件市场的商业 SDK;
- 如果想使用非原生的 video(即普通的 HTML5 自带 video),App 端有 2 种方案,具体参考官方文档;
- 如果使用的视频路径为本地路径,需要配置资源为释放模式:在 manifest.json 文件内 app-plus 节点下新增 runmode 配置,设置值为 liberate。
注意:3.6.14 及以上 + iOS 16 以上全屏:必须在 manifest.json 的 app-plus 节点下新增 screenOrientation 配置:
"screenOrientation": ["portrait-primary","portrait-secondary","landscape-primary","landscape-secondary"]
方案二:自定义 UI / 解决层级遮挡 —— cover-view 或 subNVue
非 H5 端<video>是原生组件,层级高于普通前端组件。想在上面盖自定义控件(倍速按钮、弹幕、礼物动画),普通<view>会被压住,必须用以下手段:
| 方案 | 适用场景 | 要点 |
|---|---|---|
| cover-view | 简单覆盖(按钮、文字) | 只能嵌套 cover-view/cover-image,CSS 支持有限 |
| subNVue | 复杂覆盖(自定义控制栏、弹幕层) | 原生渲染,可做任意 UI,通过 uni.$emit/on 通信 |
| plus.nativeObj.view | 5+ App 模式 | 更低层,性能最好但开发成本高 |
CSDN 上较成熟的实践是做一个统一接口层 NativeVideoWrapper.vue,内部按平台分支:
业务层 → 统一接口层 → 平台适配层 ├── H5 → HTML5 Video ├── 微信小程序 → createVideoContext └── App → plus.video.createVideoPlayer
这样业务代码不感知平台差异,扩展新平台只需加一个适配层。
方案三:直播流 / 强制横屏 / 系统级播放 —— plus.video 原生播放器
当<video>组件满足不了需求(直播流、需要完全接管全屏、要调系统播放器),直接调 5+ Engine 的 plus.video:
// 仅 APP-PLUS 环境
// #ifdef APP-PLUS
const player = plus.video.createVideoPlayer('videoPlayer', {
src: 'https://example.com/live.m3u8',
top: '0px',
left: '0px',
width: '100%',
height: '100%',
fullscreen: true
})
player.play()
player.setFullscreen(true) // 自动旋转屏幕
// #endif
plus.video原生播放器支持的协议比 <video> 组件宽:mp4、flv 格式;http/https、rtmp、hls、rtsp 直播流均支持。
强制横屏播放的完整配置:
// manifest.json -> app-plus
{
"orientation": ["landscape-primary"],
"screenOrientation": ["landscape-primary"]
}
并在 pages.json 中对视频页单独设置:
{
"path": "pages/video-player",
"style": {
"app-plus": {
"hardwareAccelerated": true,
"orientation": "landscape"
}
}
}
调用videoContext.requestFullScreen()失败的常见原因:iOS 要求禁用原生控件(controls=”false”)、视频源必须是 HTTPS、未开启横屏配置。
三个高频踩坑点
1. Android 小黑块,iOS 正常
几乎都是编码问题。用 FFmpeg 转成 H.264 + MP4:
ffmpeg -i input.mp4 -c:v libx264 -c:a aac output.mp4
避免 H.265、AV1、WebM 等 Android 兼容性差的格式。若仍黑块,在 pages.json 该页面 app-plus 下设置"hardwareAccelerated": false。
2. 进度条拖动卡死 / timeupdate 不触发
uni-app 的<video>在非 H5 端不支持 HLS(.m3u8)协议,也不支持 MSE。服务端务必开启Accept-Ranges: bytes响应头,否则拖动只能从头加载。
3. content:// 路径播不了
Android 上 uni.chooseMedia 返回的是 content:// 协议,必须转换:
let path = res.tempFiles[0].tempFilePath
if (path.startsWith('content://')) {
path = plus.io.convertLocalFileSystemURL(path) // → file://...
}
this.videoSrc = path
APP-PLUS 端做”高仿抖音竖滑”,唯一能扛住流畅度要求的方案是 nvue 页面 + 垂直 swiper + 原生 video。官方文档明确:微信基础库 2.4.0+ 和 App 端 nvue 2.1.5+,才可以通过在垂直的 swiper 中内嵌 video 来实现;非 H5 端<video>是原生组件,普通<view>盖不住,必须用 cover-view 绘制 UI 层。
下面是一套可直接落地的实现。
一、架构选型:为什么必须用 nvue
| 方案 | App 端表现 | 结论 |
|---|---|---|
| vue 页面 + swiper + video | 视频层级最高,swiper 滑动时画面卡在上一个视频不动,声音切了画面没切 | ❌ 不可用 |
| nvue 页面 + swiper + video | 真原生渲染,video 支持 gestureConfig、direction,touch 事件可被完整捕获 | ✅ 唯一解 |
| 插件市场商业 SDK | 功能全但体积大、收费 | 💰 预算充足可选 |
如果你的项目同时要兼容 H5 和小程序,建议路由级分平台:App 走 nvue 页面,H5/小程序走 vue 页面。同路由下同时放同名 .vue 和 .nvue,HBuilderX 会自动按平台选择。
二、核心页面结构(nvue)
<!-- pages/feed/feed.nvue -->
<template>
<swiper
class="swiper"
:vertical="true"
:current="currentIndex"
:duration="250"
@change="onSwiperChange"
@animationfinish="onAnimationFinish">
<swiper-item
v-for="(item, index) in visibleList"
:key="item.id"
class="swiper-item">
<!-- 只渲染当前项前后各 1 个 video,其余不挂 video -->
<video
v-if="isActive(index)"
:ref="'video_' + item.id"
class="video"
:src="item.url"
:autoplay="index === currentIndex"
:loop="true"
:muted="index === 0 ? true : false"
show-center-play-btn="false"
show-fullscreen-btn="false"
show-progress="false"
object-fit="cover"
@play="onPlay"
@pause="onPause"
@ended="onEnded(index)"
@loadedmetadata="onLoadedMeta(index)">
</video>
<!-- 封面兜底:video 未就绪时显示 -->
<image
v-if="!item.ready"
:src="item.cover"
class="cover"
mode="aspectFill">
</image>
<!-- UI 层:必须用 cover-view,普通 view 盖不住原生 video -->
<cover-view class="ui-layer">
<!-- 右侧操作栏 -->
<cover-view class="side-bar">
<cover-view class="side-btn" @click="onLike(item)">
<cover-image class="side-icon" :src="item.liked ? likeActive : likeNormal"></cover-image>
<cover-view class="side-text">{{ item.likes }}</cover-view>
</cover-view>
<cover-view class="side-btn" @click="onComment(item)">
<cover-image class="side-icon" :src="commentIcon"></cover-image>
<cover-view class="side-text">{{ item.comments }}</cover-view>
</cover-view>
<cover-view class="side-btn" @click="onShare(item)">
<cover-image class="side-icon" :src="shareIcon"></cover-image>
<cover-view class="side-text">分享</cover-view>
</cover-view>
</cover-view>
<!-- 底部信息 -->
<cover-view class="bottom-info">
<cover-view class="author">@{{ item.author }}</cover-view>
<cover-view class="title">{{ item.title }}</cover-view>
</cover-view>
<!-- 双击点赞动画锚点 -->
<cover-view
class="double-tap-area"
@click="onSingleTap(index)"
@doubleclick="onDoubleTap(item, $event)">
</cover-view>
</cover-view>
</swiper-item>
</swiper>
</template>
三、关键 JS 逻辑
<script>
export default {
data() {
return {
currentIndex: 0,
videoList: [], // 完整列表
visibleList: [], // 当前渲染的窗口(current-1 ~ current+1)
likeNormal: '/static/icon-like.png',
likeActive: '/static/icon-like-active.png',
commentIcon: '/static/icon-comment.png',
shareIcon: '/static/icon-share.png'
}
},
onLoad(query) {
this.loadVideoList(query.feedId)
},
onReady() {
// 首屏:静音自动播放,规避 iOS 自动播放策略
this.playCurrent(true)
},
methods: {
// === 列表与窗口管理 ===
loadVideoList(feedId) {
// 从接口拉取,这里用假数据示意
this.videoList = [
{ id: 1, url: 'https://xxx/video1.mp4', cover: '...', author: '张三', title: '第一条视频', likes: 1200, comments: 88, liked: false, ready: false },
{ id: 2, url: 'https://xxx/video2.mp4', cover: '...', author: '李四', title: '第二条视频', likes: 3200, comments: 156, liked: false, ready: false },
// ...更多
]
this.refreshVisible()
},
// 只保留 current-1, current, current+1 三个 video 实例
refreshVisible() {
const start = Math.max(0, this.currentIndex - 1)
const end = Math.min(this.videoList.length - 1, this.currentIndex + 1)
this.visibleList = this.videoList.slice(start, end + 1)
},
isActive(index) {
const realIndex = this.currentIndex - 1 + index
return Math.abs(realIndex - this.currentIndex) <= 1
},
// === 滑动切换 ===
onSwiperChange(e) {
const next = e.detail.current
if (next === this.currentIndex) return
// 1. 暂停旧的
this.pauseAt(this.currentIndex)
// 2. 更新索引
this.currentIndex = next
// 3. 刷新渲染窗口
this.refreshVisible()
// 4. 播放新的
this.$nextTick(() => {
this.playCurrent(this.currentIndex === 0)
})
},
onAnimationFinish(e) {
// 滑动动画结束后,清理非相邻 video 资源
this.releaseDistantVideos()
},
playCurrent(muted = false) {
const item = this.videoList[this.currentIndex]
if (!item) return
const ctx = uni.createVideoContext('video_' + item.id, this)
ctx.play()
if (!muted) {
// 首屏之后取消静音
setTimeout(() => ctx.mute(false), 100)
}
},
pauseAt(index) {
const item = this.videoList[index]
if (!item) return
const ctx = uni.createVideoContext('video_' + item.id, this)
ctx.pause()
},
releaseDistantVideos() {
// 滑出视口 500ms 后释放:pause + src 置空 + load
setTimeout(() => {
this.videoList.forEach((item, idx) => {
if (Math.abs(idx - this.currentIndex) > 1) {
const ctx = uni.createVideoContext('video_' + item.id, this)
ctx.pause()
item._released = true
}
})
}, 500)
},
// === video 事件 ===
onLoadedMeta(index) {
this.videoList[index].ready = true
},
onEnded(index) {
// 抖音式循环播放,不自动跳下一个
const item = this.videoList[index]
const ctx = uni.createVideoContext('video_' + item.id, this)
ctx.seek(0)
ctx.play()
},
// === 交互 ===
onLike(item) {
item.liked = !item.liked
item.likes += item.liked ? 1 : -1
},
onDoubleTap(item, e) {
if (!item.liked) {
item.liked = true
item.likes++
}
// 这里可以加个爱心飘出的动画 cover-view
},
onSingleTap(index) {
// 单击:播放/暂停切换
const item = this.videoList[index]
const ctx = uni.createVideoContext('video_' + item.id, this)
// 用 playing 状态判断,这里简化
ctx.pause()
}
}
}
</script>
四、样式要点(nvue 用 flex 布局)
<style scoped>
.swiper { flex: 1; width: 750rpx; }
.swiper-item { width: 750rpx; height: 1334rpx; position: relative; }
.video { width: 750rpx; height: 1334rpx; }
.cover { position: absolute; top: 0; left: 0; width: 750rpx; height: 1334rpx; }
.ui-layer {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
}
.double-tap-area {
position: absolute; top: 0; left: 0; right: 0; bottom: 200rpx;
}
.side-bar {
position: absolute; right: 24rpx; bottom: 240rpx;
flex-direction: column; align-items: center;
}
.side-btn { flex-direction: column; align-items: center; margin-bottom: 40rpx; }
.side-icon { width: 80rpx; height: 80rpx; }
.side-text { color: #fff; font-size: 24rpx; margin-top: 8rpx; }
.bottom-info {
position: absolute; left: 24rpx; right: 160rpx; bottom: 60rpx;
}
.author { color: #ffd700; font-size: 32rpx; font-weight: bold; }
.title { color: #fff; font-size: 28rpx; margin-top: 12rpx; }
</style>
五、manifest.json 必配项
{
"app-plus": {
"modules": {
"VideoPlayer": {}
},
"nvue": {
"compiler": "uni-app"
},
"screenOrientation": ["portrait-primary"],
"launchwebview": {
"renderType": "always"
}
}
}
nvue 页面必须配置沉浸式导航栏(在 pages.json 中):
{
"path": "pages/feed/feed",
"style": {
"app-plus": {
"navigationBarTitleText": "",
"navigationStyle": "custom",
"backgroundColor": "#000000"
}
}
}
六、五个高频踩坑点
1. swiper 嵌套 video 后画面不跟随滑动
这是 vue 页面的经典问题——视频是独立原生图层,swiper 切换时画面层不跟着动。必须用 nvue 页面,真原生渲染才没有这个问题。
2. iOS 首屏黑屏不自动播
浏览器策略要求自动播放必须静音。首屏 video 必须:muted="true" + :autoplay="true",等用户有过交互或其他视频再取消静音。
3. 内存爆炸
一次性渲染 100 个 video,App 端内存直接飙到 500MB+。只保留 current-1, current, current+1 三个实例,滑出视口 500ms 后 pause() + 清空 src + load() 释放解码资源。
4. cover-view 的限制
- App 端 nvue 的 cover-view 无法嵌套、无法内部滚动、无法覆盖到视频全屏界面
- 所有 UI 元素必须是 cover-view / cover-image 的直接子节点
- 复杂动画(如飘心)建议用 subNVue 辅助
5. 双击/单击手势冲突
cover-view 支持@click和@doubleclick,但单击会有 300ms 延迟(等双击判定)。抖音式的”双击点赞 + 单击暂停”需要自己在 touchstart/touchend 里算时间间隔,或者用 @doubleclick + 延时单击方案。
七、性能优化清单
object-fit="cover"让视频填满屏幕;preload="auto"(nvue 端),让系统自动管理缓冲;- 视频编码统一 H.264 + AAC,避免 Android 软解;
- 封面图先加载,
video loadedmetadata后再隐藏封面; - 列表滚动用
v-if控制 video 挂载,不要靠 display; - 双击点赞动画用 subNVue 承载,避免 cover-view 重排。
总结 方案选型决策树
- 普通点播 + 默认控件 → 方案一,
<video> + uni.createVideoContext - 需要自定义控制栏 / 倍速 / 弹幕 → 方案一 + cover-view 或 subNVue
- 直播流(RTMP/HLS/RTSP)/ 强制横屏 / 系统播放器 → 方案三,
plus.video.createVideoPlayer - 商业级需求(DRM、清晰度切换、广告) → 插件市场商业 SDK
以上关于uni-app APP端视频播放解决方案的文章就介绍到这了,更多相关内容请搜索码云笔记以前的文章或继续浏览下面的相关文章,希望大家以后多多支持码云笔记。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 admin@mybj123.com 进行投诉反馈,一经查实,立即处理!
重要:如软件存在付费、会员、充值等,均属软件开发者或所属公司行为,与本站无关,网友需自行判断
码云笔记 » uni-app APP端视频播放解决方案
微信
支付宝