Skip to content
微信小程序实现拍摄和画中画小窗视频和背景音乐播放和关闭

第一版

可以点击录制再次点击停止 也可以长按开始,松手结束

首页

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,必须强制检查隐私协议,否则大概率会审核失败