f5b632ed89
Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
257 行
6.8 KiB
TypeScript
257 行
6.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import YoutubeEmbed from 'react-youtube'
|
|
|
|
import NicoViewer from '@/components/NicoViewer'
|
|
import TwitterEmbed from '@/components/TwitterEmbed'
|
|
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
|
import { Button } from '@/components/ui/button'
|
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
|
|
|
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,
|
|
onVideoReady,
|
|
onPlaybackChange,
|
|
onError },
|
|
) => {
|
|
const dialogue = useDialogue ()
|
|
const behaviourSettings = useClientBehaviourSettings ()
|
|
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
|
|
const [manualLoadRequested, setManualLoadRequested] = useState (false)
|
|
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 ()
|
|
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) => {
|
|
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 (() => {
|
|
setManualLoadRequested (false)
|
|
}, [embedAutoLoadMode, post.url])
|
|
|
|
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)
|
|
const shouldLoadEmbed =
|
|
embedAutoLoadMode === 'auto'
|
|
|| (
|
|
embedAutoLoadMode === 'manual'
|
|
&& manualLoadRequested
|
|
)
|
|
|
|
const externalLink = (
|
|
<a href={post.url} target="_blank" rel="noreferrer">
|
|
外部ページを開く
|
|
</a>)
|
|
|
|
const manualLoadControl = (
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setManualLoadRequested (true)}>
|
|
埋め込みを読込
|
|
</Button>
|
|
{externalLink}
|
|
</div>)
|
|
|
|
switch (url.hostname.split ('.').slice (-2).join ('.'))
|
|
{
|
|
case 'nicovideo.jp':
|
|
{
|
|
const mVideoId = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)
|
|
if (!(mVideoId))
|
|
break
|
|
|
|
const [videoId] = mVideoId
|
|
|
|
if (embedAutoLoadMode === 'off')
|
|
return externalLink
|
|
if (shouldLoadEmbed === false)
|
|
return manualLoadControl
|
|
|
|
return (
|
|
<NicoViewer
|
|
ref={ref}
|
|
id={videoId}
|
|
width={640}
|
|
height={360}
|
|
onLoadComplete={handleNiconicoLoadComplete}
|
|
onMetadataChange={handleNiconicoMetadataChange}
|
|
onError={onError}/>)
|
|
}
|
|
|
|
case 'twitter.com':
|
|
case 'x.com':
|
|
{
|
|
const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/)
|
|
const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)
|
|
if (!(mUserId) || !(mStatusId))
|
|
break
|
|
|
|
const [userId] = mUserId
|
|
const [statusId] = mStatusId
|
|
|
|
if (embedAutoLoadMode === 'off')
|
|
return externalLink
|
|
if (shouldLoadEmbed === false)
|
|
return manualLoadControl
|
|
|
|
return <TwitterEmbed userId={userId} statusId={statusId}/>
|
|
}
|
|
|
|
case 'youtube.com':
|
|
{
|
|
const videoId = url.searchParams.get ('v')
|
|
if (!(videoId))
|
|
break
|
|
|
|
if (embedAutoLoadMode === 'off')
|
|
return externalLink
|
|
if (shouldLoadEmbed === false)
|
|
return manualLoadControl
|
|
|
|
return (
|
|
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
|
|
playsinline: 1,
|
|
autoplay: 1,
|
|
mute: 0,
|
|
loop: 1,
|
|
width: '640',
|
|
height: '360' } }}
|
|
onReady={handleYoutubeReady}
|
|
onStateChange={handleYoutubeStateChange}
|
|
onError={handleYoutubeError}/>)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{framed
|
|
? (
|
|
<iframe
|
|
src={post.url}
|
|
title={post.title || post.url}
|
|
width={640}
|
|
height={360}/>)
|
|
: (
|
|
<div>
|
|
{embedAutoLoadMode === 'off'
|
|
? externalLink
|
|
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
|
|
? manualLoadControl
|
|
: (
|
|
<a href="#" onClick={async e => {
|
|
e.preventDefault ()
|
|
|
|
setFramed (await dialogue.confirm ({
|
|
title: '未確認の外部ページを表示します。',
|
|
description: (
|
|
<div>
|
|
<p>悪意のあるスクリプトが実行される可能性があります。</p>
|
|
<p>表示しますか?</p>
|
|
</div>),
|
|
confirmText: '表示' }))
|
|
}}>
|
|
外部ページを表示
|
|
</a>)}
|
|
</div>)}
|
|
</>)
|
|
}
|
|
|
|
export default PostEmbed
|