微信小程序实现拍摄和画中画小窗视频和背景音乐播放和关闭
第一版
可以点击录制再次点击停止 也可以长按开始,松手结束
首页
pages/index/index.wxml
js
<view class="container">
<!-- 相机预览 -->
<camera class="camera-preview" device-position="front" flash="off">
</camera>
<!-- GIF 层 -->
<!-- 悬浮视频窗口 - 与 gif-container 同级 -->
<movable-area class="movable-area" style="height: {{phoneHeight}}px; width: {{phoneWidth}}px">
<movable-view class="pip-video" x="{{pipX}}" y="{{pipY}}" direction="all">
<view class="videomask"></view>
<video src="{{videoUrl}}" autoplay loop muted class="video-element"></video>
</movable-view>
</movable-area>
<!-- 录制区域 -->
<view class="record-area">
<view class="progress-container">
<canvas canvas-id="progressCanvas" class="progress-canvas {{maskLoadingFlag?'hidden':''}}"></canvas>
<view class="record-button" bindtap="onTapRecord">
<view class="btn-text">{{isRecording?'完成拍摄':'开始拍摄'}}</view>
</view>
</view>
<view class="hint-text">点击开始/停止,最少10秒最多30秒</view>
</view>
<!-- 特效包框 -->
<image class="gif-image" src="https://gitee.com/xiaojisengren/tuchuang/raw/master/test/yzw.gif" bindload="onGifLoad" binderror="onGifError"></image>
<!-- 跳转到logs页面前的loading图片 -->
<view class="mask" wx:if="{{maskLoadingFlag}}">
<image class="maskImage" src="https://gitee.com/xiaojisengren/tuchuang/raw/master/test/loading.gif" mode="widthFix" />
</view>
</view>pages/index/index.wxss
js
/* pages/test/test.wxss */
page {
height: 100vh;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* GIF动图容器 - 使用cover-view使其能被相机捕捉*/
.gif-image{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 相机预览 */
.camera-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
/* 悬浮视频窗口 */
.movable-area {
position: absolute;
top: 0;
left: 0;
z-index: 99;
}
.pip-video {
width: 240rpx;
height: 300rpx;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.4);
border: 4rpx solid rgba(255, 255, 255, 0.9);
background: #000;
position: relative;
}
.videomask{
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1;
}
.video-element {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 录制区域 */
.record-area {
position: absolute;
bottom: 60rpx;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
z-index: 200;
}
.record-movable{
position: absolute;
width: 100%;
height: 100%;
background-color: #0000ff80;
top: 0;
left: 0;
z-index: 300;
pointer-events: none;
}
.progress-container {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 24rpx;
}
.progress-canvas {
width: 200rpx;
height: 200rpx;
z-index: 1;
}
.record-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 130rpx;
height: 130rpx;
border-radius: 50%;
background: #ff4757;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 9rpx 24rpx rgba(255, 71, 87, 0.4);
transition: all 0.15s ease;
user-select: none;
z-index: 2;
}
/* .record-button:active {
transform: translate(-50%, -50%) scale(0.95);
} */
.btn-text {
color: #fff;
font-size: 32rpx;
font-weight: 500;
}
.record-area .hint-text {
color: rgba(255, 255, 255, 0.8);
font-size: 28rpx;
margin-top: 28rpx;
}
/* 新增隐藏类 */
.hidden {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
.mask{
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background-color: rgba(0,0,0,.7);
display: flex;
justify-content: center;
align-items: center;
}
.maskImage{
width: 80vw;
}pages/index/index.js
js
Page({
data: {
// 画中画位置
pipX: 0,
pipY: 0,
pipWidth: 120, // 画中画宽度
pipHeight: 150, // 画中画高度
// 设备信息
phoneWidth: 0,
phoneHeight: 0,
safeArea: {
top: 0,
bottom: 0
},
// 录制相关
isRecording: false,
progress: 0,
minDuration: 10000, // 最短10秒(10000毫秒)
maxDuration: 30000, // 最长30秒(30000毫秒)
recordTimer: null,
recordStartTime: 0,
innerAudioContext: null, // 改为使用 InnerAudioContext
provisionalKey: null, // 密钥信息
// 视频
videoUrl: 'https://www.yilibabyclub.com/BigPurchase/SLYZ/video/yg1.mp4',
cameraContext: null,
canvasSize: 0,
center: 0,
radius: 0, // 留边距
lineWidth: 0,
videoPostResult: {}, // 上传结果
uploading: false, // 上传loading
uploadprogress: 0, // 上传进度
maskLoadingFlag: false,
},
onLoad: async function (options) {
// 计算画布尺寸
const systemInfo = wx.getSystemInfoSync()
const pxRatio = systemInfo.windowWidth / 750
const size = 200 * pxRatio // 200rpx → px
const ten = 20 * pxRatio
this.setData({
canvasSize: size,
center: size / 2,
radius: size / 2 - ten, // 留边距
lineWidth: 10 * pxRatio
})
this.getPhoneInfo()
// 检查权限
const quanxian = await this.checkCameraAndRecordAuth()
if (!quanxian) {
wx.showModal({
title: '需要权限',
content: '请开启摄像头和录音权限',
success: (res) => {
if (res.confirm) wx.openSetting()
}
})
return
}
this.cameraContext = wx.createCameraContext()
// 创建 InnerAudioContext
const innerAudioContext = wx.createInnerAudioContext()
this.setData({
innerAudioContext
})
},
// 播放音乐
playMusic() {
const {
innerAudioContext
} = this.data
if (!innerAudioContext) return
innerAudioContext.obeyMuteSwitch = false
// 监听播放结束,手动重新播放
innerAudioContext.onEnded(() => {
innerAudioContext.src = 'https://www.yilibabyclub.com/Attachment_Islion_New/BigPurchase/xiaoji/chengzhangying/youzixiaoyang/music/yzxycjwjps.mp3?now=' + Date.now()
console.log('背景音乐播放结束,重新开始播放')
innerAudioContext.play()
})
// 设置音频并播放
innerAudioContext.src = 'https://www.yilibabyclub.com/Attachment_Islion_New/BigPurchase/xiaoji/chengzhangying/youzixiaoyang/music/yzxycjwjps.mp3?now=' + Date.now()
innerAudioContext.play()
console.log('开始播放背景音乐')
},
// 暂停音乐
pauseMusic() {
const {
innerAudioContext
} = this.data
if (!innerAudioContext) return
innerAudioContext.pause()
console.log('音乐已暂停')
},
// 停止音乐
stopMusic() {
const {
innerAudioContext
} = this.data
if (!innerAudioContext) return
innerAudioContext.stop()
console.log('音乐已停止')
},
// 获取设备信息
getPhoneInfo() {
wx.getSystemInfo({
success: (res) => {
const windowWidth = res.windowWidth
const pxRatio = windowWidth / 750 // rpx转换比例
const minBottomPx = 350 * pxRatio // 200rpx转换为px
const windowHeight = res.windowHeight - minBottomPx
const safeArea = res.safeArea || {
top: 0,
bottom: 0
}
// 初始位置:右上角(距离右侧20rpx,距离安全区顶部60rpx)
const initX = windowWidth - this.data.pipWidth - 20
const initY = safeArea.top + 60
this.setData({
phoneWidth: windowWidth,
phoneHeight: windowHeight,
safeArea: safeArea,
pipX: initX,
pipY: initY
})
// 初始化进度条
this.initProgressRing()
}
})
},
// 画中画移动事件
onPipMove(e) {
// 可以在这里实时获取位置
// console.log('位置:', e.detail.x, e.detail.y)
},
// 画中画拖动结束(自动吸附)
onPipEnd(e) {
const {
phoneWidth,
pipWidth,
safeArea
} = this.data
const currentX = e.detail.x
const currentY = e.detail.y
// 计算屏幕中心点
const screenCenter = phoneWidth / 2
const pipCenter = currentX + pipWidth / 2
let finalX = currentX
let finalY = currentY
// 自动吸附到左右边缘
if (pipCenter < screenCenter) {
// 吸附到左侧
finalX = 20
} else {
// 吸附到右侧
finalX = phoneWidth - pipWidth - 20
}
// 纵向边界限制
const minY = safeArea.top + 20
const maxY = this.data.phoneHeight - 200 // 留出底部空间
finalY = Math.max(minY, Math.min(currentY, maxY))
// 使用动画更新位置
this.setData({
pipX: finalX,
pipY: finalY
})
// 震动反馈
wx.vibrateShort({
type: 'light'
})
},
// 初始化进度条(只在页面加载时调用一次,清空画布)
initProgressRing() {
const {
canvasSize
} = this.data
const ctx = wx.createCanvasContext('progressCanvas')
// 彻底清空画布,不绘制任何圆环
ctx.clearRect(0, 0, canvasSize, canvasSize)
ctx.draw()
},
// 更新进度条
updateProgressRing(progress) {
const {
center,
radius,
canvasSize,
lineWidth
} = this.data
const ctx = wx.createCanvasContext('progressCanvas')
// 清除(使用实际 px 尺寸)
ctx.clearRect(0, 0, canvasSize, canvasSize)
// 只有在录制中,才绘制圆环
if (this.data.isRecording) {
// 绘制灰色背景圆环
ctx.beginPath()
ctx.arc(center, center, radius, 0, 2 * Math.PI)
ctx.setStrokeStyle('rgba(255, 255, 255, 0.3)')
ctx.setLineWidth(lineWidth)
ctx.stroke()
// 绘制红色进度圆环(progress > 0 时才画)
if (progress > 0) {
const startAngle = -Math.PI / 2
const endAngle = (progress / 100) * 2 * Math.PI - Math.PI / 2
ctx.beginPath()
ctx.arc(center, center, radius, startAngle, endAngle)
ctx.setStrokeStyle('#ff4757')
ctx.setLineWidth(lineWidth)
ctx.setLineCap('round')
ctx.stroke()
}
}
ctx.draw()
},
// 检查录音权限,返回 true/false
async checkCameraAndRecordAuth() {
const setting = await wx.getSetting()
const recordAuth = setting.authSetting['scope.record']
// 有权限返回 true
if (recordAuth) return true
if (!recordAuth) {
try {
await wx.authorize({
scope: 'scope.record'
})
} catch (e) {
return false
}
}
return true
},
// 清除录制定时器
clearRecordTimer() {
if (this.data.recordTimer) {
clearInterval(this.data.recordTimer)
this.setData({
recordTimer: null
})
}
},
// 实际开始录制的逻辑
async actuallyStartRecord() {
if (this.data.isRecording) return
// 开始实际录制(静音录制)
this.cameraContext.startRecord({
// muted: true, // 静音录制,不录制外界声音
timeout: 40, // 超时时间,超过时自动停止录制,官方默认30s,超出规定的超时时间不能停止录制了
success: () => {
this.playMusic()
this.setData({
isRecording: true,
progress: 0,
recordStartTime: Date.now()
})
console.log('开始录制(静音模式)')
// 更新进度 - 关键:使用 maxDuration (30秒) 作为基准计算进度
this.data.recordTimer = setInterval(() => {
const elapsed = Date.now() - this.data.recordStartTime
// 计算进度:基于最大时长 30 秒
const progress = Math.min((elapsed / this.data.maxDuration) * 100, 100)
console.log('progress', progress)
this.setData({
progress
})
this.updateProgressRing(progress)
console.log('elapsed', elapsed)
// 同时检查是否达到最大时长(30秒)
if (elapsed > this.data.maxDuration) {
this.stopRecord()
}
}, 50)
},
fail: (err) => {
console.error('录制失败:', err)
this.resetRecord()
}
})
},
// 点击开始/停止录制
onTapRecord() {
if (this.data.isRecording) {
// 计算录制时长
const recordDuration = Date.now() - this.data.recordStartTime
// 检查是否满足最短录制时间(10秒)
if (recordDuration <= this.data.minDuration) {
wx.showToast({
title: '录制时间最少10秒噢~'
})
return
}
// 如果正在录制,则停止录制
this.stopRecord()
} else {
// 如果不是在录制,则开始录制
this.actuallyStartRecord()
}
},
// 停止录制
async stopRecord() {
if (!this.data.isRecording) return
// 停止计时器
this.clearRecordTimer()
this.stopMusic()
const that = this;
this.setData({
maskLoadingFlag: true
})
// 停止录制
this.cameraContext.stopRecord({
success: async (res) => {
console.log('录制完成,返回结果:', res)
console.log('视频临时路径:', res.tempVideoPath)
console.log('视频路径类型:', typeof res.tempVideoPath)
console.log('视频路径长度:', res.tempVideoPath.length)
// 检查视频路径格式
if (res.tempVideoPath.startsWith('wxfile://')) {
console.log('视频路径格式正确,是微信临时文件路径')
} else {
console.warn('视频路径格式可能不正确:', res.tempVideoPath)
}
wx.navigateTo({
url: `/pages/logs/logs?videoPath=${encodeURIComponent(res.tempVideoPath)}`,
success: () => {
console.log('成功跳转到logs页面')
},
fail: (err) => {
console.error('跳转到logs页面失败:', err)
}
})
},
fail: (err) => {
console.error('停止录制失败:', err)
wx.showToast({
title: '录制失败,请重试',
icon: 'error'
})
},
complete: () => {
this.resetRecord()
}
})
this.clearRecordTimer()
},
// 重置录制状态
resetRecord() {
this.clearRecordTimer()
this.setData({
isRecording: false,
progress: 0
})
this.updateProgressRing(0)
this.initProgressRing()
},
// 重置录制(供logs页面调用)
resetRecording() {
this.resetRecord()
},
onUnload() {
// 清理资源
this.clearRecordTimer()
if (this.data.isRecording) {
this.cameraContext.stopRecord()
}
// 销毁 InnerAudioContext
if (this.data.innerAudioContext) {
this.data.innerAudioContext.destroy()
}
}
})预览页面
pages/logs/logs.wxml
js
<!-- pages/logs/logs.wxml -->
<view class="container">
<!-- 视频预览区域 -->
<view class="video-preview">
<video
id="myVideo"
class="video-player"
src="{{videoPath}}"
controls
autoplay
show-center-play-btn
show-play-btn
enable-play-gesture
object-fit="contain"
binderror="onVideoError"
bindplay="onVideoPlay"
></video>
</view>
<!-- 操作按钮区域 -->
<view class="action-buttons">
<button class="btn retry-btn" bindtap="retryRecording">
<view class="btn-icon">↺</view>
<text class="btn-text">重新录制</text>
</button>
<button class="btn save-btn" bindtap="saveVideo">
<view class="btn-icon">⬇</view>
<text class="btn-text">保存视频</text>
</button>
<button class="btn share-btn" bindtap="shareVideo">
<view class="btn-icon">↗</view>
<text class="btn-text">分享</text>
</button>
</view>
<!-- 底部操作栏(模仿图片中的样式) -->
<view class="bottom-bar">
<view class="action-item" bindtap="likeVideo">
<view class="action-icon">❤️</view>
<text class="action-count">{{likeCount}}</text>
</view>
<view class="action-item" bindtap="commentVideo">
<view class="action-icon">💬</view>
<text class="action-count">{{commentCount}}</text>
</view>
<view class="action-item" bindtap="collectVideo">
<view class="action-icon">⭐</view>
<text class="action-count">{{collectCount}}</text>
</view>
</view>
</view>pages/logs/logs.wxss
js
/** pages/logs/logs.wxss **/
page {
height: 100vh;
background: #000;
color: #fff;
}
.container {
position: relative;
width: 100%;
min-height: 100vh;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* 顶部状态栏 */
.status-bar {
padding: 20rpx 30rpx;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0, 0, 0, 0.8);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 80rpx;
box-sizing: border-box;
}
.time {
font-size: 30rpx;
font-weight: bold;
}
.back-btn {
font-size: 32rpx;
color: #fff;
}
/* 用户信息 */
.user-info {
margin-top: 100rpx;
padding: 0 30rpx 20rpx;
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.8);
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
border: 2rpx solid #fff;
}
.username {
flex: 1;
font-size: 28rpx;
font-weight: bold;
}
.follow-btn {
padding: 8rpx 24rpx;
background: #ff4757;
border-radius: 40rpx;
font-size: 24rpx;
}
/* 视频预览区域 */
.video-preview {
flex: 1;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
}
.video-player {
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.video-info {
position: absolute;
bottom: 200rpx;
left: 30rpx;
right: 30rpx;
z-index: 10;
}
.video-title {
display: block;
font-size: 36rpx;
font-weight: bold;
margin-bottom: 10rpx;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.8);
}
.video-tips {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
}
/* 操作按钮区域 */
.action-buttons {
padding: 40rpx 30rpx;
display: flex;
justify-content: center;
gap: 40rpx;
background: rgba(0, 0, 0, 0.9);
}
.btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx 30rpx;
border-radius: 50rpx;
border: none;
min-width: 180rpx;
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.btn-icon {
font-size: 40rpx;
margin-bottom: 10rpx;
}
.btn-text {
font-size: 24rpx;
}
.retry-btn {
background: rgba(255, 255, 255, 0.2);
}
.retry-btn:active {
background: rgba(255, 255, 255, 0.3);
}
.save-btn {
background: #ff4757;
}
.save-btn:active {
background: #ff1e1e;
}
.share-btn {
background: #1e90ff;
}
.share-btn:active {
background: #0066cc;
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.9);
padding: 30rpx;
display: flex;
justify-content: center;
gap: 80rpx;
z-index: 100;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
}
.action-icon {
font-size: 50rpx;
margin-bottom: 10rpx;
}
.action-count {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}pages/logs/logs.js
js
// pages/logs/logs.js
Page({
data: {
videoPath: '', // 视频路径
userInfo: {}, // 用户信息
likeCount: 0,
commentCount: 0,
collectCount: 0
},
onLoad(options) {
console.log('logs页面接收到的参数:', options)
// 从上一个页面获取视频路径
if (options.videoPath) {
const videoPath = decodeURIComponent(options.videoPath)
console.log('视频路径:', videoPath)
this.setData({
videoPath: videoPath
})
// 模拟获取用户信息
this.getUserInfo()
} else {
wx.showToast({
title: '未找到视频',
icon: 'error'
})
// 如果没有视频,3秒后返回
setTimeout(() => {
wx.navigateBack()
}, 3000)
}
},
onShow() {
// 页面显示时,可以做一些初始化
console.log('logs页面显示,当前videoPath:', this.data.videoPath)
// 创建视频上下文
this.videoContext = wx.createVideoContext('myVideo')
// 尝试播放视频
if (this.data.videoPath) {
setTimeout(() => {
this.videoContext.play()
console.log('尝试播放视频')
}, 1000)
}
},
// 视频加载错误
onVideoError(e) {
console.error('视频播放错误:', e.detail.errMsg)
wx.showToast({
title: '视频播放失败',
icon: 'error'
})
},
// 视频播放事件
onVideoPlay() {
console.log('视频开始播放')
},
// 获取用户信息
getUserInfo() {
wx.getUserProfile({
desc: '用于展示用户信息',
success: (res) => {
this.setData({
userInfo: res.userInfo
})
},
fail: () => {
// 如果用户拒绝,使用默认信息
this.setData({
userInfo: {
avatarUrl: '/images/default-avatar.png',
nickName: '视频创作者'
}
})
}
})
},
// 返回上一页
goBack() {
wx.navigateBack({
delta: 1
})
},
// 重新录制
retryRecording() {
wx.showModal({
title: '重新录制',
content: '确定要重新录制视频吗?当前视频将被丢弃',
confirmText: '重新录制',
confirmColor: '#ff4757',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
// 返回首页重新录制
wx.navigateBack({
delta: 1,
success: () => {
// 可以在这里触发首页的重置操作
const pages = getCurrentPages()
const prevPage = pages[pages.length - 1]
if (prevPage && prevPage.resetRecording) {
prevPage.resetRecording()
}
}
})
}
}
})
},
// 保存视频到相册
saveVideo() {
if (!this.data.videoPath) {
wx.showToast({
title: '没有可保存的视频',
icon: 'error'
})
return
}
// 首先检查授权状态
wx.getSetting({
success: (res) => {
if (!res.authSetting['scope.writePhotosAlbum']) {
// 如果没有授权,请求授权
wx.authorize({
scope: 'scope.writePhotosAlbum',
success: () => {
this.doSaveVideo()
},
fail: () => {
wx.showModal({
title: '需要相册权限',
content: '需要您授权保存视频到相册',
showCancel: true,
success: (modalRes) => {
if (modalRes.confirm) {
wx.openSetting()
}
}
})
}
})
} else {
// 已经授权,直接保存
this.doSaveVideo()
}
}
})
},
// 执行保存视频
doSaveVideo() {
wx.showLoading({
title: '保存中...',
mask: true
})
wx.saveVideoToPhotosAlbum({
filePath: this.data.videoPath,
success: () => {
wx.hideLoading()
wx.showToast({
title: '保存成功',
icon: 'success',
duration: 2000
})
},
fail: (err) => {
wx.hideLoading()
console.error('保存失败:', err)
let errMsg = '保存失败'
if (err.errMsg.includes('denied')) {
errMsg = '保存失败,请检查相册权限'
}
wx.showToast({
title: errMsg,
icon: 'error',
duration: 2000
})
}
})
},
// 分享视频
shareVideo() {
wx.showActionSheet({
itemList: ['分享给好友', '保存到本地', '复制链接'],
success: (res) => {
const { tapIndex } = res
switch (tapIndex) {
case 0:
this.shareToFriend()
break
case 1:
this.saveVideo()
break
case 2:
this.copyLink()
break
}
}
})
},
// 分享给好友
shareToFriend() {
wx.shareAppMessage({
title: '看看我录制的视频',
path: `/pages/logs/logs?videoPath=${encodeURIComponent(this.data.videoPath)}`,
imageUrl: this.data.videoPath
})
},
// 复制链接
copyLink() {
// 这里可以生成一个分享链接
const shareLink = `https://你的域名.com/share?video=${encodeURIComponent(this.data.videoPath)}`
wx.setClipboardData({
data: shareLink,
success: () => {
wx.showToast({
title: '链接已复制',
icon: 'success'
})
}
})
},
// 点赞
likeVideo() {
this.setData({
likeCount: this.data.likeCount + 1
})
wx.showToast({
title: '点赞成功',
icon: 'success'
})
},
// 评论
commentVideo() {
wx.showModal({
title: '评论',
editable: true,
placeholderText: '说点什么...',
success: (res) => {
if (res.confirm && res.content) {
this.setData({
commentCount: this.data.commentCount + 1
})
wx.showToast({
title: '评论成功',
icon: 'success'
})
}
}
})
},
// 收藏
collectVideo() {
this.setData({
collectCount: this.data.collectCount + 1
})
wx.showToast({
title: '收藏成功',
icon: 'success'
})
},
onUnload() {
// 页面卸载时的清理
console.log('logs页面卸载')
}
})第二版
去掉长按和松手功能 第一次点击开始录制,第二次点击停止录制 开始录制播放背景音乐,可循环播放背景音乐,直到录制停止
修改后的首页代码
index.wxml
js
<view class="container">
<!-- 相机预览 -->
<camera class="camera-preview" device-position="front" flash="off">
</camera>
<!-- GIF 层 -->
<!-- 悬浮视频窗口 - 与 gif-container 同级 -->
<movable-area class="movable-area" style="height: {{phoneHeight}}px; width: {{phoneWidth}}px">
<movable-view class="pip-video" x="{{pipX}}" y="{{pipY}}" direction="all">
<video src="{{videoUrl}}" autoplay loop muted class="video-element"></video>
</movable-view>
</movable-area>
<!-- 录制区域 -->
<view class="record-area">
<view class="progress-container">
<canvas canvas-id="progressCanvas" class="progress-canvas"></canvas>
<view class="record-button" bindtap="onTapRecord">
<view class="btn-text">{{isRecording?'完成拍摄':'开始拍摄'}}</view>
</view>
</view>
<view class="hint-text">点击开始/停止,最少10秒最多30秒</view>
</view>
<image class="gif-image" src="https://gitee.com/xiaojisengren/tuchuang/raw/master/test/baokuang.gif" bindload="onGifLoad" binderror="onGifError"></image>
</view>index.js
js
// pages/test/test.js
Page({
data: {
// 画中画位置
pipX: 0,
pipY: 0,
pipWidth: 120, // 画中画宽度
pipHeight: 150, // 画中画高度
// 设备信息
phoneWidth: 0,
phoneHeight: 0,
safeArea: {
top: 0,
bottom: 0
},
// 录制相关
isRecording: false,
progress: 0,
minDuration: 10000, // 最短10秒(10000毫秒)
maxDuration: 30000, // 最长30秒(30000毫秒)
currentDuration: 30000, // 当前设定录制时长,默认15秒
recordTimer: null,
recordStartTime: 0,
innerAudioContext: null, // 改为使用 InnerAudioContext
// 视频
videoUrl: 'https://www.yilibabyclub.com/BigPurchase/SLYZ/video/yg1.mp4',
cameraContext: null,
canvasSize: 0,
center: 0,
radius: 0, // 留边距
lineWidth: 0
},
onLoad: function (options) {
// 计算画布尺寸
const systemInfo = wx.getSystemInfoSync()
const pxRatio = systemInfo.windowWidth / 750
const size = 200 * pxRatio // 200rpx → px
const ten = 20 * pxRatio
this.setData({
canvasSize: size,
center: size / 2,
radius: size / 2 - ten, // 留边距
lineWidth: 10 * pxRatio
})
this.getPhoneInfo()
this.cameraContext = wx.createCameraContext()
// 创建 InnerAudioContext
const innerAudioContext = wx.createInnerAudioContext()
this.setData({
innerAudioContext
})
},
// 播放音乐
playMusic() {
const { innerAudioContext } = this.data
if (!innerAudioContext) return
innerAudioContext.obeyMuteSwitch = false
// 监听播放结束,手动重新播放
innerAudioContext.onEnded(() => {
innerAudioContext.src = 'https://www.yilibabyclub.com/Attachment_Islion_New/BigPurchase/xiaoji/chengzhangying/youzixiaoyang/music/yzxycjwjps.mp3?now=' + Date.now()
console.log('背景音乐播放结束,重新开始播放')
innerAudioContext.play()
})
// 设置音频并播放
innerAudioContext.src = 'https://www.yilibabyclub.com/Attachment_Islion_New/BigPurchase/xiaoji/chengzhangying/youzixiaoyang/music/yzxycjwjps.mp3?now=' + Date.now()
innerAudioContext.play()
console.log('开始播放背景音乐')
},
// 暂停音乐
pauseMusic() {
const { innerAudioContext } = this.data
if (!innerAudioContext) return
innerAudioContext.pause()
console.log('音乐已暂停')
},
// 停止音乐
stopMusic() {
const { innerAudioContext } = this.data
if (!innerAudioContext) return
innerAudioContext.stop()
console.log('音乐已停止')
},
// 获取设备信息
getPhoneInfo() {
wx.getSystemInfo({
success: (res) => {
const windowWidth = res.windowWidth
const pxRatio = windowWidth / 750 // rpx转换比例
const minBottomPx = 350 * pxRatio // 200rpx转换为px
const windowHeight = res.windowHeight - minBottomPx
const safeArea = res.safeArea || {
top: 0,
bottom: 0
}
// 初始位置:右上角(距离右侧20rpx,距离安全区顶部60rpx)
const initX = windowWidth - this.data.pipWidth - 20
const initY = safeArea.top + 60
this.setData({
phoneWidth: windowWidth,
phoneHeight: windowHeight,
safeArea: safeArea,
pipX: initX,
pipY: initY
})
// 初始化进度条
this.initProgressRing()
}
})
},
// 画中画移动事件
onPipMove(e) {
// 可以在这里实时获取位置
// console.log('位置:', e.detail.x, e.detail.y)
},
// 画中画拖动结束(自动吸附)
onPipEnd(e) {
const {
phoneWidth,
pipWidth,
safeArea
} = this.data
const currentX = e.detail.x
const currentY = e.detail.y
// 计算屏幕中心点
const screenCenter = phoneWidth / 2
const pipCenter = currentX + pipWidth / 2
let finalX = currentX
let finalY = currentY
// 自动吸附到左右边缘
if (pipCenter < screenCenter) {
// 吸附到左侧
finalX = 20
} else {
// 吸附到右侧
finalX = phoneWidth - pipWidth - 20
}
// 纵向边界限制
const minY = safeArea.top + 20
const maxY = this.data.phoneHeight - 200 // 留出底部空间
finalY = Math.max(minY, Math.min(currentY, maxY))
// 使用动画更新位置
this.setData({
pipX: finalX,
pipY: finalY
})
// 震动反馈
wx.vibrateShort({
type: 'light'
})
},
// 初始化进度条
initProgressRing() {
const {
center,
radius,
lineWidth
} = this.data
const ctx = wx.createCanvasContext('progressCanvas')
// 绘制背景圆环
ctx.beginPath()
ctx.arc(center, center, radius, 0, 2 * Math.PI)
ctx.setStrokeStyle('rgba(255, 255, 255, 0.3)')
ctx.setLineWidth(lineWidth)
ctx.stroke()
ctx.draw()
},
// 更新进度条
updateProgressRing(progress) {
const {
center,
radius,
canvasSize,
lineWidth
} = this.data
const ctx = wx.createCanvasContext('progressCanvas')
// 清除(使用实际 px 尺寸)
ctx.clearRect(0, 0, canvasSize, canvasSize)
// 背景圆环
ctx.beginPath()
ctx.arc(center, center, radius, 0, 2 * Math.PI)
ctx.setStrokeStyle('rgba(255, 255, 255, 0.3)')
ctx.setLineWidth(lineWidth)
ctx.stroke()
// 进度圆环 - 关键:这里使用 maxDuration 作为基准,所以 100% 对应 30 秒
const startAngle = -Math.PI / 2
const endAngle = (progress / 100) * 2 * Math.PI - Math.PI / 2
ctx.beginPath()
ctx.arc(center, center, radius, startAngle, endAngle)
ctx.setStrokeStyle('#ff4757')
ctx.setLineWidth(lineWidth)
ctx.setLineCap('round')
ctx.stroke()
ctx.draw()
},
// 检查摄像头和录音权限,返回 true/false
async checkCameraAndRecordAuth() {
const setting = await wx.getSetting()
const cameraAuth = setting.authSetting['scope.camera']
const recordAuth = setting.authSetting['scope.record']
// 都有权限返回 true
if (cameraAuth && recordAuth) return true
// 申请缺失的权限
if (!cameraAuth) {
try {
await wx.authorize({
scope: 'scope.camera'
})
} catch (e) {
return false
}
}
if (!recordAuth) {
try {
await wx.authorize({
scope: 'scope.record'
})
} catch (e) {
return false
}
}
return true
},
// 清除录制定时器
clearRecordTimer() {
if (this.data.recordTimer) {
clearInterval(this.data.recordTimer)
this.setData({
recordTimer: null
})
}
},
// 实际开始录制的逻辑
async actuallyStartRecord() {
// 检查权限
const quanxian = await this.checkCameraAndRecordAuth()
if (!quanxian) {
wx.showModal({
title: '需要权限',
content: '请开启摄像头和录音权限',
success: (res) => {
if (res.confirm) wx.openSetting()
}
})
return
}
if (this.data.isRecording) return
this.setData({
isRecording: true,
progress: 0,
recordStartTime: Date.now()
})
// 开始实际录制(静音录制)
this.cameraContext.startRecord({
// muted: true, // 静音录制,不录制外界声音(这里要关掉,不然背景音乐也出不来)
timeout: 40, // 超时时间,超过时自动停止录制,官方默认30s,超出规定的超时时间不能停止录制了
success: () => {
this.playMusic()
console.log('开始录制(静音模式)')
},
fail: (err) => {
console.error('录制失败:', err)
this.resetRecord()
}
})
// 更新进度 - 关键:使用 maxDuration (30秒) 作为基准计算进度
this.data.recordTimer = setInterval(() => {
const elapsed = Date.now() - this.data.recordStartTime
// 计算进度:基于最大时长 30 秒
const progress = Math.min((elapsed / this.data.maxDuration) * 100, 100)
this.setData({
progress
})
this.updateProgressRing(progress)
// 达到当前设定的录制时长自动停止
if (elapsed >= this.data.currentDuration) {
this.stopRecord()
}
// 同时检查是否达到最大时长(30秒)
if (elapsed >= this.data.maxDuration) {
this.stopRecord()
}
}, 50)
},
// 点击开始/停止录制
onTapRecord() {
if (this.data.isRecording) {
// 如果正在录制,则停止录制
this.stopRecord()
} else {
// 如果不是在录制,则开始录制
this.actuallyStartRecord()
}
},
// 停止录制
stopRecord() {
if (!this.data.isRecording) return
// 停止计时器
this.clearRecordTimer()
this.stopMusic()
// 停止录制
this.cameraContext.stopRecord({
success: (res) => {
console.log('录制完成,返回结果:', res)
console.log('视频临时路径:', res.tempVideoPath)
console.log('视频路径类型:', typeof res.tempVideoPath)
console.log('视频路径长度:', res.tempVideoPath.length)
// 检查视频路径格式
if (res.tempVideoPath.startsWith('wxfile://')) {
console.log('视频路径格式正确,是微信临时文件路径')
} else {
console.warn('视频路径格式可能不正确:', res.tempVideoPath)
}
// 跳转到预览页面
wx.navigateTo({
url: `../preview/preview?videoPath=${encodeURIComponent(res.tempVideoPath)}`,
success: () => {
console.log('成功跳转到logs页面')
},
fail: (err) => {
console.error('跳转到logs页面失败:', err)
}
})
},
fail: (err) => {
console.error('停止录制失败:', err)
wx.showToast({
title: '录制失败,请重试',
icon: 'error'
})
},
complete: () => {
this.resetRecord()
}
})
},
// 重置录制状态
resetRecord() {
this.clearRecordTimer()
this.setData({
isRecording: false,
progress: 0
})
this.updateProgressRing(0)
},
// 重置录制(供logs页面调用)
resetRecording() {
this.resetRecord()
},
onUnload() {
// 清理资源
this.clearRecordTimer()
if (this.data.isRecording) {
this.cameraContext.stopRecord()
}
// 销毁 InnerAudioContext
if (this.data.innerAudioContext) {
this.data.innerAudioContext.destroy()
}
}
})index.wxss
js
/* pages/test/test.wxss */
page {
height: 100vh;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* GIF动图容器 - 使用cover-view使其能被相机捕捉*/
.gif-image{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 相机预览 */
.camera-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
/* 悬浮视频窗口 */
.movable-area {
position: absolute;
top: 0;
left: 0;
z-index: 9999;
}
.pip-video {
width: 240rpx;
height: 300rpx;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.4);
border: 4rpx solid rgba(255, 255, 255, 0.9);
background: #000;
}
.video-element {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 录制区域 */
.record-area {
position: absolute;
bottom: 60rpx;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
z-index: 2000;
}
.record-movable{
position: absolute;
width: 100%;
height: 100%;
background-color: #0000ff80;
top: 0;
left: 0;
z-index: 3000;
pointer-events: none;
}
.progress-container {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 24rpx;
}
.progress-canvas {
width: 200rpx;
height: 200rpx;
}
.record-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 130rpx;
height: 130rpx;
border-radius: 50%;
background: #ff4757;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 9rpx 24rpx rgba(255, 71, 87, 0.4);
transition: all 0.15s ease;
user-select: none;
}
/* .record-button:active {
transform: translate(-50%, -50%) scale(0.95);
} */
.btn-text {
color: #fff;
font-size: 32rpx;
font-weight: 500;
}
.record-area .hint-text {
color: rgba(255, 255, 255, 0.8);
font-size: 28rpx;
margin-top: 28rpx;
}遇到的问题及解决
报错:Failed to invoke startRecord on CameraContext : not allowed to use microphone. 错误原因:没有麦克风权限 同一个appid的测试项目正常,正式项目就报错这个
解决
在正式项目里面检查
app.json文件,检查"__usePrivacyCheck__": true测试项目由于是临时创建的,并未配置这个字段,因此正常,正式环境将此字段改成false即可正常申请麦克风权限
出现的原因
上面这个字段为
true会强制检查微信小程序后台配置的隐私协议,里面需要有加麦克风的权限 开发环境这个字段改成false,就会跳过检查,先进行开发 但是提交审核这个字段必须改成true,必须强制检查隐私协议,否则大概率会审核失败
