VideoPlayer.tsx 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import { useEffect, useRef, useState } from 'react'
  2. import { PlayCircleFilled } from '@ant-design/icons'
  3. interface VideoPlayerProps {
  4. src: string
  5. poster?: string
  6. /** 容器最大宽度,默认 360 */
  7. maxWidth?: number
  8. }
  9. /**
  10. * 原生 <video> 封装(替代 video.js)
  11. *
  12. * 实现细节:
  13. * - src 始终绑定到 video 元素 (不做 React state 切换),避免 setState 后
  14. * 还没 rerender 就 play() 拿到空 src 的 race condition
  15. * - preload="none" → 浏览器在用户点击前不会发任何网络请求
  16. * - 用蒙层 + 大播放按钮覆盖,点击触发 play()
  17. * - controls 在点击后才显示
  18. * - 自适应视频原生宽高比 (不强制 9:16 / 16:9)
  19. */
  20. export default function VideoPlayer({ src, poster, maxWidth = 360 }: VideoPlayerProps) {
  21. const videoRef = useRef<HTMLVideoElement | null>(null)
  22. const [started, setStarted] = useState(false)
  23. const [error, setError] = useState<string | null>(null)
  24. // src 变更(查新视频)时重置状态
  25. useEffect(() => {
  26. setStarted(false)
  27. setError(null)
  28. }, [src])
  29. const onPlayClick = () => {
  30. const v = videoRef.current
  31. if (!v) return
  32. setStarted(true)
  33. setError(null)
  34. const p = v.play()
  35. if (p && typeof p.catch === 'function') {
  36. p.catch((err) => setError(err?.message ?? String(err)))
  37. }
  38. }
  39. return (
  40. <div
  41. style={{
  42. width: '100%',
  43. maxWidth,
  44. position: 'relative',
  45. background: '#000',
  46. borderRadius: 6,
  47. overflow: 'hidden',
  48. minHeight: started ? 'auto' : 200,
  49. }}
  50. >
  51. <video
  52. ref={videoRef}
  53. src={src}
  54. poster={poster}
  55. controls={started}
  56. preload="none"
  57. playsInline
  58. onError={(e) => {
  59. const v = e.currentTarget as HTMLVideoElement
  60. const err = v.error
  61. const codeMap: Record<number, string> = {
  62. 1: 'MEDIA_ERR_ABORTED 播放被中止',
  63. 2: 'MEDIA_ERR_NETWORK 网络错误',
  64. 3: 'MEDIA_ERR_DECODE 解码失败',
  65. 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED 源不支持(URL 不可达/格式不被识别)',
  66. }
  67. setError(`${err ? codeMap[err.code] ?? `code=${err.code}` : ''} ${err?.message ?? ''}`)
  68. }}
  69. style={{
  70. width: '100%',
  71. height: 'auto',
  72. maxHeight: 540,
  73. display: 'block',
  74. }}
  75. >
  76. 您的浏览器不支持 video 标签。
  77. </video>
  78. {!started && (
  79. <button
  80. onClick={onPlayClick}
  81. aria-label="播放视频"
  82. style={{
  83. position: 'absolute',
  84. inset: 0,
  85. background: 'rgba(0,0,0,0.25)',
  86. border: 'none',
  87. color: '#fff',
  88. fontSize: 64,
  89. cursor: 'pointer',
  90. display: 'flex',
  91. alignItems: 'center',
  92. justifyContent: 'center',
  93. transition: 'background .15s',
  94. }}
  95. onMouseEnter={(e) => {
  96. e.currentTarget.style.background = 'rgba(0,0,0,0.4)'
  97. }}
  98. onMouseLeave={(e) => {
  99. e.currentTarget.style.background = 'rgba(0,0,0,0.25)'
  100. }}
  101. >
  102. <PlayCircleFilled style={{ filter: 'drop-shadow(0 2px 6px rgba(0,0,0,0.5))' }} />
  103. </button>
  104. )}
  105. {error && (
  106. <div
  107. style={{
  108. position: 'absolute',
  109. bottom: 0,
  110. left: 0,
  111. right: 0,
  112. padding: '6px 8px',
  113. background: 'rgba(255,77,79,0.9)',
  114. color: '#fff',
  115. fontSize: 12,
  116. }}
  117. >
  118. {error}
  119. </div>
  120. )}
  121. </div>
  122. )
  123. }