import { motion } from 'framer-motion' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import ErrorScreen from '@/components/ErrorScreen' import PostEditForm from '@/components/PostEditForm' import PostEmbed from '@/components/PostEmbed' import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' import FieldError from '@/components/common/FieldError' import { useDialogue } from '@/components/dialogues/DialogueProvider' import { Button } from '@/components/ui/button' import { SITE_TITLE } from '@/config' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { fetchPost } from '@/lib/posts' import { canEditContent } from '@/lib/users' import { cn, dateString, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC, FormEvent, ReactNode } from 'react' import type { NiconicoViewerHandle, Post, Category, Tag, Theatre, TheatreComment, TheatreInfo, TheatrePostSelectionWeights, TheatreProgramme, User } from '@/types' type TheatreCommentField = 'content' type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' type TagFlow = 'vertical' | 'horizontal' const INITIAL_THEATRE_INFO: TheatreInfo = { hostFlg: false, postId: null, postStartedAt: null, postElapsedMs: null, watchingUsers: [], skipVote: { votesCount: 0, requiredCount: 1, watchingUsersCount: 0, voted: false } } const INITIAL_WEIGHTS: TheatrePostSelectionWeights = { tagPenalties: [], lightestPosts: [], heaviestPosts: [] } const LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const LAYOUT_LABELS: Record = { threeColumns: '3 列', tagsBottom: '2 列 A 型', commentsBottom: '2 列 B 型' } const TAG_FLOW_LABELS: Record = { vertical: '縦並び', horizontal: '横並び' } const userName = (user: Pick | null | undefined): string => user ? (user.name || `名もなきニジラー(#${ user.id })`) : '運営' const commentBox = ( comment: TheatreComment, programme: TheatreProgramme | null = null, ): ReactNode[] => [(
{comment.deleted ? 削除されました. : comment.content}
), (
by {userName (comment.user)}
), (
{dateString (comment.createdAt)}
), (
{programme && ( <> {programme.post.title || programme.post.url}  へのコメント )}
)] const compareTagName = (a: Tag, b: Tag): number => a.name === b.name ? 0 : (a.name < b.name ? -1 : 1) const tagsByCategory = (tags: Tag[]): Partial> => { const grouped: Partial> = { } for (const tag of tags) { grouped[tag.category] ??= [] grouped[tag.category]!.push (tag) } for (const cat of CATEGORIES) grouped[cat]?.sort (compareTagName) return grouped } const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( { tags, compact, flow = 'vertical' }, ) => { const grouped = tagsByCategory (tags) if (flow === 'horizontal') { return (
    {CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
  • ))}
) } return (
{CATEGORIES.map (cat => { const rows = grouped[cat] ?? [] if (rows.length === 0) return null return (
{CATEGORY_NAMES[cat]}
    {rows.map (tag => (
  • ))}
) })}
) } type Props = { user: User | null } const TheatreDetailPage: FC = ({ user }: Props) => { const { id } = useParams () const dialogue = useDialogue () const commentsRef = useRef (null) const embedRef = useRef (null) const loadingRef = useRef (false) const theatreInfoRef = useRef (INITIAL_THEATRE_INFO) const theatreInfoReceivedAtRef = useRef (performance.now ()) const videoLengthRef = useRef (0) const lastCommentNoRef = useRef (0) const [comments, setComments] = useState ([]) const [content, setContent] = useState ('') const [editingPost, setEditingPost] = useState (null) const [loading, setLoading] = useState (false) const [programmes, setProgrammes] = useState ([]) 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 [weights, setWeights] = useState (INITIAL_WEIGHTS) const [layoutMode, setLayoutMode] = useState (() => { const stored = localStorage.getItem (LAYOUT_STORAGE_KEY) return ( ((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[]) .includes (stored as TheatreLayoutMode)) ? (stored as TheatreLayoutMode) : 'threeColumns') }) const [tagFlow, setTagFlow] = useState (() => { const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY) return ( (['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow) ? (stored as TagFlow) : 'vertical') }) const { fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () const changeLayoutMode = (mode: TheatreLayoutMode) => { setLayoutMode (mode) localStorage.setItem (LAYOUT_STORAGE_KEY, mode) } const changeTagFlow = (flow: TagFlow) => { setTagFlow (flow) localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow) } const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { theatreInfoReceivedAtRef.current = performance.now () setTheatreInfo (nextInfo) }, []) const currentPostElapsedMs = useCallback ( (info: TheatreInfo = theatreInfoRef.current): number => { if (info.postElapsedMs == null) return 0 return Math.max ( info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current, 0) }, []) const refreshProgrammes = useCallback (async () => { if (!(id)) return setProgrammes (await apiGet ( `/theatres/${ id }/programmes`, { params: { limit: '100' } })) }, [id]) const refreshWeights = useCallback (async () => { if (!(id)) return setWeights (await apiGet ( `/theatres/${ id }/post_selection_weights`)) }, [id]) const advancePost = useCallback (async () => { if (!(id)) return setLoading (true) try { await apiPatch (`/theatres/${ id }/next_post`) await refreshProgrammes () await refreshWeights () } catch (error) { console.error (error) } finally { setLoading (false) } }, [id, refreshProgrammes, refreshWeights]) 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 ([]) setEditingPost (null) setPost (null) setProgrammes ([]) setTheatre (null) theatreInfoReceivedAtRef.current = performance.now () setTheatreInfo (INITIAL_THEATRE_INFO) setVideoLength (0) setWeights (INITIAL_WEIGHTS) 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) } }) () void refreshProgrammes () void refreshWeights () return () => { cancelled = true } }, [id, refreshProgrammes, refreshWeights]) 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, limit: '20' } }) if (!(cancelled) && newComments.length > 0) { lastCommentNoRef.current = newComments[0].no setComments (prev => [...newComments, ...prev]) } const currentInfo = theatreInfoRef.current const ended = currentInfo.hostFlg && currentInfo.postStartedAt && videoLengthRef.current > 0 && currentPostElapsedMs (currentInfo) > videoLengthRef.current + 3_000 if (ended) { if (!(cancelled)) { setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null, postElapsedMs: null })) } return } const nextInfo = await apiPut (`/theatres/${ id }/watching`) if (!(cancelled)) applyTheatreInfo (nextInfo) } catch (error) { console.error (error) } finally { running = false } } tick () const interval = setInterval (() => tick (), 1_500) return () => { cancelled = true clearInterval (interval) } }, [applyTheatreInfo, currentPostElapsedMs, id]) useEffect (() => { if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) return let cancelled = false void (async () => { await advancePost () if (cancelled) setLoading (false) }) () return () => { cancelled = true } }, [advancePost, id, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { setVideoLength (0) if (theatreInfo.postId == null) { setPost (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]) useEffect (() => { void refreshProgrammes () }, [refreshProgrammes, theatreInfo.postId]) const syncPlaybackTime = (currentTimeMs: number): number | void => { if (!(theatreInfo.postStartedAt)) return const targetTime = Math.min ( currentPostElapsedMs (theatreInfo), videoLength) const drift = Math.abs (currentTimeMs - targetTime) if (drift > 5_000) embedRef.current?.seek (targetTime) return targetTime } const handlePlaybackError = async () => { if (!(theatreInfoRef.current.hostFlg) || loadingRef.current) return loadingRef.current = true try { await advancePost () } finally { loadingRef.current = false } } const handleVideoReady = (durationMs: number) => { const playableDurationMs = Number.isFinite (durationMs) ? durationMs : 0 setVideoLength (playableDurationMs) if (playableDurationMs <= 0) { void handlePlaybackError () return } embedRef.current?.play () } const handleSkipVote = async () => { if (!(id) || !(post)) return setLoading (true) try { const nextInfo = theatreInfo.skipVote.voted ? await apiDelete ( `/theatres/${ id }/skip_vote`, { params: { post_id: post.id } }) : await apiPut ( `/theatres/${ id }/skip_vote`, { post_id: post.id }) applyTheatreInfo (nextInfo) if (nextInfo.skipped) { setPost (null) await refreshProgrammes () await refreshWeights () } } catch (error) { if (isApiError (error) && error.response?.status === 409) applyTheatreInfo (await apiPut (`/theatres/${ id }/watching`)) console.error (error) } finally { setLoading (false) } } const handleDelete = async (commentNo: number) => { try { await apiDelete (`/theatres/${ id }/comments/${ commentNo }`) setComments (prev => { const rtn = [...prev] const idx = rtn.findIndex (x => x.no === commentNo) if (idx >= 0) rtn[idx] = { ...rtn[idx], deleted: true, content: null } return rtn }) } catch { ; } } const handleCommentSubmit = async (e: FormEvent) => { 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) } } const skipVote = theatreInfo.skipVote const theatreTitle = theatre?.name ? `上映会場『${ theatre.name }』` : '上映会場' const postTags = post?.tags ?? [] const programmesAsc = useMemo ( () => [...programmes].sort ( (a, b) => Date.parse (a.createdAt) - Date.parse (b.createdAt)), [programmes]) const programmeForComment = useCallback ((comment: TheatreComment): TheatreProgramme | null => { const commentedAt = Date.parse (comment.createdAt) let found: TheatreProgramme | null = null for (const programme of programmesAsc) { const startedAt = Date.parse (programme.createdAt) if (startedAt > commentedAt) break found = programme } return found }, [programmesAsc]) if (status >= 400) return const tagPanel = (

タグ

{layoutMode === 'tagsBottom' && (
{(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => ( ))}
)}
{postTags.length === 0 ?
タグはありません。
: }
) const commentsPanel = (

コメント

0)} type="text" placeholder="ここにコメントを入力" value={content} onChange={e => setContent (e.target.value)} disabled={sending}/>
{comments.map (comment => { const commentProgramme = programmeForComment (comment) return (
{(user && comment.user?.id === user.id && !(comment.deleted)) && ( )} {commentBox (comment, commentProgramme)}
) })}
) const participantsPanel = (

参加者

{theatreInfo.watchingUsers.map (watchingUser => (
{userName (watchingUser)} {watchingUser.id === user?.id && お前}
))}
) const historyPanel = (

再生履歴

{programmes.length === 0 ?
まだ履歴はありません。
: ( programmes.map (programme => (
{programme.post.title || programme.post.url}
{dateString (programme.createdAt)}
)))}
) const weightsPanel = (

抽選重み

出にくいタグ

{weights.tagPenalties.length === 0 ?
まだ減点はありません。
: ( weights.tagPenalties.slice (0, 12).map (row => (
{row.penalty}
)))}

出にくい候補

出やすい候補

) return ( {theatre && {`${ theatreTitle } | ${ SITE_TITLE }`}}
{layoutMode !== 'tagsBottom' && (
{tagPanel}
)}

{theatreTitle}

同接 {theatreInfo.watchingUsers.length} 人

{(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => ( ))}
{post ? ( ) : (
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
)}
再生中
{post ? ( {post.title || post.url} ) : ( 未選択)}
{(post && canEditContent (user)) && ( )}
{editingPost && (

編輯中の投稿

{editingPost.title || editingPost.url} を編輯中……

{ setEditingPost (newPost) if (post?.id === newPost.id) setPost (newPost) void refreshWeights () }}/>
)}
{commentsPanel}
{layoutMode === 'commentsBottom' && (
{commentsPanel}
)}
{tagPanel}
{layoutMode === 'tagsBottom' && (
{tagPanel}
)} {historyPanel} {weightsPanel}
{participantsPanel}
{layoutMode === 'commentsBottom' && (
{participantsPanel}
)}
{layoutMode !== 'commentsBottom' && ( {commentsPanel} {participantsPanel} )}
) } const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
{rows.length === 0 ?
候補はありません。
: ( rows.slice (0, 8).map (row => (
{row.post.title || row.post.url}
penalty {row.penalty} weight {row.weight.toFixed (3)}
)))}
) export default TheatreDetailPage