このコミットが含まれているのは:
2026-06-07 02:01:16 +09:00
コミット be2df723fe
8個のファイルの変更222行の追加47行の削除
+4
ファイルの表示
@@ -47,6 +47,10 @@ If either command cannot be run or fails, report the exact command and failure.
- Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces.
+63 -26
ファイルの表示
@@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
type NiconicoPlayerMessage =
| { eventName: 'enterProgrammaticFullScreen' }
| { eventName: 'exitProgrammaticFullScreen' }
| { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
| { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
| { eventName: 'loadComplete'
playerId?: string
data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'playerMetadataChange'
playerId?: string
data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'
playerId?: string
data?: unknown }
| { eventName: 'error'
playerId?: string
data?: unknown
code?: string
message?: string }
type NiconicoCommand =
| { eventName: 'play'; sourceConnectorType: 1; playerId: string }
@@ -30,6 +40,7 @@ type NiconicoCommand =
data: { commentVisibility: boolean } }
const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
const LOAD_COMPLETE_TIMEOUT_MS = 8_000
type Props = {
id: string
@@ -42,10 +53,13 @@ type Props = {
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
const iframeRef = useRef<HTMLIFrameElement> (null)
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
const iframeRef = useRef<HTMLIFrameElement> (null)
const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const playerId = useMemo (
() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`,
[id])
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
@@ -77,8 +91,26 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
: { }
const margedStyle: CSSProperties =
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
const margedStyle: CSSProperties =
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
const clearLoadCompleteTimer = useCallback (() => {
if (!(loadCompleteTimerRef.current))
return
clearTimeout (loadCompleteTimerRef.current)
loadCompleteTimerRef.current = null
}, [])
const startLoadCompleteTimer = useCallback (() => {
clearLoadCompleteTimer ()
loadCompleteTimerRef.current = setTimeout (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
const postToPlayer = useCallback ((message: NiconicoCommand) => {
const win = iframeRef.current?.contentWindow
@@ -161,11 +193,12 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
return
}
if (data.eventName === 'loadComplete')
{
onLoadComplete?.(data.data.videoInfo)
return
}
if (data.eventName === 'loadComplete')
{
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo)
return
}
if (data.eventName === 'playerMetadataChange')
{
@@ -173,17 +206,20 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
return
}
if (data.eventName === 'error')
{
console.error ('niconico player error:', data)
onError?.(data)
}
if (data.eventName === 'error')
{
clearLoadCompleteTimer ()
console.error ('niconico player error:', data)
onError?.(data)
}
}
addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage)
}, [onError, onLoadComplete, onMetadataChange, playerId])
return () => removeEventListener ('message', onMessage)
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => {
if (!(fullScreen))
@@ -235,9 +271,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
<iframe
ref={iframeRef}
src={src}
width={width}
height={height}
style={margedStyle}
allowFullScreen
allow="autoplay"/>)
width={width}
height={height}
style={margedStyle}
onLoad={startLoadCompleteTimer}
allowFullScreen
allow="autoplay"/>)
})
+87 -5
ファイルの表示
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
@@ -8,18 +8,97 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react'
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
import type { YouTubePlayer } from 'react-youtube'
type YouTubeEvent<T = unknown> = {
data: T
target: YouTubePlayer }
type Props = {
ref?: RefObject<NiconicoViewerHandle | null>
post: Post
onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void
onVideoReady?: (durationMs: number) => void
onPlaybackChange?: (currentTimeMs: number) => number | void
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => {
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue ()
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
const currentTime = await player.getCurrentTime ()
const currentTimeMs = currentTime * 1_000
const targetTimeMs = onPlaybackChange?.(currentTimeMs)
if (typeof targetTimeMs !== 'number')
return
if (Math.abs (currentTimeMs - targetTimeMs) > 5_000)
await player.seekTo (targetTimeMs / 1_000, true)
}, [onPlaybackChange])
const handleYoutubeReady = async (event: YouTubeEvent) => {
setYoutubePlayer (event.target)
try
{
await event.target.playVideo ()
const duration = await event.target.getDuration ()
const durationMs = duration * 1_000
onVideoReady?.(durationMs)
if (!(Number.isFinite (durationMs)) || durationMs <= 0)
return
await reportYoutubePlayback (event.target)
}
catch (error)
{
onError?.({ platform: 'youtube', error })
}
}
const handleYoutubeStateChange = (event: YouTubeEvent<number>) => {
void reportYoutubePlayback (event.target)
}
const handleYoutubeError = (event: YouTubeEvent<number>) => {
onError?.({ platform: 'youtube', code: event.data })
}
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
onVideoReady?.(info.lengthInSeconds * 1_000)
onLoadComplete?.(info)
}
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta)
}
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
const timer = setInterval (
() => void reportYoutubePlayback (youtubePlayer),
1_000)
return () => clearInterval (timer)
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
@@ -39,8 +118,8 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onE
id={videoId}
width={640}
height={360}
onLoadComplete={onLoadComplete}
onMetadataChange={onMetadataChange}
onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={handleNiconicoMetadataChange}
onError={onError}/>)
}
@@ -71,7 +150,10 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onE
mute: 0,
loop: 1,
width: '640',
height: '360' } }}/>)
height: '360' } }}
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
}
}
+29 -14
ファイルの表示
@@ -21,9 +21,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent, ReactNode } from 'react'
import type { NiconicoMetadata,
NiconicoVideoInfo,
NiconicoViewerHandle,
import type { NiconicoViewerHandle,
Post,
Category,
Tag,
@@ -88,7 +86,9 @@ const commentBox = (
{dateString (comment.createdAt)}
</div>),
(
<div key={`${ comment.no }-post`} className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
<div
key={`${ comment.no }-post`}
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme && (
<>
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
@@ -439,7 +439,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
void refreshProgrammes ()
}, [refreshProgrammes, theatreInfo.postId])
const syncPlayback = (meta: NiconicoMetadata) => {
const syncPlaybackTime = (currentTimeMs: number): number | void => {
if (!(theatreInfo.postStartedAt))
return
@@ -447,24 +447,38 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
currentPostElapsedMs (theatreInfo),
videoLength)
const drift = Math.abs (meta.currentTime - targetTime)
const drift = Math.abs (currentTimeMs - targetTime)
if (drift > 5_000)
embedRef.current?.seek (targetTime)
return targetTime
}
const handlePlaybackError = async () => {
if (!(theatreInfoRef.current.hostFlg) || loadingRef.current)
return
await advancePost ()
loadingRef.current = true
try
{
await advancePost ()
}
finally
{
loadingRef.current = false
}
}
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
const lengthMs = info.lengthInSeconds * 1_000
setVideoLength (lengthMs)
const handleVideoReady = (durationMs: number) => {
const playableDurationMs =
Number.isFinite (durationMs)
? durationMs
: 0
if (lengthMs <= 0)
setVideoLength (playableDurationMs)
if (playableDurationMs <= 0)
{
void handlePlaybackError ()
return
@@ -738,7 +752,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950 md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet>
<meta name="robots" content="noindex"/>
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
@@ -811,8 +826,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
key={post.id}
ref={embedRef}
post={post}
onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={syncPlayback}
onVideoReady={handleVideoReady}
onPlaybackChange={syncPlaybackTime}
onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}