上映会改修 (#302) (#357)

Reviewed-on: #357
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #357 でマージされました.
このコミットが含まれているのは:
2026-06-07 02:51:25 +09:00
committed by みてるぞ
コミット 3980e9651e
35個のファイルの変更2344行の追加305行の削除
+80 -39
ファイルの表示
@@ -1,11 +1,11 @@
import { forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState } from 'react'
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState } from 'react'
import type { CSSProperties, ForwardedRef } from 'react'
@@ -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
@@ -37,14 +48,18 @@ type Props = {
height: number
style?: CSSProperties
onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void }
onMetadataChange?: (meta: NiconicoMetadata) => void
onError?: (data: unknown) => void }
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = 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 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']> ()
@@ -64,21 +79,39 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const styleFullScreen: CSSProperties =
fullScreen
? { top: 0,
left: landscape ? 0 : '100%',
position: 'fixed',
width: screenWidth,
height: screenHeight,
zIndex: 2_147_483_647,
maxWidth: 'none',
transformOrigin: '0% 0%',
transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%',
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
left: landscape ? 0 : '100%',
position: 'fixed',
width: screenWidth,
height: screenHeight,
zIndex: 2_147_483_647,
maxWidth: 'none',
transformOrigin: '0% 0%',
transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%',
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
: { }
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
if (!(win))
@@ -132,21 +165,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
useEffect (() => {
const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
if (!(iframeRef.current)
|| (event.source !== iframeRef.current.contentWindow)
|| (event.origin !== EMBED_ORIGIN))
return
|| (event.source !== iframeRef.current.contentWindow)
|| (event.origin !== EMBED_ORIGIN))
return
const data = event.data
if (!(data)
|| typeof data !== 'object'
|| !('eventName' in data))
return
return
if (('playerId' in data)
&& data.playerId
&& data.playerId !== playerId)
return
return
if (data.eventName === 'enterProgrammaticFullScreen')
{
@@ -162,6 +195,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'loadComplete')
{
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo)
return
}
@@ -173,13 +207,19 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
}
if (data.eventName === 'error')
console.error ('niconico player error:', data)
{
clearLoadCompleteTimer ()
console.error ('niconico player error:', data)
onError?.(data)
}
}
addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage)
}, [onLoadComplete, onMetadataChange, playerId])
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => {
if (!(fullScreen))
@@ -192,7 +232,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const pollingResize = () => {
if (ended)
return
return
const isLandscape = innerWidth >= innerHeight
const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
@@ -206,9 +246,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const startPollingResize = () => {
if (requestAnimationFrame)
requestAnimationFrame (pollingResize)
requestAnimationFrame (pollingResize)
else
pollingResize ()
pollingResize ()
}
startPollingResize ()
@@ -231,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"/>)
})
+90 -6
ファイルの表示
@@ -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,17 +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 }
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 }) => {
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)
@@ -38,8 +118,9 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
id={videoId}
width={640}
height={360}
onLoadComplete={onLoadComplete}
onMetadataChange={onMetadataChange}/>)
onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={handleNiconicoMetadataChange}
onError={onError}/>)
}
case 'twitter.com':
@@ -69,7 +150,10 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
mute: 0,
loop: 1,
width: '640',
height: '360' } }}/>)
height: '360' } }}
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
}
}
+10 -10
ファイルの表示
@@ -55,12 +55,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>&thinsp;1&thinsp;</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
@@ -71,6 +65,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
@@ -132,8 +128,12 @@ const TopNav: FC<Props> = ({ user }) => {
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -244,9 +244,9 @@ const TopNav: FC<Props> = ({ user }) => {
<motion.div
key="submenu-shell"
layout
className="relative hidden md:block overflow-hidden
className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
@@ -257,7 +257,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}>
{moreVsbl
? (
menu.map ((item, i) => (
moreMenu.map ((item, i) => (
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3">
<motion.div
@@ -267,7 +267,7 @@ const TopNav: FC<Props> = ({ user }) => {
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } })}
className="z-10 h-full flex items-center px-3 font-bold w-24">
className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
</motion.div>
{item.subMenu