Files
nl-blogs/client/src/pages/VideoDetail.vue
李琦 4f4153ee0e 1. 小程序端
2. 视频优化
2026-07-30 14:11:26 +08:00

257 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="page-section block w-full">
<div class="max-w-[90rem] mx-auto w-full pt-20 md:pt-32 px-4 md:px-6 pb-12 md:pb-20">
<button @click="$router.back()" class="mb-8 text-art-muted hover:text-art-text flex items-center gap-2 text-sm">
<Icon name="arrow-left" :size="16" /> 返回
</button>
<div v-if="loading" class="flex justify-center py-20">
<div class="animate-spin h-10 w-10 border-t-2 border-art-accent rounded-full" />
</div>
<div v-else-if="error" class="text-red-400 text-center py-12">
<p class="mb-4">{{ error }}</p>
<button
type="button"
class="px-4 py-2 border border-art-border rounded-full text-sm text-art-text hover:border-art-accent transition-colors"
@click="load"
>
重试
</button>
</div>
<template v-else-if="video">
<h1 class="font-serif text-3xl md:text-4xl text-art-text mb-6">{{ video.title }}</h1>
<!-- 播放区:未挂载前封面;挂载后 DPlayer + 暂停时大按钮遮罩 -->
<div
v-if="video.videoUrl"
class="w-full aspect-video rounded-xl overflow-hidden bg-black mb-6 relative"
>
<!-- DPlayer 容器:playerReady 后始终保留,暂停不销毁 -->
<div
v-show="playerReady"
ref="playerRef"
class="absolute inset-0 w-full h-full"
/>
<!-- 大播放按钮:初始 / 暂停时显示;点击开始或继续播 -->
<div
v-if="showPlayOverlay"
class="absolute inset-0 z-20 cursor-pointer group"
role="button"
tabindex="0"
aria-label="播放视频"
@click="onOverlayPlay"
@keydown.enter.prevent="onOverlayPlay"
@keydown.space.prevent="onOverlayPlay"
>
<img
v-if="!playerReady && coverSrc"
:src="coverSrc"
class="w-full h-full object-cover"
alt=""
/>
<div
v-else-if="!playerReady"
class="w-full h-full bg-black/60"
/>
<div
class="absolute inset-0 transition-colors"
:class="playerReady ? 'bg-black/40' : 'bg-black/35 group-hover:bg-black/25'"
/>
<div
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-20 h-20 md:w-24 md:h-24 rounded-full border border-white/40 bg-black/40 backdrop-blur-md flex items-center justify-center transition-transform group-hover:scale-105 group-active:scale-95 z-10"
>
<Icon name="play" :size="32" class="text-white ml-1" />
</div>
</div>
</div>
<div
v-else
class="w-full aspect-video rounded-xl overflow-hidden bg-black/40 border border-art-border flex items-center justify-center mb-6"
>
<p class="text-art-muted text-sm">暂无视频地址</p>
</div>
<!-- 专辑内前后导航:前 2 + 后 2 -->
<div v-if="video.albumContext" class="mb-8">
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-art-muted">
来自专辑 <span class="text-art-text">{{ video.albumContext.albumName }}</span>
</p>
<router-link
:to="`/videos/albums/${video.albumContext.albumId}`"
class="text-xs text-art-accent hover:underline"
>
返回专辑
</router-link>
</div>
<div class="flex gap-3 overflow-x-auto pb-2">
<div
v-for="pv in video.albumContext.prevVideos"
:key="'prev-' + pv.id"
class="shrink-0 w-36 cursor-pointer group"
@click="router.push(`/videos/${pv.id}`)"
>
<div class="aspect-video rounded-lg overflow-hidden border border-art-border group-hover:border-art-accent/50 transition-colors bg-black/40">
<img v-if="pv.cover" :src="pv.cover" class="w-full h-full object-cover opacity-80 group-hover:opacity-100" alt="" />
</div>
<p class="text-xs text-art-muted mt-1 line-clamp-2 group-hover:text-art-text">{{ pv.title }}</p>
</div>
<div class="shrink-0 w-36">
<div class="aspect-video rounded-lg overflow-hidden border-2 border-art-accent bg-black/40">
<img v-if="video.cover" :src="video.cover" class="w-full h-full object-cover" alt="" />
</div>
<p class="text-xs text-art-accent mt-1 line-clamp-2 font-medium">
{{ playerReady && !showPlayOverlay ? '正在播放' : '当前视频' }}
</p>
</div>
<div
v-for="nv in video.albumContext.nextVideos"
:key="'next-' + nv.id"
class="shrink-0 w-36 cursor-pointer group"
@click="router.push(`/videos/${nv.id}`)"
>
<div class="aspect-video rounded-lg overflow-hidden border border-art-border group-hover:border-art-accent/50 transition-colors bg-black/40">
<img v-if="nv.cover" :src="nv.cover" class="w-full h-full object-cover opacity-80 group-hover:opacity-100" alt="" />
</div>
<p class="text-xs text-art-muted mt-1 line-clamp-2 group-hover:text-art-text">{{ nv.title }}</p>
</div>
</div>
</div>
<div class="max-w-3xl">
<div class="flex gap-4 text-sm text-art-muted mb-4">
<span v-if="video.categoryName">{{ video.categoryName }}</span>
<span v-if="video.createdAt">发布于 {{ video.createdAt }}</span>
</div>
<p v-if="video.description" class="text-art-text/80 leading-relaxed whitespace-pre-wrap">{{ video.description }}</p>
</div>
</template>
</div>
</section>
</template>
<script setup lang="ts">
/**
* 视频详情:封面大按钮点播;暂停时再次显示大按钮(不销毁播放器)。
* 恢复 rounded-xl / rounded-lg 与 w-full,避免 PC 端观感变窄变生硬。
*/
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DPlayer from 'dplayer'
import 'dplayer/dist/DPlayer.min.css'
import { fetchVideo, type VideoDetailItem } from '../services/api'
import Icon from '../components/Icon.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
const video = ref<VideoDetailItem | null>(null)
/** 已挂载 DPlayer */
const playerReady = ref(false)
/** 初始或暂停时显示大播放按钮 */
const showPlayOverlay = ref(true)
const playerRef = ref<HTMLElement | null>(null)
let dp: DPlayer | null = null
const coverSrc = computed(() => video.value?.poster || video.value?.cover || '')
/** 销毁 DPlayer,释放资源 */
function destroyPlayer() {
if (dp) {
dp.destroy()
dp = null
}
playerReady.value = false
}
/** 绑定 play/pause,驱动大按钮显隐 */
function bindPlayerEvents(player: DPlayer) {
player.on('play', () => {
showPlayOverlay.value = false
})
player.on('pause', () => {
showPlayOverlay.value = true
})
}
/** 首次创建播放器并播放 */
async function initPlayer() {
destroyPlayer()
playerReady.value = true
await nextTick()
if (!playerRef.value || !video.value?.videoUrl) {
playerReady.value = false
return
}
dp = new DPlayer({
container: playerRef.value,
video: {
url: video.value.videoUrl,
pic: video.value.poster || video.value.cover,
},
autoplay: false,
theme: '#d4b383',
lang: 'zh-cn',
})
bindPlayerEvents(dp)
try {
dp.play()
showPlayOverlay.value = false
} catch {
showPlayOverlay.value = true
}
}
/**
* 点击大按钮:未就绪则初始化;已就绪则继续 play。
* 用户手势触发,利于移动端绕过自动播限制。
*/
async function onOverlayPlay() {
if (!video.value?.videoUrl) return
if (!playerReady.value || !dp) {
showPlayOverlay.value = false
await initPlayer()
return
}
try {
dp.play()
showPlayOverlay.value = false
} catch {
showPlayOverlay.value = true
}
}
/** 加载视频详情;切换条目时重置播放态 */
async function load() {
loading.value = true
error.value = ''
showPlayOverlay.value = true
destroyPlayer()
try {
video.value = await fetchVideo(route.params.id as string)
} catch {
error.value = '加载视频失败'
video.value = null
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => route.params.id, load)
onBeforeUnmount(() => {
destroyPlayer()
showPlayOverlay.value = true
})
</script>
<style scoped>
:deep(.dplayer) {
width: 100% !important;
height: 100% !important;
}
</style>