import { useEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import PostEmbed from '@/components/PostEmbed' import PrefetchLink from '@/components/PrefetchLink' import TagDetailSidebar from '@/components/TagDetailSidebar' import MainArea from '@/components/layout/MainArea' import SidebarComponent from '@/components/layout/SidebarComponent' import { SITE_TITLE } from '@/config' import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api' import { fetchPost } from '@/lib/posts' import { dateString } from '@/lib/utils' import type { FC } from 'react' import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre, TheatreComment } from '@/types' type TheatreInfo = { hostFlg: boolean postId: number | null postStartedAt: string | null } const INITIAL_THEATRE_INFO = { hostFlg: false, postId: null, postStartedAt: null } as const export default (() => { const { id } = useParams () const commentsRef = useRef (null) const embedRef = useRef (null) 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 [theatre, setTheatre] = useState (null) const [theatreInfo, setTheatreInfo] = useState (INITIAL_THEATRE_INFO) const [post, setPost] = useState (null) const [videoLength, setVideoLength] = useState (0) useEffect (() => { theatreInfoRef.current = theatreInfo }, [theatreInfo]) useEffect (() => { videoLengthRef.current = videoLength }, [videoLength]) useEffect (() => { lastCommentNoRef.current = comments.at (-1)?.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) { console.error (error) } }) () return () => { cancelled = true } }, [id]) useEffect (() => { commentsRef.current?.scrollTo ({ top: commentsRef.current.scrollHeight, behavior: 'smooth' }) }, [commentsRef]) 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 => [...prev, ...newComments]) } 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) || loading || 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) } return (
{theatre && ( {'上映会場' + (theatre.name ? `『${ theatre.name }』` : ` #${ theatre.id }`) + ` | ${ SITE_TITLE }`} )}
{post ? ( <> { embedRef.current?.play () setVideoLength (info.lengthInSeconds * 1_000) }} onMetadataChange={syncPlayback}/>
<>再生中: {post.title || post.url}
) : 'Loading...'}
{ e.preventDefault () if (!(content)) return try { setSending (true) await apiPost (`/theatres/${ id }/comments`, { content }) setContent ('') commentsRef.current?.scrollTo ({ top: commentsRef.current.scrollHeight, behavior: 'smooth' }) } finally { setSending (false) } }}>
{comments.map (comment => (
{comment.content}
by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'}
{dateString (comment.createdAt)}
))}
setContent (e.target.value)} disabled={sending}/>
) }) satisfies FC