|
- 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<HTMLDivElement> (null)
- const embedRef = useRef<NiconicoViewerHandle> (null)
- const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
- const videoLengthRef = useRef (0)
- const lastCommentNoRef = useRef (0)
-
- const [comments, setComments] = useState<TheatreComment[]> ([])
- const [content, setContent] = useState ('')
- const [loading, setLoading] = useState (false)
- const [sending, setSending] = useState (false)
- const [theatre, setTheatre] = useState<Theatre | null> (null)
- const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
- const [post, setPost] = useState<Post | null> (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<Theatre> (`/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<TheatreComment[]> (
- `/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<TheatreInfo> (`/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<void> (`/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 (
- <div className="md:flex md:flex-1">
- <Helmet>
- {theatre && (
- <title>
- {'上映会場'
- + (theatre.name ? `『${ theatre.name }』` : ` #${ theatre.id }`)
- + ` | ${ SITE_TITLE }`}
- </title>)}
- </Helmet>
-
- <div className="hidden md:block">
- <TagDetailSidebar post={post ?? null}/>
- </div>
-
- <MainArea>
- {post ? (
- <>
- <PostEmbed
- key={post.id}
- ref={embedRef}
- post={post}
- onLoadComplete={info => {
- embedRef.current?.play ()
- setVideoLength (info.lengthInSeconds * 1_000)
- }}
- onMetadataChange={syncPlayback}/>
- <div className="m-2">
- <>再生中:</>
- <PrefetchLink to={`/posts/${ post.id }`} className="font-bold">
- {post.title || post.url}
- </PrefetchLink>
- </div>
- </>) : 'Loading...'}
- </MainArea>
-
- <SidebarComponent>
- <form
- className="w-full h-5/6"
- onSubmit={async e => {
- 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)
- }
- }}>
- <div
- ref={commentsRef}
- className="overflow-x-hidden overflow-y-scroll text-wrap
- border border-black dark:border-white w-full h-[80vh]">
- {comments.map (comment => (
- <div key={comment.no} className="p-2">
- <div className="w-full">
- {comment.content}
- </div>
- <div className="w-full text-sm text-right">
- by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'}
- </div>
- <div className="w-full text-sm text-right">
- {dateString (comment.createdAt)}
- </div>
- </div>))}
- </div>
- <input
- className="w-full p-2 border border-black dark:border-white"
- type="text"
- value={content}
- onChange={e => setContent (e.target.value)}
- disabled={sending}/>
- </form>
- </SidebarComponent>
-
- <div className="md:hidden">
- <TagDetailSidebar post={post ?? null}/>
- </div>
- </div>)
- }) satisfies FC
|