Vue3 + Pinia + Vite5 仿抖音,完全度90% . Vue3 + Pinia + Vite5 imitate TikTok with 90% completeness
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

725 lines
20 KiB

<template>
<div id="SlideAlbum">
<div class="img-slide-wrapper">
<div class="img-slide-list"
ref="wrapperEl"
@touchstart.passive="touchStart"
@touchmove="touchMove"
@touchend="touchEnd">
<div class="img-slide-item" v-for="img in item.imgs">
<img :ref="e=>setItemRef(e,'itemRefs')"
:src="img">
</div>
</div>
</div>
<Icon icon="fluent:play-28-filled" class="pause-icon" v-if="state.status === SlideItemPlayStatus.Pause"/>
<template v-if="state.operationStatus === SlideAlbumOperationStatus.Normal">
<ItemToolbar
class="mb3r"
v-model:item="state.localItem"
:position="position"
v-bind="$attrs"
/>
<ItemDesc
class="mb3r"
v-model:item="state.localItem"
:position="position"
/>
</template>
<!--不知为啥touch事件,在下部20px的空间内不触发,加上click事件不好了 -->
<div class="progress-bar"
v-if="!state.isPreview && state.operationStatus!== SlideAlbumOperationStatus.Zooming"
@click="null"
@touchstart="progressBarTouchStart"
@touchmove="progressBarTouchMove"
@touchend="progressBarTouchMEnd"
>
<div class="bar" v-for="(img,index) in item.imgs">
<div class="progress"
:style="getWidth(index)"></div>
</div>
</div>
<Teleport to="#home-index" v-if="state.isPreview">
<div class="preview">
<div class="preview-wrapper">
<img :src="img"
:class="{'preview-img':index === state.localIndex}"
v-for="(img,index) in props.item.imgs"
:ref="e=>setItemRef(e,'previewImgs')"
>
</div>
<div class="indicator">
<span class="index">{{ state.localIndex + 1 }}</span>&nbsp;/&nbsp;{{ props.item.imgs.length }}
</div>
</div>
</Teleport>
<Teleport to="#home-index" v-if="state.operationStatus !== SlideAlbumOperationStatus.Normal">
<div class="album-toolbar">
<div class="left">
<Icon icon="iconamoon:close" @click="state.operationStatus = SlideAlbumOperationStatus.Normal"/>
</div>
<div class="right">
<Icon icon="heroicons-outline:menu-alt-1" @click="Utils.$no"/>
<Icon icon="fluent:play-28-filled"
v-if="state.status === SlideItemPlayStatus.Pause"
class="pause"
@click="startPlay"/>
<Icon icon="bi:pause-fill"
v-else
class="pause"
@click="stopPlay"/>
<Icon icon="system-uicons:push-down" @click="Utils.$no"/>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="jsx">
import enums from "../../utils/enums";
import Utils from '../../utils'
import {mat4} from 'gl-matrix'
import {Icon} from "@iconify/vue";
import {onMounted, onBeforeUpdate, reactive, ref, watch, computed, provide, nextTick, onUnmounted} from "vue";
import {
getSlideDistance,
slideInit,
slideReset,
slideTouchEnd,
slideTouchMove,
slideTouchStart
} from "./common";
import {SlideAlbumOperationStatus, SlideItemPlayStatus, SlideType} from "../../utils/const_var";
import ItemToolbar from "./ItemToolbar";
import ItemDesc from "./ItemDesc";
import GM from "../../utils";
import {cloneDeep} from "lodash";
import bus, {EVENT_KEY} from "../../utils/bus";
let out = new Float32Array([
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0
])
let ov = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
]);
let origin = cloneDeep(ov)
const rectMap = new Map()
// provide('isPlaying', computed(() => this.isPlaying))
provide('isPlaying', false)
const props = defineProps({
item: {
type: Object,
default() {
return {
type: 'imgs',
imgs: [
'https://cdn.seovx.com/ha/?mom=302',
'https://cdn.seovx.com/?mom=302',
'https://cdn.seovx.com/?mom=302',
'https://cdn.seovx.com/?mom=302',
'https://cdn.seovx.com/?mom=302',
'https://cdn.seovx.com/?mom=302',
'https://cdn.seovx.com/?mom=302',
'https://cdn.seovx.com/?mom=302',
],
"id": "034ae83b-ca0a-401a-b7c6-cf78361bae7b",
video: 'http://douyin.ttentau.top/0.mp4',
"video_data_size": 26829508,
"duration": 427780,
"desc": "我不管我们宿舍第一好看",
"allow_download": 0,
"allow_duet": 0,
"allow_react": 0,
"allow_music": 1,
"allow_douplus": 1,
"allow_share": 1,
"digg_count": 10480000,
"comment_count": 79000,
"download_count": 6,
"play_count": 0,
"share_count": 119000,
"forward_count": 0,
"collect_count": 3,
"sort": 195,
"is_top": 0,
"city": "北京",
address: '中央戏剧学院',
"musicId": "2ee213c6-3e3f-4758-ba5a-7f1c955604a4",
"create_time": "1630423555",
"creator_id": "93864497380",
"status": 1,
"topics": [
{
"id": "85ceda30-898f-4b57-b891-0e58b3ab99a9",
"name": "敬礼变装",
"creator_id": "93864497380",
"create_time": "1630423555",
"status": 1
},
{
"id": "85ceda30-898f-4b57-b891-0e58b3ab99a9",
"name": "宿舍",
"creator_id": "93864497380",
"create_time": "1630423555",
"status": 1
}
],
"music": {
"id": "cde50af2-628c-4d28-b9c6-67237a62518e",
"cover": "https://p29.douyinpic.com/img/tos-cn-avt-0015/f4de202ff2e41b523838a4a767aebd16~c5_100x100.jpeg?from=116350172",
"mp3": "https://sf3-cdn-tos.douyinstatic.com/obj/ies-music/1658584661080088.mp3",
"title": "@穷电影创作的原声-小高快起来跳舞",
"creator_id": "93864497380",
"create_time": "1630423555",
"status": 1
},
"author": {
"id": "1",
"unique_id_modify_time": "1630393144",
"unique_id": "10040050",
"favoriting_count": 143,
"avatar": new URL('../../assets/img/icon/avatar/3.png', import.meta.url).href,
school: {
name: '中央戏剧学院',
department: null,
joinTime: null,
education: null,
displayType: enums.DISPLAY_TYPE.ALL,
},
"city": "",
"province": '北京',
"country": "",
"location": "",
"birthday": "2002-01-01",
"cover": "https://p3.douyinpic.com/obj/c8510002be9a3a61aad2?from=116350172",
"following_count": 66,
"follower_count": 235000,
"aweme_count": 1796000,
"nickname": "我是小睿耶",
certification: '',
"phone": "",
"sex": "",
"last_login_time": "1630423555",
"create_time": "1630423555",
"status": 1,
"desc": `一个普普通通学表演的\n看到的人都能开开心心`,
"is_private": 0
}
}
}
},
position: {
type: Object,
default: () => {
return {
uniqueId: '',
index: '',
}
}
},
})
const judgeValue = 20
const wrapperEl = ref(null)
//用于解决,touch事件触发startPlay,然后click事件又触发stopLoop的问题
let lockDatetime = 0
const state = reactive({
name: 'SlideHorizontal',
localIndex: 0,
needCheck: true,
isPreview: false,
isZoom: false,
operationStatus: SlideAlbumOperationStatus.Normal,
next: false,
wrapper: {width: 0, height: 0, childrenLength: 0},
last: {
point1: {x: 0, y: 0},
point2: {x: 0, y: 0},
},
start: {
x: 0, y: 0,
point1: {x: 0, y: 0},
point2: {x: 0, y: 0},
center: {x: 0, y: 0},
time: 0
},
move: {x: 0, y: 0},
itemRefs: [],
previewImgs: [],
cycleFn: -1,
status: SlideItemPlayStatus.Play,
isAutoPlay: true,
localItem: props.item,
})
function stopPlay() {
state.status = SlideItemPlayStatus.Pause
stopLoop()
}
function startPlay() {
state.isAutoPlay = true
state.status = SlideItemPlayStatus.Play
startLoop()
}
function stopLoop() {
clearInterval(state.cycleFn)
state.cycleFn = -1
}
function startLoop() {
if (state.cycleFn !== -1) return
if (!state.isAutoPlay) return
state.cycleFn = setInterval(() => {
if (state.localIndex < props.item.imgs.length - 1) {
state.localIndex++
} else {
state.localIndex = 0
}
}, 1500)
}
onMounted(async () => {
await nextTick();
slideInit(wrapperEl.value, state, SlideType.HORIZONTAL)
startPlay()
// setTimeout(() => {
// state.operationStatus = SlideAlbumOperationStatus.Zooming
// }, 1000)
bus.on(EVENT_KEY.SINGLE_CLICK_BROADCAST, click)
})
onUnmounted(() => {
bus.off(EVENT_KEY.SINGLE_CLICK_BROADCAST, click)
})
function click({uniqueId, index, type}) {
// console.log('position,', type, Date.now() - lockDatetime)
if (props.position.uniqueId === uniqueId && props.position.index === index) {
// if (type === EVENT_KEY.ITEM_TOGGLE) {
// if (state.status === SlideItemPlayStatus.Play) {
// stopLoop()
// } else {
// state.isAutoPlay = true
// startLoop()
// }
// }
if (type === EVENT_KEY.ITEM_STOP) {
stopPlay()
setTimeout(() => {
state.localIndex = 0
}, 500)
}
if (type === EVENT_KEY.ITEM_PLAY) {
state.localIndex = 0
state.isAutoPlay = true
state.status = SlideItemPlayStatus.Play
startLoop()
}
}
}
// 确保在每次更新之前重置ref
onBeforeUpdate(() => {
state.itemRefs = []
state.previewImgs = []
})
watch(
() => state.localIndex,
(newVal) => {
GM.$setCss(wrapperEl.value, 'transition-duration', `300ms`)
GM.$setCss(wrapperEl.value, 'transform', `translate3d(${getSlideDistance(state, SlideType.HORIZONTAL)}px, 0, 0)`)
}
)
watch(
() => state.operationStatus,
(newVal) => {
if (newVal !== SlideAlbumOperationStatus.Normal) {
bus.emit(EVENT_KEY.ENTER_FULLSCREEN)
} else {
bus.emit(EVENT_KEY.EXIT_FULLSCREEN)
}
}
)
function calcCurrentIndex(e) {
state.isPreview = true
let x = e.touches[0].pageX
let current = -1
let length = state.previewImgs.length
for (let i = length - 1; i >= 0; i--) {
let rect = state.previewImgs[i].getBoundingClientRect()
if (rect.x < x) {
current = i
break
}
}
if (current > -1) {
state.localIndex = current
}
}
function progressBarTouchStart(e) {
Utils.$stopPropagation(e)
}
function progressBarTouchMove(e) {
Utils.$stopPropagation(e)
calcCurrentIndex(e)
}
function progressBarTouchMEnd(e) {
Utils.$stopPropagation(e)
state.isPreview = false
}
function touchStart(e) {
lockDatetime = Date.now()
// Utils.$showNoticeDialog('start'+e.touches.length)
console.log('start', e.touches.length)
if (e.touches.length === 1) {
slideTouchStart(e, wrapperEl.value, state)
} else {
state.last.point2 = state.start.point2 = {x: e.touches[1].pageX, y: e.touches[1].pageY};
if (state.operationStatus === SlideAlbumOperationStatus.Zooming) {
state.start.center = Utils.getCenter(state.start.point1, state.start.point2)
return
}
state.operationStatus = SlideAlbumOperationStatus.Zooming
state.itemRefs[state.localIndex].style['transition-duration'] = '0ms';
state.last.point1 = state.start.point1 = {x: e.touches[0].pageX, y: e.touches[0].pageY};
// state.last.point2 = state.start.point2 = {x: e.touches[1].pageX, y: e.touches[1].pageY};
state.start.center = Utils.getCenter(state.start.point1, state.start.point2)
}
}
function touchMove(e) {
// Utils.$showNoticeDialog('move'+e.touches.length)
console.log('move', e.touches.length,state.operationStatus )
let current1 = {x: e.touches[0].pageX, y: e.touches[0].pageY}
stopLoop()
//单手移动
if (e.touches.length === 1) {
if (state.operationStatus === SlideAlbumOperationStatus.Zooming) {
// console.log('m1')
Utils.$stopPropagation(e)
// console.log('单手移动',)
let movementX = current1.x - state.last.point1.x
let movementY = current1.y - state.last.point1.y
// console.log(movementX, movementY)
const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, movementX, movementY, 0, 1,]);
ov = mat4.multiply(out, t, ov);
state.itemRefs[state.localIndex].style.transform = `matrix3d(${ov.toString()})`;
state.last.point1 = current1
} else {
// console.log('m2')
state.isAutoPlay = false
slideTouchMove(e, wrapperEl.value, state, judgeValue, canNext,
() => {
}, SlideType.HORIZONTAL,
() => {
if (state.operationStatus === SlideAlbumOperationStatus.Detail) {
Utils.$stopPropagation(e)
}
})
}
} else {
// console.log('m3')
state.operationStatus = SlideAlbumOperationStatus.Zooming
Utils.$stopPropagation(e)
let rect = {x: 0, y: 0}
if (rectMap.has(state.localIndex)) {
rect = rectMap.get(state.localIndex)
} else {
//getBoundingClientRect在手机上获取不到值
let offset = $(state.itemRefs[state.localIndex]).offset()
rect = {x: offset.left, y: offset.top}
rectMap.set(state.localIndex, rect)
}
let current2 = {x: e.touches[1].pageX, y: e.touches[1].pageY}
// 双指缩放比例,就是对应的放大倍数
let currentRatio = Utils.getDistance(current1, current2) / Utils.getDistance(state.start.point1, state.start.point2);
let movementRatio = currentRatio - ov[0]
// console.log('movementRatio',movementRatio)
//如果本次比例和上次的不超过0.02。那么判定为平移
if (Math.abs(movementRatio) <= 0.02) {
let movementX = current1.x - state.last.point1.x
let movementY = current1.y - state.last.point1.y
let movement2X = current2.x - state.last.point2.x
let movement2Y = current2.y - state.last.point2.y
let minX = Math.min(movementX, movement2X)
let minY = Math.min(movementY, movement2Y)
const t1 = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
minX, minY, 0, 1
]);
ov = mat4.multiply(out, t1, ov);
} else {
let center = Utils.getCenter(current1, current2)
center.x -= rect.x
center.y -= rect.y
//用最新的放大倍数ratio除以之前的放大ov[0]倍数,算出本次要累加放大的倍数
let zoom = currentRatio / ov[0]
const x = center.x * (1 - zoom);
const y = center.y * (1 - zoom);
const t = new Float32Array([
zoom, 0, 0, 0,
0, zoom, 0, 0,
0, 0, 1, 0,
x, y, 0, 1
]);
//如果zoom是每次都是最后放大倍数,第三个参数用原值(即,矩阵x乘时,都是乘以单位矩阵)
//如果zoom是累加放大(比如每次都是0.15),第三个参数用ov。这里还是采用累加计算
ov = mat4.multiply(out, t, ov);
}
state.itemRefs[state.localIndex].style.transform = `matrix3d(${ov.toString()})`;
state.last.point1 = current1
state.last.point2 = current2
}
}
function touchEnd(e) {
console.log('Date.now() - lockDatetime', Date.now() - lockDatetime,)
if (Date.now() - lockDatetime < 300 && state.move.x === 0 && state.move.y === 0) {
if (state.status === SlideItemPlayStatus.Play) {
stopPlay()
} else {
startPlay()
}
return
}
state.isPreview = false
//这里,如果是双指触控的话,会触发两次事件,第一次touches长度为1,第二次为0
//如果是单指触控的话,触发一次事件,touches长度为0
console.log('end', e.touches.length)
// e.touches.length === 1 说明,松开了第一只手指
if (e.touches.length === 1) {
//双指缩放状态下,但只松开了一只手
if (state.operationStatus === SlideAlbumOperationStatus.Zooming) {
Utils.$stopPropagation(e)
state.last.point1 = {x: e.touches[0].pageX, y: e.touches[0].pageY}
startLoop()
}
} else {
if (state.operationStatus === SlideAlbumOperationStatus.Zooming) {
Utils.$stopPropagation(e)
ov = origin
state.itemRefs[state.localIndex].style['transition-duration'] = '300ms';
state.itemRefs[state.localIndex].style.transform = `matrix3d(${origin.toString()})`;
startLoop()
state.operationStatus = SlideAlbumOperationStatus.Look
} else {
slideTouchEnd(e, state, canNext,
() => {
console.log('nextCb')
},
() => {
console.log('doNotNextCb')
startLoop()
}
)
slideReset(wrapperEl.value, state, SlideType.HORIZONTAL, null)
}
}
}
function getWidth(index) {
if (state.localIndex >= index) return {width: '100%'}
}
function setItemRef(el, key) {
el && state[key].push(el)
}
function canNext(isNext, e) {
let res = !((state.localIndex === 0 && !isNext) || (state.localIndex === props.item.imgs.length - 1 && isNext));
if (!res && state.operationStatus === SlideAlbumOperationStatus.Detail && e) {
Utils.$stopPropagation(e)
}
return res
}
</script>
<style scoped lang="less">
@import "@/assets/less/index";
#SlideAlbum {
transition: height .3s;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
color: white;
font-size: 14rem;
.img-slide-wrapper {
position: relative;
height: 100%;
width: 100%;
.img-slide-list {
height: 100%;
width: 100%;
display: flex;
position: relative;
.img-slide-item {
height: 100%;
width: 100%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
transform-origin: 0 0;
width: 100%;
}
}
}
}
.progress-bar {
position: absolute;
width: 100%;
bottom: 10rem;
display: flex;
box-sizing: border-box;
padding: 0 5rem;
@h: 4rem;
//height: @h;
height: 10rem;
//background-color: red;
align-items: flex-end;
justify-content: space-between;
.bar {
border-radius: 10rem;
flex: 1;
margin: 0 2rem;
height: @h;
background: rgba(#000, .5);
position: relative;
overflow: hidden;
.progress {
border-radius: 10rem;
position: absolute;
left: 0;
height: @h;
background: white;
}
}
}
}
</style>
<style lang="less">
@import "@/assets/less/index";
.preview {
transition: opacity .3s;
position: fixed;
bottom: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.preview-wrapper {
img {
transition: width .3s;
margin: 0 5rem;
width: 30rem;
height: 50rem;
background-color: black;
border-radius: 3rem;
overflow: hidden;
object-fit: cover;
&.preview-img {
width: 40rem;
}
}
}
.indicator {
background: @footer-color;
width: 100%;
height: @footer-height;
color: gray;
display: flex;
align-items: center;
justify-content: center;
.index {
color: white;
}
}
}
.album-toolbar {
position: absolute;
bottom: 0;
color: white;
font-size: 24rem;
background: @footer-color;
width: 100%;
box-sizing: border-box;
height: @footer-height;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10rem;
@padding: 18rem;
.left {
height: 40rem;
background-color: rgba(71, 71, 86, 0.53);
border-radius: 10rem;
padding: 0 @padding;
display: flex;
align-items: center;
justify-content: center;
}
.right {
.left;
display: flex;
align-items: center;
gap: 20rem;
}
}
</style>