import { motion } from 'framer-motion' import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import { useLocale } from '@/lib/useLocale' import ErrorScreen from '@/components/ErrorScreen' import PostEditForm from '@/components/PostEditForm' import PostEmbed from '@/components/PostEmbed' import PrefetchLink from '@/components/PrefetchLink' import LocalisedText from '@/components/LocalisedText' import TagLink from '@/components/TagLink' import FieldError from '@/components/common/FieldError' import useDialogue from '@/lib/dialogues/useDialogue' import SidebarComponent from '@/components/layout/SidebarComponent' import { Button } from '@/components/ui/button' import PageHead from '@/components/common/PageHead' import { CATEGORIES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { clientAnimationTransition, clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPost } from '@/lib/posts' import { theatresKeys } from '@/lib/queryKeys' import { getClientTheatreLayoutMode, getClientTheatreTagFlow, setClientTheatreLayoutMode, setClientTheatreTagFlow, } from '@/lib/settings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' import { cn, localisedDateString, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import { useUITranslation } from '@/lib/useUITranslation' 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 MIN_MAIN_WIDTH = 360 const SIDEBAR_GAP_WIDTH = 16 const LAYOUT_LABELS: Record = { threeColumns: 'layouts.threeColumns', tagsBottom: 'layouts.tagsBottom', commentsBottom: 'layouts.commentsBottom' } const TAG_FLOW_LABELS: Record = { vertical: 'flows.vertical', horizontal: 'flows.horizontal' } const compareTagName = (a: Tag, b: Tag): number => a.name === b.name ? 0 : (a.name < b.name ? -1 : 1) const flattenTags = (tags: Tag[]): Tag[] => { const flattened: Tag[] = [] const seen = new Set () const visit = (tag: Tag) => { if (seen.has (tag.id)) return seen.add (tag.id) flattened.push (tag) for (const child of tag.children ?? []) visit (child) } for (const tag of tags) visit (tag) return flattened } 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 renderReadonlyTagTree = ( tag: Tag, nestLevel: number, path: string, ancestors: Set = new Set (), ): ReactNode[] => { const key = `${ path }-${ tag.id }` const nextAncestors = new Set (ancestors) nextAncestors.add (tag.id) return [ (
  • ), ...((tag.children ?? []) .filter (child => !(nextAncestors.has (child.id))) .sort (compareTagName) .flatMap (child => renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))] } const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( { tags, compact, flow = 'vertical' }) => { const { label } = useUITranslation () const horizontalGrouped = tagsByCategory (flattenTags (tags)) const verticalGrouped = tagsByCategory (tags) if (flow === 'horizontal') { return (
      {CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => (
    • ))}
    ) } return (
    {CATEGORIES.map (cat => { const rows = verticalGrouped[cat] ?? [] if (rows.length === 0) return null return (
    {label (`categories.${cat}`)}
      {rows.flatMap (tag => renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
    ) })}
    ) } type Props = { user: User | null } const TheatreDetailPage: FC = ({ user }: Props) => { const locale = useLocale () const { message, label } = useUITranslation ('theatres') const userName = (entry: Pick | null | undefined): ReactNode => entry ? (entry.name || label ('history:anonymous', { id: entry.id })) : label ('staff') const commentBox = ( comment: TheatreComment, programme: TheatreProgramme | null = null, ): ReactNode[] => [ (
    {comment.deleted ? {label ('deleted')} : comment.content}
    ), (
    {label ('authorPrefix')}{userName (comment.user)}
    ), (
    {localisedDateString (comment.createdAt, locale)}
    ), (
    {programme && ( <> {programme.post.title || programme.post.url} {label ('commentSuffix')} )}
    )] const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const layoutTransition = clientAnimationTransition ( animationMode, { normal: { duration: .2, ease: 'easeOut' as const } }, ) const { id } = useParams () const dialogue = useDialogue () const commentsRef = useRef (null) const embedRef = useRef (null) const loadingRef = useRef (false) const sidebarContainerRef = useRef (null) const theatreInfoRef = useRef (INITIAL_THEATRE_INFO) const theatreInfoReceivedAtRef = useRef (performance.now ()) const videoLengthRef = useRef (0) const lastCommentNoRef = useRef (0) const [leftSidebarWidth, setLeftSidebarWidth] = useState (256) const [rightSidebarWidth, setRightSidebarWidth] = useState (256) const [sidebarContainerWidth, setSidebarContainerWidth] = useState (0) const [comments, setComments] = useState ([]) const [content, setContent] = useState ('') const [editingPost, setEditingPost] = useState (null) const [loading, setLoading] = useState (false) const { data: initialTheatre, error: theatreError } = useQuery ({ queryKey: theatresKeys.show (id ?? '', locale), queryFn: () => apiGet (`/theatres/${ id }`, { locale }), enabled: id != null }) const { data: initialProgrammes } = useQuery ({ queryKey: theatresKeys.programmes (id ?? '', locale), queryFn: () => apiGet (`/theatres/${ id }/programmes`, { locale, params: { limit: '100' } }), enabled: id != null }) const [programmes, setProgrammes] = useState (initialProgrammes ?? []) const [sending, setSending] = useState (false) const status = isApiError (theatreError) ? theatreError.response?.status ?? 200 : 200 const [theatre, setTheatre] = useState (initialTheatre ?? 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 ('threeColumns') const [tagFlow, setTagFlow] = useState ('vertical') const { fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () const changeLayoutMode = (mode: TheatreLayoutMode) => { setLayoutMode (mode) setClientTheatreLayoutMode (mode) } useEffect (() => { setLayoutMode (getClientTheatreLayoutMode ()) setTagFlow (getClientTheatreTagFlow ()) }, []) const changeTagFlow = (flow: TagFlow) => { setTagFlow (flow) setClientTheatreTagFlow (flow) } const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { theatreInfoReceivedAtRef.current = performance.now () theatreInfoRef.current = nextInfo 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`, { locale, params: { limit: '100' } })) }, [id, locale]) const refreshWeights = useCallback (async () => { if (!(id)) return setWeights (await apiGet ( `/theatres/${ id }/post_selection_weights`, { locale })) }, [id, locale]) const advancePost = useCallback (async () => { if (!(id)) return setLoading (true) try { await apiPatch (`/theatres/${ id }/next_post`, { }, { locale }) await refreshProgrammes () await refreshWeights () } catch (error) { console.error (error) } finally { setLoading (false) } }, [id, refreshProgrammes, refreshWeights, locale]) useEffect (() => { loadingRef.current = loading }, [loading]) useEffect (() => { theatreInfoRef.current = theatreInfo }, [theatreInfo]) useEffect (() => { videoLengthRef.current = videoLength }, [videoLength]) useEffect (() => { lastCommentNoRef.current = comments[0]?.no ?? 0 }, [comments]) useEffect (() => { const updateSidebarContainerWidth = () => { const nextWidth = sidebarContainerRef.current?.clientWidth ?? window.innerWidth setSidebarContainerWidth (nextWidth) } updateSidebarContainerWidth () window.addEventListener ('resize', updateSidebarContainerWidth) return () => window.removeEventListener ('resize', updateSidebarContainerWidth) }, []) useEffect (() => { if (!(id)) return setComments ([]) setEditingPost (null) setPost (null) setProgrammes (initialProgrammes ?? []) setTheatre (initialTheatre ?? null) theatreInfoReceivedAtRef.current = performance.now () setTheatreInfo (INITIAL_THEATRE_INFO) setVideoLength (0) setWeights (INITIAL_WEIGHTS) lastCommentNoRef.current = 0 void refreshWeights () }, [id, initialProgrammes, initialTheatre, refreshWeights, locale]) 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`, { locale, params: { no_gt: lastCommentNoRef.current, limit: '20' } }) if (!(cancelled) && newComments.length > 0) { lastCommentNoRef.current = newComments[0].no setComments (prev => [...newComments, ...prev]) } if (loadingRef.current) return 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 watchingRequestedAt = performance.now () const nextInfo = await apiPut (`/theatres/${ id }/watching`, { }, { locale }) if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current) 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, locale]) 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), locale) if (!(cancelled)) setPost (nextPost) } catch (error) { console.error (error) } }) () return () => { cancelled = true } }, [theatreInfo.postId, locale]) useEffect (() => { void refreshProgrammes () }, [refreshProgrammes, theatreInfo.postId]) const syncPlaybackTime = (currentTimeMs: number): number | void => { if (!(theatreInfo.postStartedAt)) return const currentVideoLength = videoLengthRef.current if (currentVideoLength <= 0) return const targetTime = Math.min ( currentPostElapsedMs (theatreInfo), currentVideoLength) 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) videoLengthRef.current = playableDurationMs if (playableDurationMs <= 0) { void handlePlaybackError () return } embedRef.current?.play () } const handleSkipVote = async () => { if (!(id) || !(post)) return loadingRef.current = true setLoading (true) try { const nextInfo = theatreInfo.skipVote.voted ? await apiDelete ( `/theatres/${ id }/skip_vote`, { locale, params: { post_id: post.id } }) : await apiPut ( `/theatres/${ id }/skip_vote`, { post_id: post.id }, { locale }) 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`, { }, { locale })) console.error (error) } finally { loadingRef.current = false setLoading (false) } } const handleDelete = async (commentNo: number) => { try { await apiDelete (`/theatres/${ id }/comments/${ commentNo }`, { locale }) 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 }, { locale }) setContent ('') commentsRef.current?.scrollTo ({ top: 0, behavior: clientScrollBehaviour (animationMode) }) } catch (error) { applyValidationError (error) } finally { setSending (false) } } const skipVote = theatreInfo.skipVote const theatreTitle = theatre?.name ? message ('namedTitle', { name: theatre.name }) : message ('title') 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 singleSidebarMaxWidth = Math.max ( 192, sidebarContainerWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH) const leftSidebarMaxWidth = layoutMode === 'threeColumns' ? Math.max ( 192, sidebarContainerWidth - rightSidebarWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH * 2) : singleSidebarMaxWidth const rightSidebarMaxWidth = layoutMode === 'threeColumns' ? Math.max ( 192, sidebarContainerWidth - leftSidebarWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH * 2) : singleSidebarMaxWidth const tagPanel = (

    {label ('navigation:tags')}

    {layoutMode === 'tagsBottom' && (
    {(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => ( ))}
    )}
    {postTags.length === 0 ?
    {label ('noTags')}
    : }
    ) const commentsPanel = (

    {label ('comments')}

    0)} type="text" placeholder={message ('commentPlaceholder').text} lang={message ('commentPlaceholder').language} 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 = (

    {label ('participants')}

    {theatreInfo.watchingUsers.map (watchingUser => (
    {userName (watchingUser)} {watchingUser.id === user?.id && ( {label ('navigation:currentUser')})}
    ))}
    ) const historyPanel = (

    {label ('history')}

    {programmes.length === 0 ?
    {label ('emptyHistory')}
    : ( programmes.map (programme => (
    {programme.post.title || programme.post.url}
    {localisedDateString (programme.createdAt, locale)}
    )))}
    ) const weightsPanel = (

    {label ('weights')}

    {label ('unlikelyTags')}

    {weights.tagPenalties.length === 0 ?
    {label ('emptyPenalties')}
    : ( weights.tagPenalties.slice (0, 12).map (row => (
    {row.penalty}
    )))}

    {label ('unlikelyPosts')}

    {label ('likelyPosts')}

    ) return (
    {layoutMode !== 'tagsBottom' && ( {tagPanel} )}

    {theatreTitle.text}

    {label ('watching', { count: theatreInfo.watchingUsers.length })}

    {(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => ( ))}
    {post ? ( ) : (
    {label (loading ? 'selecting' : 'waiting')}
    )}
    {label ('playing')}
    {post ? ( {post.title || post.url} ) : ( {label ('unselected')})}
    {(post && canEditContent (user)) && ( )}
    {editingPost && (

    {label ('editingPost')}

    {editingPost.title || editingPost.url} {label ('editingSuffix')}

    { 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 }) => { const { label } = useUITranslation ('theatres') return (
    {rows.length === 0 ?
    {label ('emptyPosts')}
    : ( rows.slice (0, 8).map (row => (
    {row.post.title || row.post.url}
    {label ('penalty', { value: row.penalty })} {label ('weight', { value: row.weight.toFixed (3) })}
    )))}
    ) } export default TheatreDetailPage