diff --git a/frontend/src/components/NicoViewer.test.tsx b/frontend/src/components/NicoViewer.test.tsx new file mode 100644 index 0000000..1e5789d --- /dev/null +++ b/frontend/src/components/NicoViewer.test.tsx @@ -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 ( + , + ) + 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 () + const { container } = render ( + , + ) + 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', + ) + }) +}) diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index c407391..97f1ffb 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -129,7 +129,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef { - 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 0) + clearLoadCompleteTimer () + onMetadataChange?.(data.data) return } diff --git a/frontend/src/components/PostEmbed.test.tsx b/frontend/src/components/PostEmbed.test.tsx index e34713e..15cd42c 100644 --- a/frontend/src/components/PostEmbed.test.tsx +++ b/frontend/src/components/PostEmbed.test.tsx @@ -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 }) =>
Nico:{id}
, + default: (props: { id: string }) => { + nicoViewer.props (props) + return
Nico:{props.id}
+ }, })) 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 ( + , + ) + + 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 ( + , + ) + + 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 () diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index ac7f91d..3cbbd00 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -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 = ({ const dialogue = useDialogue () const [framed, setFramed] = useState (false) const [youtubePlayer, setYoutubePlayer] = useState (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 = ({ } 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 diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx index 3ba3409..7adab24 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -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 } - }) =>
Embed:{post.title || post.url}
, + }) => { + postEmbed.props (props) + if (props.ref) + props.ref.current = { + play: postEmbed.play, + seek: postEmbed.seek, + } + + return
Embed:{props.post.title || props.post.url}
+ }, })) vi.mock ('@/components/PostEditForm', () => ({ default: () =>
Post edit form
, @@ -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 () diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 519820b..2a206b6 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -443,9 +443,13 @@ const TheatreDetailPage: FC = ({ 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 = ({ user }: Props) => { : 0 setVideoLength (playableDurationMs) + videoLengthRef.current = playableDurationMs if (playableDurationMs <= 0) {