feat: add YouTube embedded player (#460)
Co-authored-by: Daniel Vergara <daniel.omar.vergara@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedNormalUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedYoutubeParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { getImageInfosFromEvent } from '@/lib/event'
|
||||
@@ -30,10 +31,12 @@ import Emoji from '../Emoji'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const nodes = parseContent(translatedEvent?.content ?? event.content, [
|
||||
EmbeddedYoutubeParser,
|
||||
EmbeddedImageParser,
|
||||
EmbeddedMediaParser,
|
||||
EmbeddedNormalUrlParser,
|
||||
@@ -119,6 +122,9 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
||||
if (!emoji) return node.data
|
||||
return <Emoji emoji={emoji} key={index} className="size-4" />
|
||||
}
|
||||
if (node.type === 'youtube') {
|
||||
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Image from '../Image'
|
||||
export default function WebPreview({ url, className }: { url: string; className?: string }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { title, description, image } = useFetchWebMetadata(url)
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
|
||||
86
src/components/YoutubeEmbeddedPlayer/index.tsx
Normal file
86
src/components/YoutubeEmbeddedPlayer/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import mediaManager from '@/services/media-manager.service'
|
||||
import { YouTubePlayer } from '@/types/youtube'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
export default function YoutubeEmbeddedPlayer({
|
||||
url,
|
||||
className
|
||||
}: {
|
||||
url: string
|
||||
className?: string
|
||||
}) {
|
||||
const videoId = useMemo(() => extractVideoId(url), [url])
|
||||
const playerRef = useRef<YouTubePlayer | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoId || !containerRef.current) return
|
||||
|
||||
if (!window.YT) {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://www.youtube.com/iframe_api'
|
||||
document.body.appendChild(script)
|
||||
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
initPlayer()
|
||||
}
|
||||
} else {
|
||||
initPlayer()
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
if (!videoId || !containerRef.current) return
|
||||
playerRef.current = new window.YT.Player(containerRef.current, {
|
||||
videoId: videoId,
|
||||
events: {
|
||||
onStateChange: (event: any) => {
|
||||
if (event.data === window.YT.PlayerState.PLAYING) {
|
||||
mediaManager.play(playerRef.current)
|
||||
} else if (event.data === window.YT.PlayerState.PAUSED) {
|
||||
mediaManager.pause(playerRef.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy()
|
||||
}
|
||||
}
|
||||
}, [videoId])
|
||||
|
||||
if (!videoId) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border overflow-hidden w-full aspect-video', className)}>
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractVideoId(url: string) {
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -88,6 +88,8 @@ export const HASHTAG_REGEX = /#[\p{L}\p{N}\p{M}_]+/gu
|
||||
export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g
|
||||
export const EMOJI_REGEX =
|
||||
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23E9}-\u{23EF}]|[\u{23F0}]|[\u{23F3}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]/gu
|
||||
export const YOUTUBE_URL_REGEX =
|
||||
/^https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+)|youtu\.be\/[\w-]+)(?:\?[^#\s]*)?(?:#[^\s]*)?/g
|
||||
|
||||
export const MONITOR = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923'
|
||||
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
LN_INVOICE_REGEX,
|
||||
URL_REGEX,
|
||||
MEDIA_REGEX,
|
||||
WS_URL_REGEX
|
||||
WS_URL_REGEX,
|
||||
YOUTUBE_URL_REGEX
|
||||
} from '@/constants'
|
||||
|
||||
export type TEmbeddedNodeType =
|
||||
@@ -23,6 +24,7 @@ export type TEmbeddedNodeType =
|
||||
| 'url'
|
||||
| 'emoji'
|
||||
| 'invoice'
|
||||
| 'youtube'
|
||||
|
||||
export type TEmbeddedNode =
|
||||
| {
|
||||
@@ -76,6 +78,11 @@ export const EmbeddedNormalUrlParser: TContentParser = {
|
||||
regex: URL_REGEX
|
||||
}
|
||||
|
||||
export const EmbeddedYoutubeParser: TContentParser = {
|
||||
type: 'youtube',
|
||||
regex: YOUTUBE_URL_REGEX
|
||||
}
|
||||
|
||||
export const EmbeddedEmojiParser: TContentParser = {
|
||||
type: 'emoji',
|
||||
regex: EMOJI_SHORT_CODE_REGEX
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { YouTubePlayer } from '@/types/youtube'
|
||||
|
||||
type Media = HTMLMediaElement | YouTubePlayer
|
||||
|
||||
class MediaManagerService {
|
||||
static instance: MediaManagerService
|
||||
|
||||
private currentMedia: HTMLMediaElement | null = null
|
||||
private currentMedia: Media | null = null
|
||||
|
||||
constructor() {
|
||||
if (!MediaManagerService.instance) {
|
||||
@@ -10,17 +14,20 @@ class MediaManagerService {
|
||||
return MediaManagerService.instance
|
||||
}
|
||||
|
||||
pause(media: HTMLMediaElement) {
|
||||
pause(media: Media | null) {
|
||||
if (!media) {
|
||||
return
|
||||
}
|
||||
if (isPipElement(media)) {
|
||||
return
|
||||
}
|
||||
if (this.currentMedia === media) {
|
||||
this.currentMedia = null
|
||||
}
|
||||
media.pause()
|
||||
pause(media)
|
||||
}
|
||||
|
||||
autoPlay(media: HTMLMediaElement) {
|
||||
autoPlay(media: Media) {
|
||||
if (
|
||||
document.pictureInPictureElement &&
|
||||
isMediaPlaying(document.pictureInPictureElement as HTMLMediaElement)
|
||||
@@ -30,19 +37,22 @@ class MediaManagerService {
|
||||
this.play(media)
|
||||
}
|
||||
|
||||
play(media: HTMLMediaElement) {
|
||||
play(media: Media | null) {
|
||||
if (!media) {
|
||||
return
|
||||
}
|
||||
if (document.pictureInPictureElement && document.pictureInPictureElement !== media) {
|
||||
;(document.pictureInPictureElement as HTMLMediaElement).pause()
|
||||
}
|
||||
if (this.currentMedia && this.currentMedia !== media) {
|
||||
this.currentMedia.pause()
|
||||
pause(this.currentMedia)
|
||||
}
|
||||
this.currentMedia = media
|
||||
if (isMediaPlaying(media)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.currentMedia.play().catch((error) => {
|
||||
play(this.currentMedia).catch((error) => {
|
||||
console.error('Error playing media:', error)
|
||||
this.currentMedia = null
|
||||
})
|
||||
@@ -52,13 +62,37 @@ class MediaManagerService {
|
||||
const instance = new MediaManagerService()
|
||||
export default instance
|
||||
|
||||
function isMediaPlaying(media: HTMLMediaElement) {
|
||||
function isYouTubePlayer(media: Media): media is YouTubePlayer {
|
||||
return (media as YouTubePlayer).pauseVideo !== undefined
|
||||
}
|
||||
|
||||
function isMediaPlaying(media: Media) {
|
||||
if (isYouTubePlayer(media)) {
|
||||
return media.getPlayerState() === window.YT.PlayerState.PLAYING
|
||||
}
|
||||
return media.currentTime > 0 && !media.paused && !media.ended && media.readyState >= 2
|
||||
}
|
||||
|
||||
function isPipElement(media: HTMLMediaElement) {
|
||||
function isPipElement(media: Media) {
|
||||
if (isYouTubePlayer(media)) {
|
||||
return false // YouTube players do not support Picture-in-Picture
|
||||
}
|
||||
if (document.pictureInPictureElement === media) {
|
||||
return true
|
||||
}
|
||||
return (media as any).webkitPresentationMode === 'picture-in-picture'
|
||||
}
|
||||
|
||||
function pause(media: Media) {
|
||||
if (isYouTubePlayer(media)) {
|
||||
return media.pauseVideo()
|
||||
}
|
||||
return media.pause()
|
||||
}
|
||||
|
||||
async function play(media: Media) {
|
||||
if (isYouTubePlayer(media)) {
|
||||
return media.playVideo()
|
||||
}
|
||||
return media.play()
|
||||
}
|
||||
|
||||
0
src/types.ts → src/types/index.d.ts
vendored
0
src/types.ts → src/types/index.d.ts
vendored
45
src/types/youtube.d.ts
vendored
Normal file
45
src/types/youtube.d.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
YT: {
|
||||
Player: new (element: HTMLElement, config: YouTubePlayerConfig) => YouTubePlayer
|
||||
PlayerState: {
|
||||
UNSTARTED: number
|
||||
ENDED: number
|
||||
PLAYING: number
|
||||
PAUSED: number
|
||||
BUFFERING: number
|
||||
CUED: number
|
||||
}
|
||||
}
|
||||
onYouTubeIframeAPIReady: () => void
|
||||
}
|
||||
}
|
||||
|
||||
interface YouTubePlayerConfig {
|
||||
videoId: string
|
||||
width?: number
|
||||
height?: number
|
||||
playerVars?: {
|
||||
autoplay?: 0 | 1
|
||||
controls?: 0 | 1
|
||||
start?: number
|
||||
end?: number
|
||||
}
|
||||
events?: {
|
||||
onReady?: (event: { target: YouTubePlayer }) => void
|
||||
onStateChange?: (event: { data: number; target: YouTubePlayer }) => void
|
||||
onError?: (event: { data: number }) => void
|
||||
}
|
||||
}
|
||||
|
||||
export interface YouTubePlayer {
|
||||
destroy(): void
|
||||
playVideo(): void
|
||||
pauseVideo(): void
|
||||
stopVideo(): void
|
||||
getCurrentTime(): number
|
||||
getDuration(): number
|
||||
getPlayerState(): number
|
||||
}
|
||||
|
||||
export {}
|
||||
Reference in New Issue
Block a user