ぼざクリタグ広場 https://hub.nizika.monster
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.
 
 
 
 
 
 

240 lines
7.2 KiB

  1. import { forwardRef,
  2. useCallback,
  3. useEffect,
  4. useImperativeHandle,
  5. useLayoutEffect,
  6. useMemo,
  7. useRef,
  8. useState } from 'react'
  9. import type { CSSProperties, ForwardedRef } from 'react'
  10. import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '@/types'
  11. type NiconicoPlayerMessage =
  12. | { eventName: 'enterProgrammaticFullScreen' }
  13. | { eventName: 'exitProgrammaticFullScreen' }
  14. | { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
  15. | { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
  16. | { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
  17. | { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
  18. type NiconicoCommand =
  19. | { eventName: 'play'; sourceConnectorType: 1; playerId: string }
  20. | { eventName: 'pause'; sourceConnectorType: 1; playerId: string }
  21. | { eventName: 'seek'; sourceConnectorType: 1; playerId: string; data: { time: number } }
  22. | { eventName: 'mute'; sourceConnectorType: 1; playerId: string; data: { mute: boolean } }
  23. | { eventName: 'volumeChange'; sourceConnectorType: 1; playerId: string;
  24. data: { volume: number } }
  25. | { eventName: 'commentVisibilityChange'; sourceConnectorType: 1; playerId: string;
  26. data: { commentVisibility: boolean } }
  27. const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
  28. type Props = {
  29. id: string
  30. width: number
  31. height: number
  32. style?: CSSProperties
  33. onLoadComplete?: (info: NiconicoVideoInfo) => void
  34. onMetadataChange?: (meta: NiconicoMetadata) => void }
  35. export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
  36. const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props
  37. const iframeRef = useRef<HTMLIFrameElement> (null)
  38. const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
  39. const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
  40. const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
  41. const [landscape, setLandscape] = useState<boolean> (false)
  42. const [fullScreen, setFullScreen] = useState<boolean> (false)
  43. const src =
  44. `${ EMBED_ORIGIN }/watch/${ id }`
  45. + '?jsapi=1'
  46. + `&playerId=${ encodeURIComponent (playerId) }`
  47. + '&persistence=1'
  48. + '&oldScript=1'
  49. + '&referer='
  50. + '&from=0'
  51. + '&allowProgrammaticFullScreen=1'
  52. const styleFullScreen: CSSProperties =
  53. fullScreen
  54. ? { top: 0,
  55. left: landscape ? 0 : '100%',
  56. position: 'fixed',
  57. width: screenWidth,
  58. height: screenHeight,
  59. zIndex: 2_147_483_647,
  60. maxWidth: 'none',
  61. transformOrigin: '0% 0%',
  62. transform: landscape ? 'none' : 'rotate(90deg)',
  63. WebkitTransformOrigin: '0% 0%',
  64. WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
  65. : { }
  66. const margedStyle: CSSProperties =
  67. { border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
  68. const postToPlayer = useCallback ((message: NiconicoCommand) => {
  69. const win = iframeRef.current?.contentWindow
  70. if (!(win))
  71. return
  72. win.postMessage (message, EMBED_ORIGIN)
  73. }, [])
  74. const play = useCallback (() => {
  75. postToPlayer ({ eventName: 'play', sourceConnectorType: 1, playerId })
  76. }, [playerId, postToPlayer])
  77. const pause = useCallback (() => {
  78. postToPlayer ({ eventName: 'pause', sourceConnectorType: 1, playerId })
  79. }, [playerId, postToPlayer])
  80. const seek = useCallback ((time: number) => {
  81. postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
  82. }, [playerId, postToPlayer])
  83. const mute = useCallback (() => {
  84. postToPlayer ({ eventName: 'mute', sourceConnectorType: 1, playerId, data: { mute: true } })
  85. }, [playerId, postToPlayer])
  86. const unmute = useCallback (() => {
  87. postToPlayer ({ eventName: 'mute', sourceConnectorType: 1, playerId, data: { mute: false } })
  88. }, [playerId, postToPlayer])
  89. const setVolume = useCallback ((volume: number) => {
  90. postToPlayer (
  91. { eventName: 'volumeChange', sourceConnectorType: 1, playerId, data: { volume } })
  92. }, [playerId, postToPlayer])
  93. const showComments = useCallback (() => {
  94. postToPlayer (
  95. { eventName: 'commentVisibilityChange', sourceConnectorType: 1, playerId,
  96. data: { commentVisibility: true } })
  97. }, [playerId, postToPlayer])
  98. const hideComments = useCallback (() => {
  99. postToPlayer (
  100. { eventName: 'commentVisibilityChange', sourceConnectorType: 1, playerId,
  101. data: { commentVisibility: false } })
  102. }, [playerId, postToPlayer])
  103. useImperativeHandle (
  104. ref,
  105. () => ({ play, pause, seek, mute, unmute, setVolume, showComments, hideComments }),
  106. [play, pause, seek, mute, unmute, setVolume, showComments, hideComments])
  107. useEffect (() => {
  108. const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
  109. if (!(iframeRef.current)
  110. || (event.source !== iframeRef.current.contentWindow)
  111. || (event.origin !== EMBED_ORIGIN))
  112. return
  113. const data = event.data
  114. if (!(data)
  115. || typeof data !== 'object'
  116. || !('eventName' in data))
  117. return
  118. if (('playerId' in data)
  119. && data.playerId
  120. && data.playerId !== playerId)
  121. return
  122. if (data.eventName === 'enterProgrammaticFullScreen')
  123. {
  124. setFullScreen (true)
  125. return
  126. }
  127. if (data.eventName === 'exitProgrammaticFullScreen')
  128. {
  129. setFullScreen (false)
  130. return
  131. }
  132. if (data.eventName === 'loadComplete')
  133. {
  134. onLoadComplete?.(data.data.videoInfo)
  135. return
  136. }
  137. if (data.eventName === 'playerMetadataChange')
  138. {
  139. onMetadataChange?.(data.data)
  140. return
  141. }
  142. if (data.eventName === 'error')
  143. console.error ('niconico player error:', data)
  144. }
  145. addEventListener ('message', onMessage)
  146. return () => removeEventListener ('message', onMessage)
  147. }, [onLoadComplete, onMetadataChange, playerId])
  148. useLayoutEffect (() => {
  149. if (!(fullScreen))
  150. return
  151. const initialScrollX = scrollX
  152. const initialScrollY = scrollY
  153. let timer: ReturnType<typeof setTimeout>
  154. let ended = false
  155. const pollingResize = () => {
  156. if (ended)
  157. return
  158. const isLandscape = innerWidth >= innerHeight
  159. const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
  160. const windowHeight = `${ isLandscape ? innerHeight : innerWidth }px`
  161. setLandscape (isLandscape)
  162. setScreenWidth (windowWidth)
  163. setScreenHeight (windowHeight)
  164. timer = setTimeout (startPollingResize, 200)
  165. }
  166. const startPollingResize = () => {
  167. if (requestAnimationFrame)
  168. requestAnimationFrame (pollingResize)
  169. else
  170. pollingResize ()
  171. }
  172. startPollingResize ()
  173. return () => {
  174. clearTimeout (timer)
  175. ended = true
  176. scrollTo (initialScrollX, initialScrollY)
  177. }
  178. }, [fullScreen])
  179. useEffect (() => {
  180. if (!(fullScreen))
  181. return
  182. scrollTo (0, 0)
  183. }, [screenWidth, screenHeight, fullScreen])
  184. return (
  185. <iframe
  186. ref={iframeRef}
  187. src={src}
  188. width={width}
  189. height={height}
  190. style={margedStyle}
  191. allowFullScreen
  192. allow="autoplay"/>)
  193. })