このコミットが含まれているのは:
2026-06-07 09:06:25 +09:00
コミット ec7462344a
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])
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
}
+66 -1
ファイルの表示
@@ -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' })}/>)
+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 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
+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 { 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 ()
+6 -1
ファイルの表示
@@ -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)
{