ぼざクリタグ広場 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.
 
 
 
 
 
 

320 lines
7.3 KiB

  1. import { useEffect, useRef, useState } from 'react'
  2. import { Helmet } from 'react-helmet-async'
  3. import { useParams } from 'react-router-dom'
  4. import PostEmbed from '@/components/PostEmbed'
  5. import PrefetchLink from '@/components/PrefetchLink'
  6. import TagDetailSidebar from '@/components/TagDetailSidebar'
  7. import MainArea from '@/components/layout/MainArea'
  8. import SidebarComponent from '@/components/layout/SidebarComponent'
  9. import { SITE_TITLE } from '@/config'
  10. import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api'
  11. import { fetchPost } from '@/lib/posts'
  12. import { dateString } from '@/lib/utils'
  13. import type { FC } from 'react'
  14. import type { NiconicoMetadata,
  15. NiconicoViewerHandle,
  16. Post,
  17. Theatre,
  18. TheatreComment } from '@/types'
  19. type TheatreInfo = {
  20. hostFlg: boolean
  21. postId: number | null
  22. postStartedAt: string | null }
  23. const INITIAL_THEATRE_INFO = { hostFlg: false, postId: null, postStartedAt: null } as const
  24. export default (() => {
  25. const { id } = useParams ()
  26. const commentsRef = useRef<HTMLDivElement> (null)
  27. const embedRef = useRef<NiconicoViewerHandle> (null)
  28. const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
  29. const videoLengthRef = useRef (0)
  30. const lastCommentNoRef = useRef (0)
  31. const [comments, setComments] = useState<TheatreComment[]> ([])
  32. const [content, setContent] = useState ('')
  33. const [loading, setLoading] = useState (false)
  34. const [sending, setSending] = useState (false)
  35. const [theatre, setTheatre] = useState<Theatre | null> (null)
  36. const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
  37. const [post, setPost] = useState<Post | null> (null)
  38. const [videoLength, setVideoLength] = useState (0)
  39. useEffect (() => {
  40. theatreInfoRef.current = theatreInfo
  41. }, [theatreInfo])
  42. useEffect (() => {
  43. videoLengthRef.current = videoLength
  44. }, [videoLength])
  45. useEffect (() => {
  46. lastCommentNoRef.current = comments.at (-1)?.no ?? 0
  47. }, [comments])
  48. useEffect (() => {
  49. if (!(id))
  50. return
  51. let cancelled = false
  52. setComments ([])
  53. setTheatre (null)
  54. setPost (null)
  55. setTheatreInfo (INITIAL_THEATRE_INFO)
  56. setVideoLength (0)
  57. lastCommentNoRef.current = 0
  58. void (async () => {
  59. try
  60. {
  61. const data = await apiGet<Theatre> (`/theatres/${ id }`)
  62. if (!(cancelled))
  63. setTheatre (data)
  64. }
  65. catch (error)
  66. {
  67. console.error (error)
  68. }
  69. }) ()
  70. return () => {
  71. cancelled = true
  72. }
  73. }, [id])
  74. useEffect (() => {
  75. commentsRef.current?.scrollTo ({
  76. top: commentsRef.current.scrollHeight,
  77. behavior: 'smooth' })
  78. }, [commentsRef])
  79. useEffect (() => {
  80. if (!(id))
  81. return
  82. let cancelled = false
  83. let running = false
  84. const tick = async () => {
  85. if (running)
  86. return
  87. running = true
  88. try
  89. {
  90. const newComments = await apiGet<TheatreComment[]> (
  91. `/theatres/${ id }/comments`,
  92. { params: { no_gt: lastCommentNoRef.current } })
  93. if (!(cancelled) && newComments.length > 0)
  94. {
  95. lastCommentNoRef.current = newComments[newComments.length - 1].no
  96. setComments (prev => [...prev, ...newComments])
  97. }
  98. const currentInfo = theatreInfoRef.current
  99. const ended =
  100. currentInfo.hostFlg
  101. && currentInfo.postStartedAt
  102. && ((Date.now () - (new Date (currentInfo.postStartedAt)).getTime ())
  103. > videoLengthRef.current + 3_000)
  104. if (ended)
  105. {
  106. if (!(cancelled))
  107. setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null }))
  108. return
  109. }
  110. const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
  111. if (!(cancelled))
  112. setTheatreInfo (nextInfo)
  113. }
  114. catch (error)
  115. {
  116. console.error (error)
  117. }
  118. finally
  119. {
  120. running = false
  121. }
  122. }
  123. tick ()
  124. const interval = setInterval (() => tick (), 1_500)
  125. return () => {
  126. cancelled = true
  127. clearInterval (interval)
  128. }
  129. }, [id])
  130. useEffect (() => {
  131. if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null)
  132. return
  133. let cancelled = false
  134. void (async () => {
  135. setLoading (true)
  136. try
  137. {
  138. await apiPatch<void> (`/theatres/${ id }/next_post`)
  139. }
  140. catch (error)
  141. {
  142. console.error (error)
  143. }
  144. finally
  145. {
  146. if (!(cancelled))
  147. setLoading (false)
  148. }
  149. }) ()
  150. return () => {
  151. cancelled = true
  152. }
  153. }, [id, theatreInfo.hostFlg, theatreInfo.postId])
  154. useEffect (() => {
  155. setVideoLength (0)
  156. if (theatreInfo.postId == null)
  157. return
  158. let cancelled = false
  159. void (async () => {
  160. try
  161. {
  162. const nextPost = await fetchPost (String (theatreInfo.postId))
  163. if (!(cancelled))
  164. setPost (nextPost)
  165. }
  166. catch (error)
  167. {
  168. console.error (error)
  169. }
  170. }) ()
  171. return () => {
  172. cancelled = true
  173. }
  174. }, [theatreInfo.postId])
  175. const syncPlayback = (meta: NiconicoMetadata) => {
  176. if (!(theatreInfo.postStartedAt))
  177. return
  178. const targetTime = Math.min (
  179. Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()),
  180. videoLength)
  181. const drift = Math.abs (meta.currentTime - targetTime)
  182. if (drift > 5_000)
  183. embedRef.current?.seek (targetTime)
  184. }
  185. return (
  186. <div className="md:flex md:flex-1">
  187. <Helmet>
  188. {theatre && (
  189. <title>
  190. {'上映会場'
  191. + (theatre.name ? `『${ theatre.name }』` : ` #${ theatre.id }`)
  192. + ` | ${ SITE_TITLE }`}
  193. </title>)}
  194. </Helmet>
  195. <div className="hidden md:block">
  196. <TagDetailSidebar post={post ?? null}/>
  197. </div>
  198. <MainArea>
  199. {post ? (
  200. <>
  201. <PostEmbed
  202. key={post.id}
  203. ref={embedRef}
  204. post={post}
  205. onLoadComplete={info => {
  206. embedRef.current?.play ()
  207. setVideoLength (info.lengthInSeconds * 1_000)
  208. }}
  209. onMetadataChange={syncPlayback}/>
  210. <div className="m-2">
  211. <>再生中:</>
  212. <PrefetchLink to={`/posts/${ post.id }`} className="font-bold">
  213. {post.title || post.url}
  214. </PrefetchLink>
  215. </div>
  216. </>) : 'Loading...'}
  217. </MainArea>
  218. <SidebarComponent>
  219. <form
  220. className="w-full h-5/6"
  221. onSubmit={async e => {
  222. e.preventDefault ()
  223. if (!(content))
  224. return
  225. try
  226. {
  227. setSending (true)
  228. await apiPost (`/theatres/${ id }/comments`, { content })
  229. setContent ('')
  230. commentsRef.current?.scrollTo ({
  231. top: commentsRef.current.scrollHeight,
  232. behavior: 'smooth' })
  233. }
  234. finally
  235. {
  236. setSending (false)
  237. }
  238. }}>
  239. <div
  240. ref={commentsRef}
  241. className="overflow-x-hidden overflow-y-scroll text-wrap
  242. border border-black dark:border-white w-full h-[80vh]">
  243. {comments.map (comment => (
  244. <div key={comment.no} className="p-2">
  245. <div className="w-full">
  246. {comment.content}
  247. </div>
  248. <div className="w-full text-sm text-right">
  249. by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'}
  250. </div>
  251. <div className="w-full text-sm text-right">
  252. {dateString (comment.createdAt)}
  253. </div>
  254. </div>))}
  255. </div>
  256. <input
  257. className="w-full p-2 border border-black dark:border-white"
  258. type="text"
  259. value={content}
  260. onChange={e => setContent (e.target.value)}
  261. disabled={sending}/>
  262. </form>
  263. </SidebarComponent>
  264. <div className="md:hidden">
  265. <TagDetailSidebar post={post ?? null}/>
  266. </div>
  267. </div>)
  268. }) satisfies FC