0
仿快手视频播放
黄粱一梦2024-01-1712
效果展示
首页
下拉加载
详情页面
切换视频
效果实现
主页详情页?emmm那必须上路由啊
如图先创建两个路由TouchFish和TouchFishDetail,之后在router/index.js引入
import TouchFish from '@/views/TouchFish/TouchFish.vue'
{
path: '/touchfish',
component: TouchFish,
meta: {
title: '视频首页'
}
},{
path: '/touchfishdetail/:id',
component: () => import('@/views/TouchFishDetail/TouchFishDetail.vue'),
meta: {
title: '视频详情'
}
}
store/touchfish.js
import {defineStore} from 'pinia'
export const touchFishStore = defineStore('touchFishStore',{
state:() => {
return {
shortVideoList:[], // 短视频列表
}
},
actions: {
setShortVideoList(list){
this.shortVideoList = list
}
},
persist: {
enabled:true,
storage:localStorage,
key:'TOUCH_FISH_STORE'
}
})
先介绍两个文件
- @/config/ksHttpConfig api需要的参数文件
/* 请求随机视频队列的接口配置文件 */
export const ksConfigRandomVidoeListData = {
query: `fragment photoContent on PhotoEntity {
__typename
id
duration
caption
originCaption
likeCount
viewCount
commentCount
realLikeCount
coverUrl
photoUrl
photoH265Url
manifest
manifestH265
videoResource
coverUrls {
url
__typename
}
timestamp
expTag
animatedCoverUrl
distance
videoRatio
liked
stereoType
profileUserTopPhoto
musicBlocked
}
fragment recoPhotoFragment on recoPhotoEntity {
__typename
id
duration
caption
originCaption
likeCount
viewCount
commentCount
realLikeCount
coverUrl
photoUrl
photoH265Url
manifest
manifestH265
videoResource
coverUrls {
url
__typename
}
timestamp
expTag
animatedCoverUrl
distance
videoRatio
liked
stereoType
profileUserTopPhoto
musicBlocked
}
fragment feedContent on Feed {
type
author {
id
name
headerUrl
following
headerUrls {
url
__typename
}
__typename
}
photo {
...photoContent
...recoPhotoFragment
__typename
}
canAddComment
llsid
status
currentPcursor
tags {
type
name
__typename
}
__typename
}
fragment photoResult on PhotoResult {
result
llsid
expTag
serverExpTag
pcursor
feeds {
...feedContent
__typename
}
webPageArea
__typename
}
query brilliantTypeDataQuery($pcursor: String, $hotChannelId: String, $page: String, $webPageArea: String) {
brilliantTypeData(pcursor: $pcursor, hotChannelId: $hotChannelId, page: $page, webPageArea: $webPageArea) {
...photoResult
__typename
}
}
`,
variables: {hotChannelId: "00",page:"brilliant",pcursor:"1"}
}
- @/api/touchFish.js 随机获取快手的视频列表api
import { ksConfigRandomVidoeListData } from '@/config/ksHttpConfig'
import {ksHttpInstance} from '@/http/baseHttp/baseHttp.js'
/**
* @name getRandomKSvideoList
* @description 随机获取快手的视频列表
* @method POST
* @path /graphql
* @returns promise
*/
export const httpInstanceGetRandomKSvideoList = () => {
return ksHttpInstance({
url: "/ksproxy/graphql",
method: "POST",
// headers: {"Content-Type":"application/json"},
data:ksConfigRandomVidoeListData
})
}
编写视频首页代码
<script setup>
import { onMounted, reactive, ref} from 'vue';
import {useRouter} from 'vue-router'
import {httpInstanceGetRandomKSvideoList} from '@/api/touchFish.js'
import {HeartIcon} from '@layui/icons-vue'
import {videoList} from '@/config/ksDataConfig' // 获取到的快手短视频数据
import scrollBottom from '@/components/scrollBottom.vue'
import {touchFishStore} from '@/store/touchfish'
let _touchFishStore = touchFishStore()
const router = useRouter()
onMounted(() => {
getRandomKSvideoList()
})
// 轮播
let touchFishBannerActive = ref("1")
let touchFishBannerConfig = reactive({
touchFishBannerActive: "1",
bannerList:[{
id:"1",
url:"https://img-baofun.zhhainiao.com/pcwallpaper_ugc/static/8897033871bd3adf2165075e3801628a.jpg?x-oss-process=image%2fresize%2cm_lfit%2cw_3840%2ch_2160"
},{
id:"2",
url:"https://img-baofun.zhhainiao.com/pcwallpaper_ugc/static/479b26fdb8991c71c5b877c2e0fdce4a.jpg?x-oss-process=image%2fresize%2cm_lfit%2cw_3840%2ch_2160"
},{
id:"3",
url:"https://bpic.588ku.com/Templet_origin_pic/05/41/70/8171875aa84b0320eea9f3b840855a2f.jpg"
},{
id:"4",
url:"https://img.zcool.cn/community/015f6958b78944a801219c77f62de2.jpg@2o.jpg"
},{
id:"5",
url:"https://img-baofun.zhhainiao.com/pcwallpaper_ugc/static/58dcba384954105ed097d64f9c0d9d31.jpg?x-oss-process=image%2fresize%2cm_lfit%2cw_3840%2ch_2160"
}]
})
let brilliantTypeData = reactive({
feeds:[],
loading:false
})
/* 获取短视频列表 */
let getRandomKSvideoList = async () => {
let {data:res} = await httpInstanceGetRandomKSvideoList()
brilliantTypeData.loading = true
// let {data:res} = videoList
brilliantTypeData.loading = true
brilliantTypeData.feeds = [...brilliantTypeData.feeds,...res.data.brilliantTypeData.feeds]
_touchFishStore.setShortVideoList(brilliantTypeData.feeds)
console.log('brilliantTypeData',brilliantTypeData.feeds);
}
/* 下来加载 */
let pushShortVideo = () => {
getRandomKSvideoList()
}
/* 单个视频点击处理函数 */
let shortVideoClickHandler = (shortVideoItem) => {
console.log('shortVideoItem',shortVideoItem);
router.push('/touchfishdetail/'+shortVideoItem.photo.id,)
}
</script>
<template>
<div class="touch-fish-container">
<div class="touch-fish-short-video">
<h1 class="touch-fish-title mr-tb-15">摸鱼短视频</h1>
<div class="touch-fish-short-video-top mr-tb-15">
<lay-carousel v-model="touchFishBannerConfig.touchFishBannerActive">
<lay-carousel-item v-for="item in touchFishBannerConfig.bannerList" :id="item.id" :key="item.id">
<div :style="{width:'100%',height:'100%',background:`url(${item.url}) 50% 40%`,backgroundSize:'100%'}"></div>
</lay-carousel-item>
</lay-carousel>
</div>
<div class="touch-fish-short-video-bottom">
<h1 class="touch-fish-title mr-tb-15">推荐视频</h1>
<scroll-bottom :obligateBottom="50" @scrollBottom="pushShortVideo">
<ul class="short-video-list">
<li class="short-video-item" v-for="item in brilliantTypeData.feeds" :key="item.photo.id" @click="shortVideoClickHandler(item)">
<img class="short-video-item-cover" v-lazy="item.photo.animatedCoverUrl" alt="" srcset="">
<div class="short-video-info">
<p class="author">作者:{{ item.author.name }}</p>
<p class="caption">{{ item.photo.caption}}</p>
<p class="like-count">
<HeartIcon></HeartIcon>
{{ item.photo.likeCount }}
</p>
</div>
</li>
<i class="short-video-item-zw"></i><i class="short-video-item-zw"></i><i class="short-video-item-zw"></i><i class="short-video-item-zw"></i><i class="short-video-item-zw"></i><i class="short-video-item-zw"></i>
</ul>
</scroll-bottom>
<p class="loading-text mr-tb-15">{{ brilliantTypeData.loading ? '正在加载···' : '我也是有底线的哦~' }}</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.touch-fish-container {
width: 100%;
.touch-fish-short-video {
width: 1200px;
min-height: 538px;
margin: 0 auto;
.touch-fish-title {
font-weight: bold;
}
.layui-carousel {
height: 500px !important;
}
}
// video
.touch-fish-short-video-bottom {
.short-video-list {
width: 1200px;
min-height: 358px;
flex-wrap: wrap;
display: flex;
justify-content: space-between;
.short-video-item {
// overflow: hidden;
width: 185px;
height: 314px;
position: relative;
cursor: pointer;
margin-bottom: 124px;
.short-video-item-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.short-video-info {
padding: 10px 5px;
position: absolute;
width: 100%;
height: 80px;
bottom: -80px;
left: 0px;
color: #000000;
// background: rgba(0,0,0,.5);
overflow: hidden;
.author {
line-height: 22px;
font-weight: bold;
font-size: 12px;
}
.caption {
height: 22px;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-align: center;
}
.like-count {
font-size: 12px;
line-height: 22px;
}
}
}
.short-video-item-zw {
width: 185px;
position: relative;
margin-bottom: 44px;
}
}
.loading-text {
text-align: center;
}
}
}
</style>
没有什么需要特别说明的,就是下拉加载记得添加一个防抖函数
详情页面
- 详情页面所包含的功能较多分步实现~
- 选用合适的播放器
- 视频的切换
- 鼠标滚轮切换
- 点击页面上下按键切换
- 当前视频播放完毕自动切换
- 当前可播放列表全部播放完毕时候由用户选择是重新播放还是刷新当前播放列表
- 选中当前视频条目,视频标题高亮,自动进入可视区域在侧边栏置顶
西瓜播放器
字节跳动的视频业务大多数是短视频,早期的时候我们在 video.js 基础上做二次开发。后来发现很多功能达不到我们的要求,比如自定义UI的成本、视频的清晰度无缝切换、视频流量的节省。考虑到当前点播依旧是mp4居多,我们做了个大胆的假设:在播放器端加载视频、解析视频、转换格式,让不支持分段播放的mp4动态支持,这样就无须转换源视频的格式,服务器端也无其他开销。在这个动力下,我们在2017年年底完成了这项开发任务,并与2018年年初测试了稳定性和经济收益。在这个背景下,我们一次解析了 hls、flv 等视频,这样我们不再简单的依赖第三方的视频库,只有掌握了底层技术才有优化的可能性。在不断攻克 hls、flv 解析的背景下,我们增强了产品体验,比如交互效果、进场动画等。直到最近,我们想完善文档并把播放器源代码开源出来给更多的视频从业者一个参考,我们一起交流学习,共同进步。
西瓜播放器官网 !!!注意引入时候引入西瓜播放器的样式 官网上面好像没说import 'xgplayer/dist/index.min.css';
视频的切换
鼠标滚轮的切换(根据wheel事件进行监听
)
let wheelHandler = (eventData) => {
/* 判断方向 */
let isTop = eventData.wheelDelta > 0 ? true : false
let ID = ''
let currentIndex = _touchFishStore.shortVideoList.findIndex(item => item.photo.id == id.value) //当前的视频在列表的索引
// 上一条
if (isTop) {
if (currentIndex == 0 ) {
layer.close()
layer.msg('已经是第一条了')
return
}
ID = _touchFishStore.shortVideoList[currentIndex-1].photo.id
router.push('/touchfishdetail/' + ID)
}else {
// 下一条
if (currentIndex == _touchFishStore.shortVideoList.length - 1) {
layer.closeAll()
layer.msg('已经是最后一条')
/* 显示遮罩 */
isEnd.value = true
console.log('isEnd',isEnd.value);
return
}
ID = _touchFishStore.shortVideoList[currentIndex+1].photo.id
console.log('ID',ID);
router.push('/touchfishdetail/' + ID)
}
}
点击页面上下按键切换
点击切换与上下滚轮切换的逻辑处理相同(手动传入参数)
<div class="taggle-view">
<div class="upicon-view" @click="wheelHandler({wheelDelta:1})">
<UpIcon></UpIcon>
</div>
<div class="downicon-view" :class="[isEnd ? 'no-down-video-active' : '']" @click="wheelHandler({wheelDelta:-1})">
<DownIcon></DownIcon>
</div>
</div>
视频播放完毕自动切换
未做。可根据xgplayer提供的api监听当前页面的播放结束事件 进行判断进行切换
当前可播放列表全部播放完毕时候由用户选择是重新播放还是刷新当前播放列表
/* 刷新播放列表 */
let refresgHandler = async () => {
console.log('开始刷新');
let {data:res} = await httpInstanceGetRandomKSvideoList()
_touchFishStore.setShortVideoList(res.data.brilliantTypeData.feeds)
replateVideoView()
}
/* 重新观看 */
let replateVideoView = () => {
isEnd.value = false
let ID = _touchFishStore.shortVideoList[0].photo.id
router.push('/touchfishdetail/' + ID)
}
选中当前视频条目,视频标题高亮,自动进入可视区域在侧边栏置顶
watch
根据当前视频详情路由切换进行监听
watch(() => route.params.id,(newId,oldId) => {
nextTick(() => {
isEnd.value = false
id.value = route.params.id
videoInfo.info = _touchFishStore.shortVideoList.find(item => item.photo.id == id.value) // 当前视频数据详情
console.log('当前视频信息',videoInfo.info);
videoInfo.name = videoInfo.info.photo.caption
videoInfo.author = videoInfo.info.author.name
videoInfo.headerUrl = videoInfo.info.author.headerUrl
url.value = videoInfo.info.photo.photoUrl
player.src = url.value
/* 添加切换时候左侧菜单的标题在可视区里面高亮 */
let dom = document.querySelector(`[data-id='${newId}']`)
let scrollDom = document.querySelector('.short-video-list')
scrollDom.scrollTo({
behavior:"smooth",
top:dom.offsetTop - 10
})
})
},{immediate:true})
完整详情vue文件
<script setup>
import { touchFishStore } from '@/store/touchfish';
import { layer } from '@layui/layui-vue';
import { onMounted, reactive, ref,watch , nextTick} from 'vue'
import { useRoute, useRouter } from 'vue-router';
import Player from 'xgplayer'
import {RefreshThreeIcon,DownIcon,UpIcon} from '@layui/icons-vue'
import 'xgplayer/dist/index.min.css';
import {debounce} from '@/util/utilFunction'
import {httpInstanceGetRandomKSvideoList} from '@/api/touchFish'
let _touchFishStore = touchFishStore()
const route = useRoute()
const router = useRouter()
/* 当前页面短视频id */
let id = ref('')
let url = ref('')
let isEnd = ref(false)
let videoInfo = reactive({
info:{},
name: '',
author:'',
headerUrl:'',
coverUrl: '' // 背景
})
let player = null
/* 创建播放器 */
let initPlayer = () => {
player = new Player({
id: "xg-player",
url:url.value,
fitVideoSize: 'auto',
width: 438,
height: "100%",
autoplay: true,
download: true, //设置download控件显示
loop:true,
controls:true
})
}
let shortVideoPlay = ref(null) // 获取节点
/* 切换 */
let updateVideo = (item) => {
router.push('/touchfishdetail/' + item.photo.id)
}
watch(() => route.params.id,(newId,oldId) => {
nextTick(() => {
isEnd.value = false
id.value = route.params.id
videoInfo.info = _touchFishStore.shortVideoList.find(item => item.photo.id == id.value) // 当前视频数据详情
console.log('当前视频信息',videoInfo.info);
videoInfo.name = videoInfo.info.photo.caption
videoInfo.author = videoInfo.info.author.name
videoInfo.headerUrl = videoInfo.info.author.headerUrl
url.value = videoInfo.info.photo.photoUrl
player.src = url.value
/* 添加切换时候左侧菜单的标题在可视区里面高亮 */
let dom = document.querySelector(`[data-id='${newId}']`)
let scrollDom = document.querySelector('.short-video-list')
scrollDom.scrollTo({
behavior:"smooth",
top:dom.offsetTop - 10
})
})
},{immediate:true})
let wheelHandler = (eventData) => {
/* 判断方向 */
let isTop = eventData.wheelDelta > 0 ? true : false
let ID = ''
let currentIndex = _touchFishStore.shortVideoList.findIndex(item => item.photo.id == id.value) //当前的视频在列表的索引
// 上一条
if (isTop) {
if (currentIndex == 0 ) {
layer.close()
layer.msg('已经是第一条了')
return
}
ID = _touchFishStore.shortVideoList[currentIndex-1].photo.id
router.push('/touchfishdetail/' + ID)
}else {
// 下一条
if (currentIndex == _touchFishStore.shortVideoList.length - 1) {
layer.closeAll()
layer.msg('已经是最后一条')
/* 显示遮罩 */
isEnd.value = true
console.log('isEnd',isEnd.value);
return
}
ID = _touchFishStore.shortVideoList[currentIndex+1].photo.id
console.log('ID',ID);
router.push('/touchfishdetail/' + ID)
}
}
/* 刷新播放列表 */
let refresgHandler = async () => {
console.log('开始刷新');
let {data:res} = await httpInstanceGetRandomKSvideoList()
_touchFishStore.setShortVideoList(res.data.brilliantTypeData.feeds)
replateVideoView()
}
/* 重新观看 */
let replateVideoView = () => {
isEnd.value = false
let ID = _touchFishStore.shortVideoList[0].photo.id
router.push('/touchfishdetail/' + ID)
}
onMounted(() => {
id.value = route.params.id
videoInfo.info = _touchFishStore.shortVideoList.find(item => item.photo.id == id.value) // 当前视频数据详情
videoInfo.name = videoInfo.info.photo.caption
videoInfo.author = videoInfo.info.author.name
videoInfo.coverUrl = videoInfo.info.photo.coverUrl
videoInfo.headerUrl = videoInfo.info.author.headerUrl
url.value = videoInfo.info.photo.photoUrl
initPlayer()
shortVideoPlay.value.addEventListener('wheel',wheelHandler)
})
</script>
<template>
<div class="container-short">
<div class="mask-bg" :style="{backgroundImage:`url(${videoInfo.coverUrl})`}">
</div>
<div class="mask-bg_two">
</div>
<div class="container">
<div class="left-list">
<div class="hot-title mr-tb-15 pd-lr-15">
<h1>推荐视频</h1>
<RefreshThreeIcon @click="refresgHandler"></RefreshThreeIcon>
</div>
<ul class="short-video-list">
<li class="short-video-item mr-tb-15" :data-id="item.photo.id" v-for="item in _touchFishStore.shortVideoList" :key="item.photo.id">
<div class="video-left">
<img class="video-cover" :src="item.photo.coverUrl" alt="">
</div>
<div class="video-right">
<p class="video-title" :class="[item.photo.id == id ? 'active-title' : '']" @click="updateVideo(item)">{{item.photo.caption}}</p>
<p class="viewcount">浏览量:{{item.photo.viewCount}}</p>
<p class="like-count">喜欢:{{item.photo.likeCount}}</p>
</div>
</li>
</ul>
</div>
<div class="short-video-play" ref="shortVideoPlay">
<div class="short-video-play-container">
<div id="xg-player"></div>
<div class="short-video-info pd-lr-15">
<img :src="videoInfo.headerUrl" class="author-header-url mr-tb-5" alt="" srcset="">
<p class="video-author mr-tb-5">@{{videoInfo.author}}</p>
<p class="video-title mr-tb-10">{{videoInfo.name}}</p>
</div>
<!-- 显示刷新弹窗 -->
<div class="short-video-play-mask" v-show="isEnd">
<lay-button class="refech-btn" @click="refresgHandler">刷新播放列表</lay-button>
<lay-button type="danger" class="view" @click="replateVideoView">重新观看</lay-button>
</div>
<!-- 上下视频切换箭头 -->
<div class="taggle-view">
<div class="upicon-view" @click="wheelHandler({wheelDelta:1})">
<UpIcon></UpIcon>
</div>
<div class="downicon-view" :class="[isEnd ? 'no-down-video-active' : '']" @click="wheelHandler({wheelDelta:-1})">
<DownIcon></DownIcon>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.container-short {
width: 100%;
height: 100vh;
position: relative;
overflow: hidden;
.mask-bg {
position: absolute;
top: 0px;
width: 100%;
height: 100%;
background: #000000;
// background-image: url('https://p1.a.yximgs.com/upic/2023/08/01/20/BMjAyMzA4MDEyMDIzMjBfMjkxODI3OTkyMV8xMDk0NDA2NDQ4ODNfMV8z_Bd65a14a4c1a5597f4890dfcc5423f5ee.jpg?tag=1-1696643878-xpcwebbrilliant-0-uyv6zebx9w-e5998ce9e73f435c&clientCacheKey=3x58wbhtfrjvfae.jpg&di=3b2781b2&bp=14944');
background-repeat: no-repeat;
background-size: 100%;
transform: scale(1.2);
opacity: .8;
filter: blur(15px);
}
.mask-bg_two {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
background: #000;
opacity: .6;
}
.container {
display: flex;
position: absolute;
z-index: 14;
height: 100%;
width: 100%;
left: 0px;
top: 0px;
color: #fff;
.left-list {
width: 300px;
height: 100%;
border-radius: 0px 15px 15px 0px;
background: rgba(0,0,0,.5);
.hot-title {
display: flex;
align-items: center;
// justify-content: space-between;
h1 {
display: inline-block;
margin-right: 20px;
}
::v-deep .layui-icon-refresh-three {
transition: .3s all;
cursor: pointer;
&:hover{
color: red;
}
}
}
.short-video-list {
&::-webkit-scrollbar {
display: none;
}
position: relative;
padding-bottom: 15px;
overflow-y: auto;
height: calc(100% - 47px);
.short-video-item {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 100%;
height: 80px;
overflow: hidden;
.video-left {
.video-cover {
width: 120px;
height: 75px;
object-fit: cover;
}
}
.video-right {
padding: 0px 5px;
box-sizing: border-box;
display: flex;
flex-direction: column;
flex-wrap: wrap;
justify-content: space-between;
width: 180px;
height: 100%;
overflow: hidden;
.video-title {
transition: .3s all;
width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
// text-overflow: ellipsis;
// text-overflow: -o-ellipsis-lastline;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
&:hover {
color: red;
}
}
.active-title {
color: red;
}
.viewcount {
font-size: 12px;
color: #909090;
}
.like-count {
font-size: 12px;
color: #909090;
}
}
}
}
}
/* 右侧 */
.short-video-play {
flex: 1;
display: flex;
justify-content: center;
.short-video-play-container {
position: relative;
margin: 0 auto;
// 显示的刷新的遮罩
.short-video-play-mask {
position: absolute;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.785);
.refech-btn {
margin-right: 15px;
background: red;
color: #fff;
outline: none;
border: none;
border-radius: 0;
&:hover {
background: rgb(189, 10, 10);
color: #fff;
opacity: 1;
}
}
.view {
margin-left: 15px;
outline: none;
border: none;
border-radius: 0;
background: #484847;
color: #fff;
&:hover {
background: #323232;
color: #fff;
opacity: 1;
}
}
}
.taggle-view {
position: absolute;
right: -60px;
bottom: 100px;
border-radius: 25px;
padding: 5px 5px;
background: rgba(0, 0, 0, 0.307);
.upicon-view,.downicon-view {
display: flex;
justify-content: center;
align-items: center;
transition: .3s all;
cursor: pointer;
margin: 10px 0px;
padding: 10px 0px;
border-radius: 50%;
width: 40px;
height: 40px;
}
.upicon-view:hover {
background: rgba(0, 0, 0, 0.821);
}
.downicon-view:hover {
background: rgba(0, 0, 0, 0.821);
}
/* 如果是最后一个视频时候 */
.no-down-video-active {
color: #575757;
pointer-events: none;
}
}
}
.short-video-info {
width: 40%;
position: absolute;
bottom: 60px;
left: 0px;
.author-header-url {
width: 45px;
height: 45px;
border-radius: 15px;
}
}
}
}
}
</style>
版权声明
本文系作者 @黄粱一梦 转载请注明出处,文中若有转载的以及参考文章地址也需注明。\(^o^)/~
Preview