707 lines
16 KiB
Vue
707 lines
16 KiB
Vue
![]() |
<template>
|
|||
|
<div class="video-player-wrapper">
|
|||
|
<div class="video-container" ref="videoContainer">
|
|||
|
<!-- 视频播放器 -->
|
|||
|
<video
|
|||
|
ref="videoElement"
|
|||
|
class="video-element"
|
|||
|
:poster="poster"
|
|||
|
@loadedmetadata="onLoadedMetadata"
|
|||
|
@timeupdate="onTimeUpdate"
|
|||
|
@play="onPlay"
|
|||
|
@pause="onPause"
|
|||
|
@ended="onEnded"
|
|||
|
@error="onError"
|
|||
|
preload="metadata"
|
|||
|
playsinline
|
|||
|
webkit-playsinline
|
|||
|
crossorigin="anonymous"
|
|||
|
>
|
|||
|
您的浏览器不支持视频播放。
|
|||
|
</video>
|
|||
|
|
|||
|
<!-- 播放按钮覆盖层 -->
|
|||
|
<div v-if="showPlayButton" class="play-overlay" @click="togglePlay">
|
|||
|
<div class="play-button">
|
|||
|
<svg width="80" height="80" viewBox="0 0 80 80">
|
|||
|
<circle cx="40" cy="40" r="36" fill="rgba(0,0,0,0.7)" stroke="rgba(255,255,255,0.8)" stroke-width="2"/>
|
|||
|
<path d="M32 24L32 56L56 40L32 24Z" fill="white"/>
|
|||
|
</svg>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 加载状态 -->
|
|||
|
<div v-if="loading" class="loading-overlay">
|
|||
|
<div class="loading-spinner">
|
|||
|
<svg width="40" height="40" viewBox="0 0 40 40">
|
|||
|
<circle cx="20" cy="20" r="16" stroke="rgba(255,255,255,0.3)" stroke-width="3" fill="none"/>
|
|||
|
<circle cx="20" cy="20" r="16" stroke="white" stroke-width="3" fill="none"
|
|||
|
stroke-dasharray="100" stroke-dashoffset="75" stroke-linecap="round">
|
|||
|
<animateTransform attributeName="transform" type="rotate" dur="1s" repeatCount="indefinite" values="0 20 20;360 20 20"/>
|
|||
|
</circle>
|
|||
|
</svg>
|
|||
|
</div>
|
|||
|
<p>加载中...</p>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 错误状态 -->
|
|||
|
<div v-if="error" class="error-overlay">
|
|||
|
<div class="error-content">
|
|||
|
<svg width="60" height="60" viewBox="0 0 60 60" fill="none">
|
|||
|
<circle cx="30" cy="30" r="25" stroke="#ff4757" stroke-width="3"/>
|
|||
|
<path d="M20 20L40 40M40 20L20 40" stroke="#ff4757" stroke-width="3" stroke-linecap="round"/>
|
|||
|
</svg>
|
|||
|
<p>视频加载失败</p>
|
|||
|
<button class="retry-button" @click="retryLoad">重试</button>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 自定义控制栏 -->
|
|||
|
<div v-if="showControls && !error" class="video-controls" :class="{ 'controls-visible': controlsVisible }">
|
|||
|
<!-- 进度条 -->
|
|||
|
<div class="progress-container" @click="seekTo" @mousemove="showProgressPreview" @mouseleave="hideProgressPreview">
|
|||
|
<div class="progress-track">
|
|||
|
<div class="progress-buffer" :style="{ width: bufferPercent + '%' }"></div>
|
|||
|
<div class="progress-played" :style="{ width: progressPercent + '%' }"></div>
|
|||
|
<div class="progress-thumb" :style="{ left: progressPercent + '%' }"></div>
|
|||
|
</div>
|
|||
|
<!-- 进度预览 -->
|
|||
|
<div v-if="showPreview" class="progress-preview" :style="{ left: previewPosition + '%' }">
|
|||
|
{{ formatTime(previewTime) }}
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 控制按钮 -->
|
|||
|
<div class="controls-row">
|
|||
|
<div class="controls-left">
|
|||
|
<button class="control-btn play-btn" @click="togglePlay">
|
|||
|
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24">
|
|||
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
|||
|
</svg>
|
|||
|
<svg v-else width="24" height="24" viewBox="0 0 24 24">
|
|||
|
<path d="M6 4H10V20H6V4ZM14 4H18V20H14V4Z" fill="currentColor"/>
|
|||
|
</svg>
|
|||
|
</button>
|
|||
|
|
|||
|
<div class="time-display">
|
|||
|
<span class="current-time">{{ formatTime(currentTime) }}</span>
|
|||
|
<span class="separator">/</span>
|
|||
|
<span class="total-time">{{ formatTime(duration) }}</span>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<div class="controls-right">
|
|||
|
<!-- 音量控制 -->
|
|||
|
<div class="volume-container">
|
|||
|
<button class="control-btn volume-btn" @click="toggleMute">
|
|||
|
<svg v-if="volume > 50 && !muted" width="20" height="20" viewBox="0 0 20 20">
|
|||
|
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
|||
|
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
|
|||
|
<path d="M14 5C15 6 15.5 7.5 15.5 9S15 12 14 13" stroke="currentColor" stroke-width="1"/>
|
|||
|
</svg>
|
|||
|
<svg v-else-if="volume > 0 && !muted" width="20" height="20" viewBox="0 0 20 20">
|
|||
|
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
|||
|
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
|
|||
|
</svg>
|
|||
|
<svg v-else width="20" height="20" viewBox="0 0 20 20">
|
|||
|
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
|||
|
<path d="M12 7L16 11M16 7L12 11" stroke="currentColor" stroke-width="1.5"/>
|
|||
|
</svg>
|
|||
|
</button>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 全屏按钮 -->
|
|||
|
<button class="control-btn fullscreen-btn" @click="toggleFullscreen">
|
|||
|
<svg width="20" height="20" viewBox="0 0 20 20">
|
|||
|
<path d="M3 3H7V5H5V7H3V3ZM13 3H17V7H15V5H13V3ZM17 13V17H13V15H15V13H17ZM7 17H3V13H5V15H7V17Z" fill="currentColor"/>
|
|||
|
</svg>
|
|||
|
</button>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 视频信息 -->
|
|||
|
<div v-if="title" class="video-info">
|
|||
|
<h3 class="video-title">{{ title }}</h3>
|
|||
|
<p v-if="description" class="video-description">{{ description }}</p>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</template>
|
|||
|
|
|||
|
<script setup lang="ts">
|
|||
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|||
|
import Hls from 'hls.js'
|
|||
|
|
|||
|
// Props
|
|||
|
interface Props {
|
|||
|
videoUrl: string
|
|||
|
title?: string
|
|||
|
description?: string
|
|||
|
poster?: string
|
|||
|
autoplay?: boolean
|
|||
|
showControls?: boolean
|
|||
|
}
|
|||
|
|
|||
|
const props = withDefaults(defineProps<Props>(), {
|
|||
|
autoplay: false,
|
|||
|
showControls: true
|
|||
|
})
|
|||
|
|
|||
|
// Emits
|
|||
|
const emit = defineEmits<{
|
|||
|
play: []
|
|||
|
pause: []
|
|||
|
ended: []
|
|||
|
timeupdate: [time: number]
|
|||
|
error: [error: Event]
|
|||
|
}>()
|
|||
|
|
|||
|
// Refs
|
|||
|
const videoElement = ref<HTMLVideoElement>()
|
|||
|
const videoContainer = ref<HTMLDivElement>()
|
|||
|
|
|||
|
// HLS实例
|
|||
|
let hls: Hls | null = null
|
|||
|
|
|||
|
// 播放状态
|
|||
|
const isPlaying = ref(false)
|
|||
|
const loading = ref(false)
|
|||
|
const error = ref(false)
|
|||
|
const currentTime = ref(0)
|
|||
|
const duration = ref(0)
|
|||
|
const volume = ref(100)
|
|||
|
const muted = ref(false)
|
|||
|
|
|||
|
// 控制栏状态
|
|||
|
const controlsVisible = ref(true)
|
|||
|
const showPlayButton = ref(true)
|
|||
|
const showPreview = ref(false)
|
|||
|
const previewPosition = ref(0)
|
|||
|
const previewTime = ref(0)
|
|||
|
|
|||
|
// 计算属性
|
|||
|
const progressPercent = computed(() => {
|
|||
|
if (duration.value === 0) return 0
|
|||
|
return (currentTime.value / duration.value) * 100
|
|||
|
})
|
|||
|
|
|||
|
const bufferPercent = computed(() => {
|
|||
|
// 简化的缓冲进度,实际应该从video.buffered获取
|
|||
|
return Math.min(progressPercent.value + 10, 100)
|
|||
|
})
|
|||
|
|
|||
|
// 监听视频URL变化
|
|||
|
watch(() => props.videoUrl, (newUrl) => {
|
|||
|
console.log('VideoPlayer: 视频URL变化:', newUrl)
|
|||
|
if (newUrl && videoElement.value) {
|
|||
|
loadVideo()
|
|||
|
}
|
|||
|
})
|
|||
|
|
|||
|
// 视频事件处理
|
|||
|
const onLoadedMetadata = () => {
|
|||
|
if (videoElement.value) {
|
|||
|
duration.value = videoElement.value.duration
|
|||
|
loading.value = false
|
|||
|
error.value = false
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
const onTimeUpdate = () => {
|
|||
|
if (videoElement.value) {
|
|||
|
currentTime.value = videoElement.value.currentTime
|
|||
|
emit('timeupdate', currentTime.value)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
const onPlay = () => {
|
|||
|
isPlaying.value = true
|
|||
|
showPlayButton.value = false
|
|||
|
emit('play')
|
|||
|
}
|
|||
|
|
|||
|
const onPause = () => {
|
|||
|
isPlaying.value = false
|
|||
|
showPlayButton.value = true
|
|||
|
emit('pause')
|
|||
|
}
|
|||
|
|
|||
|
const onEnded = () => {
|
|||
|
isPlaying.value = false
|
|||
|
showPlayButton.value = true
|
|||
|
emit('ended')
|
|||
|
}
|
|||
|
|
|||
|
const onError = (event: Event) => {
|
|||
|
error.value = true
|
|||
|
loading.value = false
|
|||
|
emit('error', event)
|
|||
|
}
|
|||
|
|
|||
|
// 控制方法
|
|||
|
const togglePlay = async () => {
|
|||
|
if (!videoElement.value) return
|
|||
|
|
|||
|
try {
|
|||
|
if (isPlaying.value) {
|
|||
|
videoElement.value.pause()
|
|||
|
} else {
|
|||
|
await videoElement.value.play()
|
|||
|
}
|
|||
|
} catch (err) {
|
|||
|
console.error('播放控制失败:', err)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
const seekTo = (event: MouseEvent) => {
|
|||
|
if (!videoElement.value) return
|
|||
|
|
|||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|||
|
const percent = (event.clientX - rect.left) / rect.width
|
|||
|
const newTime = percent * duration.value
|
|||
|
|
|||
|
videoElement.value.currentTime = newTime
|
|||
|
}
|
|||
|
|
|||
|
const showProgressPreview = (event: MouseEvent) => {
|
|||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|||
|
const percent = (event.clientX - rect.left) / rect.width
|
|||
|
const time = percent * duration.value
|
|||
|
|
|||
|
previewPosition.value = Math.max(0, Math.min(100, percent * 100))
|
|||
|
previewTime.value = Math.max(0, Math.min(duration.value, time))
|
|||
|
showPreview.value = true
|
|||
|
}
|
|||
|
|
|||
|
const hideProgressPreview = () => {
|
|||
|
showPreview.value = false
|
|||
|
}
|
|||
|
|
|||
|
const toggleMute = () => {
|
|||
|
if (!videoElement.value) return
|
|||
|
|
|||
|
videoElement.value.muted = !videoElement.value.muted
|
|||
|
muted.value = videoElement.value.muted
|
|||
|
}
|
|||
|
|
|||
|
const toggleFullscreen = () => {
|
|||
|
if (!videoContainer.value) return
|
|||
|
|
|||
|
if (!document.fullscreenElement) {
|
|||
|
videoContainer.value.requestFullscreen()
|
|||
|
} else {
|
|||
|
document.exitFullscreen()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
const formatTime = (seconds: number): string => {
|
|||
|
const hours = Math.floor(seconds / 3600)
|
|||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|||
|
const secs = Math.floor(seconds % 60)
|
|||
|
|
|||
|
if (hours > 0) {
|
|||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
|||
|
}
|
|||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
|||
|
}
|
|||
|
|
|||
|
const loadVideo = () => {
|
|||
|
if (!videoElement.value || !props.videoUrl) return
|
|||
|
|
|||
|
loading.value = true
|
|||
|
error.value = false
|
|||
|
|
|||
|
// 清理之前的HLS实例
|
|||
|
if (hls) {
|
|||
|
hls.destroy()
|
|||
|
hls = null
|
|||
|
}
|
|||
|
|
|||
|
// 检查是否是HLS视频
|
|||
|
if (props.videoUrl.includes('.m3u8')) {
|
|||
|
// 使用HLS.js加载HLS视频
|
|||
|
if (Hls.isSupported()) {
|
|||
|
hls = new Hls({
|
|||
|
enableWorker: true,
|
|||
|
lowLatencyMode: true,
|
|||
|
backBufferLength: 90
|
|||
|
})
|
|||
|
|
|||
|
hls.loadSource(props.videoUrl)
|
|||
|
hls.attachMedia(videoElement.value)
|
|||
|
|
|||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|||
|
console.log('HLS manifest parsed successfully')
|
|||
|
loading.value = false
|
|||
|
})
|
|||
|
|
|||
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|||
|
console.error('HLS error:', data)
|
|||
|
if (data.fatal) {
|
|||
|
error.value = true
|
|||
|
loading.value = false
|
|||
|
emit('error', new Event('error'))
|
|||
|
}
|
|||
|
})
|
|||
|
} else if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) {
|
|||
|
// Safari原生支持HLS
|
|||
|
videoElement.value.src = props.videoUrl
|
|||
|
videoElement.value.load()
|
|||
|
} else {
|
|||
|
console.error('HLS not supported')
|
|||
|
error.value = true
|
|||
|
loading.value = false
|
|||
|
}
|
|||
|
} else {
|
|||
|
// 普通视频文件
|
|||
|
videoElement.value.src = props.videoUrl
|
|||
|
videoElement.value.load()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
const retryLoad = () => {
|
|||
|
loadVideo()
|
|||
|
}
|
|||
|
|
|||
|
// 自动隐藏控制栏
|
|||
|
let hideControlsTimer: number | null = null
|
|||
|
|
|||
|
const showControls = () => {
|
|||
|
controlsVisible.value = true
|
|||
|
if (hideControlsTimer) {
|
|||
|
clearTimeout(hideControlsTimer)
|
|||
|
}
|
|||
|
hideControlsTimer = window.setTimeout(() => {
|
|||
|
if (isPlaying.value) {
|
|||
|
controlsVisible.value = false
|
|||
|
}
|
|||
|
}, 3000)
|
|||
|
}
|
|||
|
|
|||
|
const onMouseMove = () => {
|
|||
|
showControls()
|
|||
|
}
|
|||
|
|
|||
|
// 生命周期
|
|||
|
onMounted(() => {
|
|||
|
nextTick(() => {
|
|||
|
if (props.videoUrl) {
|
|||
|
loadVideo()
|
|||
|
}
|
|||
|
|
|||
|
// 添加鼠标移动监听
|
|||
|
if (videoContainer.value) {
|
|||
|
videoContainer.value.addEventListener('mousemove', onMouseMove)
|
|||
|
}
|
|||
|
})
|
|||
|
})
|
|||
|
|
|||
|
onUnmounted(() => {
|
|||
|
if (hideControlsTimer) {
|
|||
|
clearTimeout(hideControlsTimer)
|
|||
|
}
|
|||
|
|
|||
|
if (videoContainer.value) {
|
|||
|
videoContainer.value.removeEventListener('mousemove', onMouseMove)
|
|||
|
}
|
|||
|
|
|||
|
// 清理HLS实例
|
|||
|
if (hls) {
|
|||
|
hls.destroy()
|
|||
|
hls = null
|
|||
|
}
|
|||
|
})
|
|||
|
|
|||
|
// 暴露方法给父组件
|
|||
|
defineExpose({
|
|||
|
play: () => videoElement.value?.play(),
|
|||
|
pause: () => videoElement.value?.pause(),
|
|||
|
seek: (time: number) => {
|
|||
|
if (videoElement.value) {
|
|||
|
videoElement.value.currentTime = time
|
|||
|
}
|
|||
|
},
|
|||
|
setVolume: (vol: number) => {
|
|||
|
if (videoElement.value) {
|
|||
|
videoElement.value.volume = vol / 100
|
|||
|
volume.value = vol
|
|||
|
}
|
|||
|
}
|
|||
|
})
|
|||
|
</script>
|
|||
|
|
|||
|
<style scoped>
|
|||
|
.video-player-wrapper {
|
|||
|
width: 100%;
|
|||
|
background: #000;
|
|||
|
border-radius: 8px;
|
|||
|
overflow: hidden;
|
|||
|
}
|
|||
|
|
|||
|
.video-container {
|
|||
|
position: relative;
|
|||
|
width: 100%;
|
|||
|
aspect-ratio: 16/9;
|
|||
|
background: #000;
|
|||
|
overflow: hidden;
|
|||
|
}
|
|||
|
|
|||
|
.video-element {
|
|||
|
width: 100%;
|
|||
|
height: 100%;
|
|||
|
object-fit: contain;
|
|||
|
background: #000;
|
|||
|
}
|
|||
|
|
|||
|
/* 播放按钮覆盖层 */
|
|||
|
.play-overlay {
|
|||
|
position: absolute;
|
|||
|
top: 0;
|
|||
|
left: 0;
|
|||
|
right: 0;
|
|||
|
bottom: 0;
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
justify-content: center;
|
|||
|
background: rgba(0, 0, 0, 0.3);
|
|||
|
cursor: pointer;
|
|||
|
transition: opacity 0.3s;
|
|||
|
}
|
|||
|
|
|||
|
.play-button {
|
|||
|
transition: transform 0.3s;
|
|||
|
}
|
|||
|
|
|||
|
.play-overlay:hover .play-button {
|
|||
|
transform: scale(1.1);
|
|||
|
}
|
|||
|
|
|||
|
/* 加载状态 */
|
|||
|
.loading-overlay {
|
|||
|
position: absolute;
|
|||
|
top: 0;
|
|||
|
left: 0;
|
|||
|
right: 0;
|
|||
|
bottom: 0;
|
|||
|
display: flex;
|
|||
|
flex-direction: column;
|
|||
|
align-items: center;
|
|||
|
justify-content: center;
|
|||
|
background: rgba(0, 0, 0, 0.8);
|
|||
|
color: white;
|
|||
|
}
|
|||
|
|
|||
|
.loading-spinner {
|
|||
|
margin-bottom: 16px;
|
|||
|
}
|
|||
|
|
|||
|
/* 错误状态 */
|
|||
|
.error-overlay {
|
|||
|
position: absolute;
|
|||
|
top: 0;
|
|||
|
left: 0;
|
|||
|
right: 0;
|
|||
|
bottom: 0;
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
justify-content: center;
|
|||
|
background: rgba(0, 0, 0, 0.8);
|
|||
|
color: white;
|
|||
|
}
|
|||
|
|
|||
|
.error-content {
|
|||
|
text-align: center;
|
|||
|
}
|
|||
|
|
|||
|
.retry-button {
|
|||
|
margin-top: 16px;
|
|||
|
padding: 8px 16px;
|
|||
|
background: #1890ff;
|
|||
|
color: white;
|
|||
|
border: none;
|
|||
|
border-radius: 4px;
|
|||
|
cursor: pointer;
|
|||
|
transition: background-color 0.3s;
|
|||
|
}
|
|||
|
|
|||
|
.retry-button:hover {
|
|||
|
background: #40a9ff;
|
|||
|
}
|
|||
|
|
|||
|
/* 视频控制栏 */
|
|||
|
.video-controls {
|
|||
|
position: absolute;
|
|||
|
bottom: 0;
|
|||
|
left: 0;
|
|||
|
right: 0;
|
|||
|
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 50%, transparent 100%);
|
|||
|
padding: 16px;
|
|||
|
transform: translateY(100%);
|
|||
|
transition: transform 0.3s ease;
|
|||
|
}
|
|||
|
|
|||
|
.video-controls.controls-visible {
|
|||
|
transform: translateY(0);
|
|||
|
}
|
|||
|
|
|||
|
.video-container:hover .video-controls {
|
|||
|
transform: translateY(0);
|
|||
|
}
|
|||
|
|
|||
|
/* 进度条 */
|
|||
|
.progress-container {
|
|||
|
position: relative;
|
|||
|
margin-bottom: 12px;
|
|||
|
cursor: pointer;
|
|||
|
}
|
|||
|
|
|||
|
.progress-track {
|
|||
|
height: 4px;
|
|||
|
background: rgba(255, 255, 255, 0.3);
|
|||
|
border-radius: 2px;
|
|||
|
position: relative;
|
|||
|
overflow: hidden;
|
|||
|
}
|
|||
|
|
|||
|
.progress-buffer {
|
|||
|
position: absolute;
|
|||
|
top: 0;
|
|||
|
left: 0;
|
|||
|
height: 100%;
|
|||
|
background: rgba(255, 255, 255, 0.5);
|
|||
|
transition: width 0.3s;
|
|||
|
}
|
|||
|
|
|||
|
.progress-played {
|
|||
|
position: absolute;
|
|||
|
top: 0;
|
|||
|
left: 0;
|
|||
|
height: 100%;
|
|||
|
background: #1890ff;
|
|||
|
transition: width 0.1s;
|
|||
|
}
|
|||
|
|
|||
|
.progress-thumb {
|
|||
|
position: absolute;
|
|||
|
top: 50%;
|
|||
|
width: 12px;
|
|||
|
height: 12px;
|
|||
|
background: #1890ff;
|
|||
|
border-radius: 50%;
|
|||
|
transform: translate(-50%, -50%);
|
|||
|
opacity: 0;
|
|||
|
transition: opacity 0.3s;
|
|||
|
}
|
|||
|
|
|||
|
.progress-container:hover .progress-thumb {
|
|||
|
opacity: 1;
|
|||
|
}
|
|||
|
|
|||
|
.progress-preview {
|
|||
|
position: absolute;
|
|||
|
bottom: 100%;
|
|||
|
transform: translateX(-50%);
|
|||
|
background: rgba(0, 0, 0, 0.8);
|
|||
|
color: white;
|
|||
|
padding: 4px 8px;
|
|||
|
border-radius: 4px;
|
|||
|
font-size: 12px;
|
|||
|
margin-bottom: 8px;
|
|||
|
pointer-events: none;
|
|||
|
}
|
|||
|
|
|||
|
/* 控制按钮行 */
|
|||
|
.controls-row {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
justify-content: space-between;
|
|||
|
}
|
|||
|
|
|||
|
.controls-left,
|
|||
|
.controls-right {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
gap: 12px;
|
|||
|
}
|
|||
|
|
|||
|
.control-btn {
|
|||
|
background: none;
|
|||
|
border: none;
|
|||
|
color: white;
|
|||
|
cursor: pointer;
|
|||
|
padding: 8px;
|
|||
|
border-radius: 4px;
|
|||
|
transition: background-color 0.3s;
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
justify-content: center;
|
|||
|
}
|
|||
|
|
|||
|
.control-btn:hover {
|
|||
|
background: rgba(255, 255, 255, 0.2);
|
|||
|
}
|
|||
|
|
|||
|
.play-btn {
|
|||
|
padding: 12px;
|
|||
|
}
|
|||
|
|
|||
|
.time-display {
|
|||
|
color: white;
|
|||
|
font-size: 14px;
|
|||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
|||
|
}
|
|||
|
|
|||
|
.separator {
|
|||
|
margin: 0 4px;
|
|||
|
opacity: 0.7;
|
|||
|
}
|
|||
|
|
|||
|
.volume-container {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
}
|
|||
|
|
|||
|
/* 视频信息 */
|
|||
|
.video-info {
|
|||
|
padding: 16px;
|
|||
|
background: white;
|
|||
|
}
|
|||
|
|
|||
|
.video-title {
|
|||
|
font-size: 18px;
|
|||
|
font-weight: 600;
|
|||
|
color: #333;
|
|||
|
margin: 0 0 8px 0;
|
|||
|
}
|
|||
|
|
|||
|
.video-description {
|
|||
|
font-size: 14px;
|
|||
|
color: #666;
|
|||
|
line-height: 1.5;
|
|||
|
margin: 0;
|
|||
|
}
|
|||
|
|
|||
|
/* 响应式设计 */
|
|||
|
@media (max-width: 768px) {
|
|||
|
.video-controls {
|
|||
|
padding: 12px;
|
|||
|
}
|
|||
|
|
|||
|
.controls-row {
|
|||
|
flex-direction: column;
|
|||
|
gap: 8px;
|
|||
|
}
|
|||
|
|
|||
|
.controls-left,
|
|||
|
.controls-right {
|
|||
|
width: 100%;
|
|||
|
justify-content: center;
|
|||
|
}
|
|||
|
|
|||
|
.time-display {
|
|||
|
font-size: 12px;
|
|||
|
}
|
|||
|
}
|
|||
|
</style>
|