上映会ニコニコ・バグ修正 (#358) #359
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })}/>)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする