|
|
|
@@ -3,84 +3,221 @@ 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, apiPut } from '@/lib/api' |
|
|
|
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 } from '@/types' |
|
|
|
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> ({ hostFlg: false, postId: null, postStartedAt: null }) |
|
|
|
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO) |
|
|
|
const [post, setPost] = useState<Post | null> (null) |
|
|
|
const [videoLength, setVideoLength] = useState (9_999_999_999) |
|
|
|
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 () => { |
|
|
|
setTheatre (await apiGet<Theatre> (`/theatres/${ id }`)) |
|
|
|
try |
|
|
|
{ |
|
|
|
const data = await apiGet<Theatre> (`/theatres/${ id }`) |
|
|
|
if (!(cancelled)) |
|
|
|
setTheatre (data) |
|
|
|
} |
|
|
|
catch (error) |
|
|
|
{ |
|
|
|
console.error (error) |
|
|
|
} |
|
|
|
}) () |
|
|
|
|
|
|
|
const interval = setInterval (async () => { |
|
|
|
if (theatreInfo.hostFlg |
|
|
|
&& theatreInfo.postStartedAt |
|
|
|
&& ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime () |
|
|
|
> videoLength)) |
|
|
|
setTheatreInfo ({ hostFlg: true, postId: null, postStartedAt: null }) |
|
|
|
else |
|
|
|
setTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)) |
|
|
|
}, 1_000) |
|
|
|
return () => { |
|
|
|
cancelled = true |
|
|
|
} |
|
|
|
}, [id]) |
|
|
|
|
|
|
|
return () => clearInterval (interval) |
|
|
|
}, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength]) |
|
|
|
useEffect (() => { |
|
|
|
commentsRef.current?.scrollTo ({ |
|
|
|
top: commentsRef.current.scrollHeight, |
|
|
|
behavior: 'smooth' }) |
|
|
|
}, [commentsRef]) |
|
|
|
|
|
|
|
useEffect (() => { |
|
|
|
if (!(theatreInfo.hostFlg) || loading) |
|
|
|
if (!(id)) |
|
|
|
return |
|
|
|
|
|
|
|
if (theatreInfo.postId == null) |
|
|
|
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 |
|
|
|
{ |
|
|
|
void (async () => { |
|
|
|
setLoading (true) |
|
|
|
await apiPatch<void> (`/theatres/${ id }/next_post`) |
|
|
|
running = false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
tick () |
|
|
|
const interval = setInterval (() => tick (), 1_000) |
|
|
|
|
|
|
|
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 |
|
|
|
} |
|
|
|
}) () |
|
|
|
|
|
|
|
return () => { |
|
|
|
cancelled = true |
|
|
|
} |
|
|
|
}, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) |
|
|
|
|
|
|
|
useEffect (() => { |
|
|
|
if (theatreInfo.postId == null) |
|
|
|
return |
|
|
|
|
|
|
|
let cancelled = false |
|
|
|
|
|
|
|
void (async () => { |
|
|
|
setPost (await fetchPost (String (theatreInfo.postId))) |
|
|
|
try |
|
|
|
{ |
|
|
|
const nextPost = await fetchPost (String (theatreInfo.postId)) |
|
|
|
if (!(cancelled)) |
|
|
|
setPost (nextPost) |
|
|
|
} |
|
|
|
catch (error) |
|
|
|
{ |
|
|
|
console.error (error) |
|
|
|
} |
|
|
|
}) () |
|
|
|
|
|
|
|
return () => { |
|
|
|
cancelled = true |
|
|
|
} |
|
|
|
}, [theatreInfo.postId, theatreInfo.postStartedAt]) |
|
|
|
|
|
|
|
const syncPlayback = (meta: NiconicoMetadata) => { |
|
|
|
if (!(theatreInfo.postStartedAt)) |
|
|
|
return |
|
|
|
|
|
|
|
const targetTime = |
|
|
|
((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ()) |
|
|
|
const targetTime = Math.min ( |
|
|
|
Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()), |
|
|
|
videoLength) |
|
|
|
|
|
|
|
const drift = Math.abs (meta.currentTime - targetTime) |
|
|
|
|
|
|
|
@@ -89,7 +226,7 @@ export default (() => { |
|
|
|
} |
|
|
|
|
|
|
|
return ( |
|
|
|
<MainArea> |
|
|
|
<div className="md:flex md:flex-1"> |
|
|
|
<Helmet> |
|
|
|
{theatre && ( |
|
|
|
<title> |
|
|
|
@@ -99,16 +236,82 @@ export default (() => { |
|
|
|
</title>)} |
|
|
|
</Helmet> |
|
|
|
|
|
|
|
{post && ( |
|
|
|
<PostEmbed |
|
|
|
ref={embedRef} |
|
|
|
post={post} |
|
|
|
onLoadComplete={info => { |
|
|
|
embedRef.current?.play () |
|
|
|
setVideoLength (info.lengthInSeconds * 1_000) |
|
|
|
}} |
|
|
|
onMetadataChange={meta => { |
|
|
|
syncPlayback (meta) |
|
|
|
}}/>)} |
|
|
|
</MainArea>) |
|
|
|
<div className="hidden md:block"> |
|
|
|
<TagDetailSidebar post={post ?? null}/> |
|
|
|
</div> |
|
|
|
|
|
|
|
<MainArea> |
|
|
|
{post ? ( |
|
|
|
<> |
|
|
|
<PostEmbed |
|
|
|
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> |
|
|
|
{comments.length > 0 && ( |
|
|
|
<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-3/4"> |
|
|
|
{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 |