このコミットが含まれているのは:
@@ -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])
|
||||
|
||||
const seek = useCallback ((time: number) => {
|
||||
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
|
||||
postToPlayer (
|
||||
{ eventName: 'seek', sourceConnectorType: 1, playerId,
|
||||
data: { time } })
|
||||
}, [playerId, postToPlayer])
|
||||
|
||||
const mute = useCallback (() => {
|
||||
@@ -202,6 +204,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
if (data.eventName === 'playerMetadataChange')
|
||||
{
|
||||
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
|
||||
clearLoadCompleteTimer ()
|
||||
|
||||
onMetadataChange?.(data.data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,12 +8,19 @@ const dialogue = vi.hoisted (() => ({
|
||||
confirm: vi.fn (),
|
||||
}))
|
||||
|
||||
const nicoViewer = vi.hoisted (() => ({
|
||||
props: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
|
||||
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', () => ({
|
||||
@@ -31,6 +38,64 @@ describe ('PostEmbed', () => {
|
||||
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', () => {
|
||||
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 NicoViewer from '@/components/NicoViewer'
|
||||
@@ -36,6 +36,17 @@ const PostEmbed: FC<Props> = ({
|
||||
const dialogue = useDialogue ()
|
||||
const [framed, setFramed] = useState (false)
|
||||
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 currentTime = await player.getCurrentTime ()
|
||||
@@ -80,15 +91,20 @@ const PostEmbed: FC<Props> = ({
|
||||
}
|
||||
|
||||
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
|
||||
onVideoReady?.(info.lengthInSeconds * 1_000)
|
||||
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
|
||||
onLoadComplete?.(info)
|
||||
}
|
||||
|
||||
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
|
||||
notifyNiconicoVideoReady (meta.duration)
|
||||
onPlaybackChange?.(meta.currentTime)
|
||||
onMetadataChange?.(meta)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
niconicoVideoReadyRef.current = false
|
||||
}, [post.url])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(youtubePlayer) || !(onPlaybackChange))
|
||||
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -31,15 +31,31 @@ const dialogue = vi.hoisted (() => ({
|
||||
confirm: vi.fn (),
|
||||
}))
|
||||
|
||||
const postEmbed = vi.hoisted (() => ({
|
||||
props: vi.fn (),
|
||||
play: vi.fn (),
|
||||
seek: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/PostEmbed', () => ({
|
||||
default: ({ post }: {
|
||||
default: (props: {
|
||||
ref?: { current: unknown }
|
||||
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', () => ({
|
||||
default: () => <div>Post edit form</div>,
|
||||
@@ -153,6 +169,7 @@ const mockDefaultApi = () => {
|
||||
|
||||
describe ('TheatreDetailPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.useRealTimers ()
|
||||
vi.clearAllMocks ()
|
||||
mockDefaultApi ()
|
||||
})
|
||||
@@ -188,6 +205,83 @@ describe ('TheatreDetailPage', () => {
|
||||
.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 () => {
|
||||
renderPage ()
|
||||
|
||||
|
||||
@@ -443,9 +443,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
if (!(theatreInfo.postStartedAt))
|
||||
return
|
||||
|
||||
const currentVideoLength = videoLengthRef.current
|
||||
if (currentVideoLength <= 0)
|
||||
return
|
||||
|
||||
const targetTime = Math.min (
|
||||
currentPostElapsedMs (theatreInfo),
|
||||
videoLength)
|
||||
currentVideoLength)
|
||||
|
||||
const drift = Math.abs (currentTimeMs - targetTime)
|
||||
|
||||
@@ -477,6 +481,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
: 0
|
||||
|
||||
setVideoLength (playableDurationMs)
|
||||
videoLengthRef.current = playableDurationMs
|
||||
|
||||
if (playableDurationMs <= 0)
|
||||
{
|
||||
|
||||
新しい課題から参照
ユーザをブロックする