e72ec608f4
#95 #95 #95 #95 #95 Merge remote-tracking branch 'origin/main' into feature/095 #95 #95 #95 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #311
342 lines
8.1 KiB
TypeScript
342 lines
8.1 KiB
TypeScript
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 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
|
||
watchingUsers: { id: number; name: string }[] }
|
||
|
||
const INITIAL_THEATRE_INFO =
|
||
{ hostFlg: false,
|
||
postId: null,
|
||
postStartedAt: null,
|
||
watchingUsers: [] as { id: number; name: string }[] } 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 [status, setStatus] = useState (200)
|
||
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[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<Theatre> (`/theatres/${ id }`)
|
||
if (!(cancelled))
|
||
setTheatre (data)
|
||
}
|
||
catch (error)
|
||
{
|
||
setStatus ((error as any)?.response.status ?? 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<TheatreComment[]> (
|
||
`/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<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)
|
||
}
|
||
|
||
if (status >= 400)
|
||
return <ErrorScreen status={status}/>
|
||
|
||
return (
|
||
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||
<Helmet>
|
||
{theatre && (
|
||
<title>
|
||
{'上映会場'
|
||
+ (theatre.name ? `『${ theatre.name }』` : ` #${ theatre.id }`)
|
||
+ ` | ${ SITE_TITLE }`}
|
||
</title>)}
|
||
</Helmet>
|
||
|
||
<div className="hidden md:block">
|
||
{post && <TagDetailSidebar post={post}/>}
|
||
</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-auto h-auto border border-black dark:border-white rounded mx-2"
|
||
onSubmit={async e => {
|
||
e.preventDefault ()
|
||
|
||
if (!(content))
|
||
return
|
||
|
||
try
|
||
{
|
||
setSending (true)
|
||
await apiPost (`/theatres/${ id }/comments`, { content })
|
||
setContent ('')
|
||
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
|
||
}
|
||
finally
|
||
{
|
||
setSending (false)
|
||
}
|
||
}}>
|
||
<input
|
||
className="w-full p-2 border rounded"
|
||
type="text"
|
||
placeholder="ここにコメントを入力"
|
||
value={content}
|
||
onChange={e => setContent (e.target.value)}
|
||
disabled={sending}/>
|
||
|
||
<div
|
||
ref={commentsRef}
|
||
className="overflow-x-hidden overflow-y-scroll text-wrap w-full
|
||
h-[32vh] md:h-[64vh] border rounded">
|
||
{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 || `名もなきニジラー(#${ comment.user.id })`)
|
||
: '運営'}
|
||
</div>
|
||
<div className="w-full text-sm text-right">
|
||
{dateString (comment.createdAt)}
|
||
</div>
|
||
</div>))}
|
||
</div>
|
||
</form>
|
||
|
||
<div className="w-auto h-auto border border-black dark:border-white rounded mx-2 mt-4">
|
||
<div className="p-2">
|
||
現在の同接数:{theatreInfo.watchingUsers.length}
|
||
</div>
|
||
|
||
<div className="overflow-x-hidden overflow-y-scroll text-wrap w-full h-32
|
||
border rounded">
|
||
<ul className="list-inside list-disc">
|
||
{theatreInfo.watchingUsers.map (user => (
|
||
<li key={user.id} className="px-4 py-1 text-sm">
|
||
{user.name || `名もなきニジラー(#${ user.id })`}
|
||
</li>))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</SidebarComponent>
|
||
|
||
<div className="md:hidden">
|
||
{post && <TagDetailSidebar post={post} sp/>}
|
||
</div>
|
||
</div>)
|
||
}) satisfies FC
|