上映会ニコニコ・バグ修正 (#358) #359

マージ済み
みてるぞ が 1 個のコミットを feature/358 から main へマージ 2026-06-07 09:08:42 +09:00
6個のファイルの変更276行の追加8行の削除
+83
ファイルの表示
@@ -0,0 +1,83 @@
import { act, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import NicoViewer from '@/components/NicoViewer'
import type { NiconicoViewerHandle } from '@/types'
describe ('NicoViewer', () => {
afterEach (() => {
vi.useRealTimers ()
})
it ('does not time out after metadata reports a playable duration', () => {
vi.useFakeTimers ()
const onError = vi.fn ()
const onMetadataChange = vi.fn ()
const { container } = render (
<NicoViewer
id="sm12345"
width={640}
height={360}
onMetadataChange={onMetadataChange}
onError={onError}/>,
)
const iframe = container.querySelector ('iframe')
expect (iframe).not.toBeNull ()
fireEvent.load (iframe!)
act (() => {
window.dispatchEvent (new MessageEvent ('message', {
origin: 'https://embed.nicovideo.jp',
source: iframe!.contentWindow,
data: {
eventName: 'playerMetadataChange',
data: {
currentTime: 7,
duration: 120,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
},
},
}))
})
act (() => {
vi.advanceTimersByTime (8_000)
})
expect (onMetadataChange).toHaveBeenCalled ()
expect (onError).not.toHaveBeenCalled ()
})
it ('seeks with milliseconds', () => {
const ref = createRef<NiconicoViewerHandle> ()
const { container } = render (
<NicoViewer
ref={ref}
id="sm12345"
width={640}
height={360}/>,
)
const iframe = container.querySelector ('iframe')!
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
act (() => {
ref.current!.seek (7_000)
})
expect (postMessage).toHaveBeenCalledWith (
expect.objectContaining ({
eventName: 'seek',
data: { time: 7_000 },
}),
'https://embed.nicovideo.jp',
)
})
})
+6 -1
ファイルの表示
@@ -129,7 +129,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const seek = useCallback ((time: number) => { const seek = useCallback ((time: number) => {
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } }) postToPlayer (
{ eventName: 'seek', sourceConnectorType: 1, playerId,
data: { time } })
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const mute = useCallback (() => { const mute = useCallback (() => {
@@ -202,6 +204,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'playerMetadataChange') if (data.eventName === 'playerMetadataChange')
{ {
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
clearLoadCompleteTimer ()
onMetadataChange?.(data.data) onMetadataChange?.(data.data)
return return
} }
+66 -1
ファイルの表示
@@ -8,12 +8,19 @@ const dialogue = vi.hoisted (() => ({
confirm: vi.fn (), confirm: vi.fn (),
})) }))
const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue, useDialogue: () => dialogue,
})) }))
vi.mock ('@/components/NicoViewer', () => ({ vi.mock ('@/components/NicoViewer', () => ({
default: ({ id }: { id: string }) => <div>Nico:{id}</div>, default: (props: { id: string }) => {
nicoViewer.props (props)
return <div>Nico:{props.id}</div>
},
})) }))
vi.mock ('react-youtube', () => ({ vi.mock ('react-youtube', () => ({
@@ -31,6 +38,64 @@ describe ('PostEmbed', () => {
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument () expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
}) })
it ('reports niconico metadata as milliseconds', () => {
const onVideoReady = vi.fn ()
const onPlaybackChange = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}
onPlaybackChange={onPlaybackChange}/>,
)
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledWith (120_000)
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
})
it ('reports niconico video readiness only once', () => {
const onVideoReady = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}/>,
)
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
title: '動画',
videoId: 'sm12345',
lengthInSeconds: 120,
thumbnailUrl: 'https://example.com/thumb.jpg',
description: '',
viewCount: 1,
commentCount: 2,
mylistCount: 3,
postedAt: '2026-01-02T03:04:05.000Z',
watchId: 12345,
})
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledTimes (1)
expect (onVideoReady).toHaveBeenCalledWith (120_000)
})
it ('embeds x/twitter status URLs', () => { it ('embeds x/twitter status URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>) render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
+18 -2
ファイルの表示
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import YoutubeEmbed from 'react-youtube' import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
@@ -36,6 +36,17 @@ const PostEmbed: FC<Props> = ({
const dialogue = useDialogue () const dialogue = useDialogue ()
const [framed, setFramed] = useState (false) const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null) const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
if (niconicoVideoReadyRef.current
|| !(Number.isFinite (durationMs))
|| durationMs <= 0)
return
niconicoVideoReadyRef.current = true
onVideoReady?.(durationMs)
}, [onVideoReady])
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => { const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
const currentTime = await player.getCurrentTime () const currentTime = await player.getCurrentTime ()
@@ -80,15 +91,20 @@ const PostEmbed: FC<Props> = ({
} }
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => { const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
onVideoReady?.(info.lengthInSeconds * 1_000) notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
onLoadComplete?.(info) onLoadComplete?.(info)
} }
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => { const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
notifyNiconicoVideoReady (meta.duration)
onPlaybackChange?.(meta.currentTime) onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta) onMetadataChange?.(meta)
} }
useEffect (() => {
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => { useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange)) if (!(youtubePlayer) || !(onPlaybackChange))
return return
+97 -3
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -31,15 +31,31 @@ const dialogue = vi.hoisted (() => ({
confirm: vi.fn (), confirm: vi.fn (),
})) }))
const postEmbed = vi.hoisted (() => ({
props: vi.fn (),
play: vi.fn (),
seek: vi.fn (),
}))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue, useDialogue: () => dialogue,
})) }))
vi.mock ('@/components/PostEmbed', () => ({ vi.mock ('@/components/PostEmbed', () => ({
default: ({ post }: { default: (props: {
ref?: { current: unknown }
post: { title: string | null; url: string } post: { title: string | null; url: string }
}) => <div>Embed:{post.title || post.url}</div>, }) => {
postEmbed.props (props)
if (props.ref)
props.ref.current = {
play: postEmbed.play,
seek: postEmbed.seek,
}
return <div>Embed:{props.post.title || props.post.url}</div>
},
})) }))
vi.mock ('@/components/PostEditForm', () => ({ vi.mock ('@/components/PostEditForm', () => ({
default: () => <div>Post edit form</div>, default: () => <div>Post edit form</div>,
@@ -153,6 +169,7 @@ const mockDefaultApi = () => {
describe ('TheatreDetailPage', () => { describe ('TheatreDetailPage', () => {
beforeEach (() => { beforeEach (() => {
vi.useRealTimers ()
vi.clearAllMocks () vi.clearAllMocks ()
mockDefaultApi () mockDefaultApi ()
}) })
@@ -188,6 +205,83 @@ describe ('TheatreDetailPage', () => {
.toBeInTheDocument () .toBeInTheDocument ()
}) })
it ('does not seek to zero while applying video length from the player', async () => {
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
hostFlg: true,
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 7_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
const props = postEmbed.props.mock.calls.at (-1)![0]
act (() => {
props.onVideoReady (120_000)
props.onPlaybackChange (0)
})
const seekMs = postEmbed.seek.mock.calls[0][0]
expect (seekMs).toBeGreaterThanOrEqual (7_000)
expect (seekMs).toBeLessThan (10_000)
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
})
it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
hostFlg: true,
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 4_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching')
})
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledTimes (2)
}, { timeout: 2_500 })
expect (api.apiPatch).not.toHaveBeenCalledWith ('/theatres/7/next_post')
})
it ('deletes an owned comment after confirmation', async () => { it ('deletes an owned comment after confirmation', async () => {
renderPage () renderPage ()
+6 -1
ファイルの表示
@@ -443,9 +443,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(theatreInfo.postStartedAt)) if (!(theatreInfo.postStartedAt))
return return
const currentVideoLength = videoLengthRef.current
if (currentVideoLength <= 0)
return
const targetTime = Math.min ( const targetTime = Math.min (
currentPostElapsedMs (theatreInfo), currentPostElapsedMs (theatreInfo),
videoLength) currentVideoLength)
const drift = Math.abs (currentTimeMs - targetTime) const drift = Math.abs (currentTimeMs - targetTime)
@@ -477,6 +481,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
: 0 : 0
setVideoLength (playableDurationMs) setVideoLength (playableDurationMs)
videoLengthRef.current = playableDurationMs
if (playableDurationMs <= 0) if (playableDurationMs <= 0)
{ {