import { useEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import ErrorScreen from '@/components/ErrorScreen' import PostEmbed from '@/components/PostEmbed' import PrefetchLink from '@/components/PrefetchLink' import TagDetailSidebar from '@/components/TagDetailSidebar' import FieldError from '@/components/common/FieldError' import MainArea from '@/components/layout/MainArea' import SidebarComponent from '@/components/layout/SidebarComponent' import { SITE_TITLE } from '@/config' import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { fetchPost } from '@/lib/posts' import { dateString, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre, TheatreComment } from '@/types' type TheatreInfo = { hostFlg: boolean postId: number | null postStartedAt: string | null watchingUsers: { id: number; name: string }[] } type TheatreCommentField = 'content' const INITIAL_THEATRE_INFO = { hostFlg: false, postId: null, postStartedAt: null, watchingUsers: [] as { id: number; name: string }[] } as const const TheatreDetailPage: FC = () => { const { id } = useParams () const commentsRef = useRef (null) const embedRef = useRef (null) const loadingRef = useRef (false) const theatreInfoRef = useRef (INITIAL_THEATRE_INFO) const videoLengthRef = useRef (0) const lastCommentNoRef = useRef (0) const [comments, setComments] = useState ([]) const [content, setContent] = useState ('') const [loading, setLoading] = useState (false) const [sending, setSending] = useState (false) const [status, setStatus] = useState (200) const [theatre, setTheatre] = useState (null) const [theatreInfo, setTheatreInfo] = useState (INITIAL_THEATRE_INFO) const [post, setPost] = useState (null) const [videoLength, setVideoLength] = useState (0) const { fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () useEffect (() => { loadingRef.current = loading }, [loading]) useEffect (() => { theatreInfoRef.current = theatreInfo }, [theatreInfo]) useEffect (() => { videoLengthRef.current = videoLength }, [videoLength]) useEffect (() => { lastCommentNoRef.current = comments[0]?.no ?? 0 }, [comments]) useEffect (() => { if (!(id)) return let cancelled = false setComments ([]) setTheatre (null) setPost (null) setTheatreInfo (INITIAL_THEATRE_INFO) setVideoLength (0) lastCommentNoRef.current = 0 void (async () => { try { const data = await apiGet (`/theatres/${ id }`) if (!(cancelled)) setTheatre (data) } catch (error) { setStatus (isApiError (error) ? error.response?.status ?? 200 : 200) } }) () return () => { cancelled = true } }, [id]) useEffect (() => { if (!(id)) return let cancelled = false let running = false const tick = async () => { if (running) return running = true try { const newComments = await apiGet ( `/theatres/${ id }/comments`, { params: { no_gt: lastCommentNoRef.current } }) if (!(cancelled) && newComments.length > 0) { lastCommentNoRef.current = newComments[newComments.length - 1].no setComments (prev => [...newComments, ...prev]) } const currentInfo = theatreInfoRef.current const ended = currentInfo.hostFlg && currentInfo.postStartedAt && ((Date.now () - (new Date (currentInfo.postStartedAt)).getTime ()) > videoLengthRef.current + 3_000) if (ended) { if (!(cancelled)) setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null })) return } const nextInfo = await apiPut (`/theatres/${ id }/watching`) if (!(cancelled)) setTheatreInfo (nextInfo) } catch (error) { console.error (error) } finally { running = false } } tick () const interval = setInterval (() => tick (), 1_500) return () => { cancelled = true clearInterval (interval) } }, [id]) useEffect (() => { if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) return let cancelled = false void (async () => { setLoading (true) try { await apiPatch (`/theatres/${ id }/next_post`) } catch (error) { console.error (error) } finally { if (!(cancelled)) setLoading (false) } }) () return () => { cancelled = true } }, [id, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { setVideoLength (0) if (theatreInfo.postId == null) return let cancelled = false void (async () => { try { const nextPost = await fetchPost (String (theatreInfo.postId)) if (!(cancelled)) setPost (nextPost) } catch (error) { console.error (error) } }) () return () => { cancelled = true } }, [theatreInfo.postId]) const syncPlayback = (meta: NiconicoMetadata) => { if (!(theatreInfo.postStartedAt)) return const targetTime = Math.min ( Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()), videoLength) const drift = Math.abs (meta.currentTime - targetTime) if (drift > 5_000) embedRef.current?.seek (targetTime) } if (status >= 400) return return (
{theatre && ( {'上映会場' + (theatre.name ? `『${ theatre.name }』` : ` #${ theatre.id }`) + ` | ${ SITE_TITLE }`} )}
{post && }
{post ? ( <> { embedRef.current?.play () setVideoLength (info.lengthInSeconds * 1_000) }} onMetadataChange={syncPlayback}/>
<>再生中: {post.title || post.url}
) : 'Loading...'}
{ e.preventDefault () if (!(content)) return try { setSending (true) clearValidationErrors () await apiPost (`/theatres/${ id }/comments`, { content }) setContent ('') commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) } catch (error) { applyValidationError (error) } finally { setSending (false) } }}> 0)} type="text" placeholder="ここにコメントを入力" value={content} onChange={e => setContent (e.target.value)} disabled={sending}/>
{comments.map (comment => (
{comment.content}
by {comment.user ? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`) : '運営'}
{dateString (comment.createdAt)}
))}
現在の同接数:{theatreInfo.watchingUsers.length}
    {theatreInfo.watchingUsers.map (user => (
  • {user.name || `名もなきニジラー(#${ user.id })`}
  • ))}
{post && }
) } export default TheatreDetailPage