目录

vue-3-阿里云视频播放器-如何让用户自己给视频添加节点

目录

vue 3 阿里云视频播放器 如何让用户自己给视频添加节点

vue 3 阿里云视频播放器 如何让用户自己给视频添加节点

带节点截图功能的视频标记
1.自定义视频控制条,在进度条上显示节点标记
2.添加截图功能,保存节点时刻的视频画面
3.右侧面板管理所有节点和截图

<template>
  <!-- 成员名单 -->
  <el-dialog
    v-model="dialogpeopleVisible"
    :before-close="handleClose"
    class="aliyunplayDialog"
    @opened="opena"
  >
    <!-- :show-close = "false" -->

    <!-- <div  class="prism-player" id="playerContainer" ></div> -->
    <div class="container">
      <div class="dialog-content-self">自定义节点标记</div>
      <!-- <header>
        <h1>Vue 3 阿里云视频播放器 - 自定义节点标记</h1>
        <p>当前时间: {{ currentTimeFormatted }}</p>
      </header> -->

      <div class="main-content">
        <div class="video-container">
          <div id="player-container"></div>
        </div>

        <div class="nodes-panel">
          <div class="panel-header">
            <div>节点管理</div>
            <span>总数: {{ nodes.length }}</span>
          </div>

          <div class="add-node-form">
            <div class="form-group">
              <label>节点标题</label>
              <el-input v-model="newNodeTitle" placeholder="输入节点标题" />
            </div>

            <div class="form-group">
              <div style="display: flex;align-items: center;">
                <label>节点图片</label>
                <el-button
                  type="primary"
                  class="btn-screenshot"
                  style="margin-left: 12px;"
                  @click="captureNodeScreenshot($event)"
                >
                  截图
                </el-button>
              </div>
              <div style="display: flex; flex-direction: column">
                <el-upload
                  :class="{ isShowImg: newNodeImg.length > 0 }"
                  action="#"
                  list-type="picture-card"
                  :auto-upload="false"
                  accept="image/*"
                  :limit="1"
                  :on-exceed="handleExceed"
                  v-model:file-list="newNodeImg"
                  :on-change="handleChange"
                >
                  <el-icon>
                    <Plus />
                  </el-icon>
                  <template #file="{ file }">
                    <div>
                      <img
                        class="el-upload-list__item-thumbnail"
                        :src="file.url"
                        alt=""
                      />
                      <span class="el-upload-list__item-actions">
                        <span
                          v-if="!disabled"
                          class="el-upload-list__item-delete"
                          @click="handleRemovePicture(file)"
                        >
                          <el-icon>
                            <Delete />
                          </el-icon>
                        </span>
                      </span>
                    </div>
                  </template>
                </el-upload>
                <div style="font-size: 12px; color: #86909c">
                  {{ $t("view.course.the_image_size_cannot_exceed_2mb!") }}
                </div>
              </div>
            </div>
            <div class="form-group">
              <label>节点内容</label>
              <el-input
                :rows="3"
                type="textarea"
                v-model="newNodeContent"
                placeholder="输入节点内容(可选)"
              ></el-input>
            </div>

            <div style="display: flex;align-items: center;">
              <el-button type="primary" @click="addNode">添加节点</el-button>
              <el-button type="primary" @click="completeNode">完成</el-button>
            </div>
          </div>

          <div class="nodes-list" v-if="sortedNodes.length > 0">
            <div v-if="sortedNodes.length === 0" class="empty-state">
              <p>暂无节点点击上方按钮添加</p>
            </div>

            <div
              v-for="node in sortedNodes"
              :key="node.id"
              class="node-item"
              @click="jumpToNode(node.time)"
            >
              <div class="node-header">
                <span class="node-time">{{ node.timeFormatted }}</span>
                <button class="btn-delete" @click="deleteNode(node.id, $event)">
                  删除
                </button>
              </div>
              <div>{{ node.title }}</div>
              <div class="node-content">{{ node.content }}</div>
            </div>
          </div>
        </div>
      </div>

      <!-- <div class="instructions">
        <h3>使用说明</h3>
        <ul>
          <li>播放视频到想要添加节点的位置暂停或继续播放</li>
          <li>填写节点标题和内容可选</li>
          <li>点击"添加节点"按钮节点将保存在当前时间点</li>
          <li>在右侧面板点击任意节点可以跳转到对应时间点</li>
          <li>可以删除不需要的节点</li>
        </ul>
      </div> -->
    </div>
  </el-dialog>
</template>

<script setup>
import { ref, reactive, onMounted, onUnmounted } from "vue";
import CustomProgressComponent from "@/components/course/CustomProgressComponent/index";
import { ElMessage, ElMessageBox } from "element-plus";

const dialogpeopleVisible = ref(false);

const infoValue = ref({});

const player = ref(null);
const nodes = ref([]);
const newNodeTitle = ref("");
const newNodeContent = ref("");
const newNodeImg = ref([]);
const currentTime = ref(0);
const videoElement = ref(null);

const emit = defineEmits(["close","completeNode"]);
const openDialog = (bool, info) => {
  console.log(info, "info");

  dialogpeopleVisible.value = bool;
  infoValue.value = info;
};

const opena = () => {
  initPlayer();
};

// 更新播放器的进度标记
const updatePlayerMarkers = () => {
  if (player.value) {
    player.value.setProgressMarkers(nodes.value);
  }
};
// 初始化播放器
const initPlayer = () => {
  if (player.value) {
    // 如果已经创建了,就销毁
    player.value.dispose();
    player.value = null;
  }
  player.value = new Aliplayer(
    {
      id: "player-container",
      source: infoValue.value.url.replace(/^http:\/\//i, "https://"), // 替换为实际视频URL
      width: "100%",
      height: "500px",
      autoplay: false,
      // isLive: false,
      // rePlay: false,
      // playsinline: true,
      // preload: true,
      // controlBarVisibility: "hover",
      // useH5Prism: true,
      skinLayoutIgnore: ["loading"],
      //对播放按钮位置修改
      skinLayout: [
        { name: "bigPlayButton", align: "cc", x: 30, y: 80 },
        {
          name: "H5Loading",
          align: "cc",
          x: 30,
          y: 80,
        },
        {
          name: "controlBar",
          align: "blabs",
          x: 0,
          y: 0,
          children: [
            {
              name: "progress",
              align: "tlabs",
              x: 0,
              y: 0,
            },
            { name: "playButton", align: "tl", x: 15, y: 12 },
            { name: "timeDisplay", align: "tl", x: 10, y: 6 },
            { name: "fullScreenButton", align: "tr", x: 10, y: 12 },
            { name: "volume", align: "tr", x: 10, y: 10 },
            // { name: "setting", align: "tr", x: 10, y: 12 }
          ],
        },
        {
          name: "fullControlBar",
          align: "tlabs",
          x: 0,
          y: 0,
          children: [
            { name: "fullTitle", align: "tl", x: 25, y: 6 },
            { name: "fullNormalScreenButton", align: "tr", x: 24, y: 13 },
            { name: "fullTimeDisplay", align: "tr", x: 10, y: 12 },
            { name: "fullZoom", align: "cc" },
          ],
        },
      ],
      progressMarkers: nodes.value,
      components: [
        {
          name: "CustomProgressComponent",
          type: CustomProgressComponent,
        },
      ],
    },
    () => {
      console.log("播放器初始化完成");

      // 获取视频DOM元素
      setTimeout(() => {
        const playerWrap = document.getElementById("player-container");
        videoElement.value = playerWrap.querySelector("video");
      }, 1000);
    }
  );

  player.value.on("timeupdate", () => {
    console.log(player.value.getCurrentTime(), "player.value.getCurrentTime()");
    currentTime.value = player.value.getCurrentTime();
  });
};

// 添加节点
const addNode = () => {
  if (!newNodeTitle.value.trim()) {
    ElMessage.error("请输入节点标题");
    return;
  }

  const time = currentTime.value;
  const timeFormatted = formatTime(time);

  nodes.value.push({
    id: Date.now(),
    time: time,
    timeFormatted: timeFormatted,
    title: newNodeTitle.value,
    content: newNodeContent.value,
    offset: time,
    describe: newNodeContent.value,
    coverUrl:
      newNodeImg.value.length > 0
        ? URL.createObjectURL(newNodeImg.value[0].raw)
        : "",
    isCustomized: true,
  });
  console.log(nodes.value, "nodes.value");
  // 更新播放器的进度标记
  updatePlayerMarkers();
  // 清空表单
  newNodeTitle.value = "";
  newNodeContent.value = "";
  newNodeImg.value = [];
};

//视频节点完成处理
const completeNode = () =>{
  emit('completeNode',infoValue.value )
  handleClose()
}
// 删除节点
const deleteNode = (id, event) => {
  event.stopPropagation();
  nodes.value = nodes.value.filter((node) => node.id !== id);

  // 更新播放器的进度标记
  updatePlayerMarkers();
};

// 跳转到节点时间点
const jumpToNode = (time) => {
  if (player.value) {
    player.value.seek(time);
  }
};

// 格式化时间
const formatTime = (seconds) => {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins.toString().padStart(2, "0")}:${secs
    .toString()
    .padStart(2, "0")}`;
};

// 当前时间格式化显示
const currentTimeFormatted = computed(() => {
  return formatTime(currentTime.value);
});

// 按时间排序节点
const sortedNodes = computed(() => {
  return [...nodes.value].sort((a, b) => a.time - b.time);
});

const handleExceed = (files) => {
  ElMessage({
    type: "error",
    message: t("view.course.only_one_cover_image_can_be_uploaded"),
  });
};
const handleChange = (data, fileList) => {
  let file = data.raw;
  const isJpgPng =
    file.type === "image/jpeg" ||
    file.type === "image/png" ||
    file.type === "image/gif";

  if (!isJpgPng) {
    ElMessage({
      message: t(
        "view.course.the_uploaded_file_format_can_only_be_jpg/png/gif"
      ),
      type: "warning",
    });
    const currIdx = fileList.indexOf(file);
    fileList.splice(currIdx, 1);
    return false;
  }

  const isLt2M = file.size / 1024 / 1024 < 10;
  if (!isLt2M) {
    ElMessage({
      message: t("view.course.the_avatar_image_size_cannot_exceed_2mb!"),
      type: "warning",
    });
    const currIdx = fileList.indexOf(file);
    fileList.splice(currIdx, 1);
    return false;
  }
  return isJpgPng && isLt2M;
};

const handleRemovePicture  = (file) => {
  console.log(file,'file');
  
      newNodeImg.value = newNodeImg.value.filter(
      (item) => item.name !== file.name
    ); 
}
// base64转file
const base64ToFile = (base64, filename = "") => {
  const arr = base64.split(",");
  let mime = arr[0].match(/:(.*?);/)[1]; // 匹配出图片类型
  mime = mime.replace("data:", ""); // 去掉data:image/png;base64 // 去掉url中的base64,并转化为Uint8Array类型
  const bstr = atob(arr[1]);
  let n = bstr.length;
  const u8arr = new Uint8Array(n);
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }
  return new File([u8arr], filename, { type: mime });
};
// 为已有节点捕获截图
const captureNodeScreenshot = (event) => {
  // event.stopPropagation();

  if (!videoElement.value) {
    alert("视频元素未准备好,请稍后再试");
    return;
  }


  // 等待视频跳转完成
  setTimeout(() => {
    // 创建canvas来捕获视频帧
    const canvas = document.createElement("canvas");
    canvas.width = videoElement.value.videoWidth;
    canvas.height = videoElement.value.videoHeight;
    const ctx = canvas.getContext("2d");
    ctx.drawImage(videoElement.value, 0, 0, canvas.width, canvas.height);

    // 将canvas转换为数据URL
    const screenshotData = canvas.toDataURL("image/png");
    let  rawFileAvatar = base64ToFile(screenshotData, "avatar.png")
    // console.log(screenshotData,'screenshotData');
    let imageUrl = URL.createObjectURL(rawFileAvatar);
    newNodeImg.value.push({
    name: '节点图片',
    url: imageUrl,
    raw: rawFileAvatar,
  });
  }, 300);
};


const handleClose = () => {
  dialogpeopleVisible.value = false;
  // 清空节点数据,这样 sortedNodes 计算属性会自动更新为空数组
  nodes.value = [];
  player.value?.dispose(); // 修正变量名
  player.value = null;
  emit("closeNodes");
};
onUnmounted(() => {
  // 清空节点数据,这样 sortedNodes 计算属性会自动更新为空数组
  nodes.value = [];
  player.value?.dispose();
  player.value = null;
  emit("closeNodes");
});
defineExpose({ openDialog, handleClose });
</script>
<style lang="scss">
.el-dialog.aliyunplayDialog {
  .el-dialog__header {
    padding-top: 0px !important;
  }

  .el-dialog__body {
    padding-top: 10px !important;
  }
}

.dialog-content-self {
  font-size: 20px;
  // font-weight: 600;
  color: rgb(29, 33, 41);
  // margin-bottom: 20px;
}

.prism-player {
  width: 750px;
  height: 522px;
}
</style>
<style>
.container {
  max-width: 1200px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 20px;
  /* max-height: 500px; */
}

header {
  text-align: center;
  padding: 15px 0;
  background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
  color: white;
  border-radius: 10px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.main-content {
  display: flex;
  gap: 20px;
}

.video-container {
  flex: 3;
  background: white;
  border-radius: 10px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  height: 500px;
}

#player-container {
  width: 100%;
  height: 400px;
}

.nodes-panel {
  flex: 1;
  background: white;
  border-radius: 10px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  padding: 20px;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  height: 500px;
}

.panel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  padding-bottom: 10px;
  border-bottom: 1px solid #eee;
}

.add-node-form {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-bottom: 20px;
  padding: 15px;
  background: #f9f9f9;
  border-radius: 8px;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

label {
  font-weight: 500;
  font-size: 14px;
}

input,
textarea {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

textarea {
  min-height: 60px;
  resize: vertical;
}
.btn-screenshot{
  padding: 10px;
  height: 0px;
}
button {
  padding: 8px 16px;
  background: #4a6cf7;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: background 0.3s;
}



.btn-delete {
  background: #f44336;
}

.btn-delete:hover {
  background: #e53935;
}

.nodes-list {
  flex: 1;
  /* overflow-y: auto; */
}

.node-item {
  padding: 12px;
  border-radius: 6px;
  background: #f9f9f9;
  margin-bottom: 10px;
  cursor: pointer;
  transition: background 0.2s;
}

.node-item:hover {
  background: #f0f4ff;
}

.node-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 5px;
}

.node-time {
  font-weight: 600;
  color: #4a6cf7;
}

.node-content {
  font-size: 14px;
  color: #555;
}

.empty-state {
  text-align: center;
  padding: 30px;
  color: #888;
}

.instructions {
  background: white;
  border-radius: 10px;
  padding: 20px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.instructions h3 {
  margin-bottom: 10px;
  color: #4a6cf7;
}

.instructions ul {
  padding-left: 20px;
}

.instructions li {
  margin-bottom: 8px;
  font-size: 14px;
}

@media (max-width: 768px) {
  .main-content {
    flex-direction: column;
  }

  #player-container {
    height: 300px;
  }
}

.isShowImg {
  .el-upload-list__item.is-ready {
    width: 160px;
    height: 90px;
    border: none;
  }

  .el-upload-list__item.is-success {
    width: 160px;
    height: 90px;
    border: none;
  }

  .el-upload--picture-card {
    display: none;
  }
}
</style>

https://i-blog.csdnimg.cn/direct/3effc8f090404bbeabc155176ec33b48.png

https://i-blog.csdnimg.cn/direct/3ab17372501c4de6a27347d7ade33b9a.png