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,
|
EmbeddedMentionParser,
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
EmbeddedWebsocketUrlParser,
|
EmbeddedWebsocketUrlParser,
|
||||||
|
EmbeddedYoutubeParser,
|
||||||
parseContent
|
parseContent
|
||||||
} from '@/lib/content-parser'
|
} from '@/lib/content-parser'
|
||||||
import { getImageInfosFromEvent } from '@/lib/event'
|
import { getImageInfosFromEvent } from '@/lib/event'
|
||||||
@@ -30,10 +31,12 @@ import Emoji from '../Emoji'
|
|||||||
import ImageGallery from '../ImageGallery'
|
import ImageGallery from '../ImageGallery'
|
||||||
import MediaPlayer from '../MediaPlayer'
|
import MediaPlayer from '../MediaPlayer'
|
||||||
import WebPreview from '../WebPreview'
|
import WebPreview from '../WebPreview'
|
||||||
|
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||||
|
|
||||||
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
|
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
const translatedEvent = useTranslatedEvent(event.id)
|
||||||
const nodes = parseContent(translatedEvent?.content ?? event.content, [
|
const nodes = parseContent(translatedEvent?.content ?? event.content, [
|
||||||
|
EmbeddedYoutubeParser,
|
||||||
EmbeddedImageParser,
|
EmbeddedImageParser,
|
||||||
EmbeddedMediaParser,
|
EmbeddedMediaParser,
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
@@ -119,6 +122,9 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
if (!emoji) return node.data
|
if (!emoji) return node.data
|
||||||
return <Emoji emoji={emoji} key={index} className="size-4" />
|
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
|
return null
|
||||||
})}
|
})}
|
||||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
{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 }) {
|
export default function WebPreview({ url, className }: { url: string; className?: string }) {
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const { title, description, image } = useFetchWebMetadata(url)
|
const { title, description, image } = useFetchWebMetadata(url)
|
||||||
|
|
||||||
const hostname = useMemo(() => {
|
const hostname = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname
|
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 LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g
|
||||||
export const EMOJI_REGEX =
|
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
|
/[\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 = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923'
|
||||||
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
|
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
LN_INVOICE_REGEX,
|
LN_INVOICE_REGEX,
|
||||||
URL_REGEX,
|
URL_REGEX,
|
||||||
MEDIA_REGEX,
|
MEDIA_REGEX,
|
||||||
WS_URL_REGEX
|
WS_URL_REGEX,
|
||||||
|
YOUTUBE_URL_REGEX
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
|
|
||||||
export type TEmbeddedNodeType =
|
export type TEmbeddedNodeType =
|
||||||
@@ -23,6 +24,7 @@ export type TEmbeddedNodeType =
|
|||||||
| 'url'
|
| 'url'
|
||||||
| 'emoji'
|
| 'emoji'
|
||||||
| 'invoice'
|
| 'invoice'
|
||||||
|
| 'youtube'
|
||||||
|
|
||||||
export type TEmbeddedNode =
|
export type TEmbeddedNode =
|
||||||
| {
|
| {
|
||||||
@@ -76,6 +78,11 @@ export const EmbeddedNormalUrlParser: TContentParser = {
|
|||||||
regex: URL_REGEX
|
regex: URL_REGEX
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const EmbeddedYoutubeParser: TContentParser = {
|
||||||
|
type: 'youtube',
|
||||||
|
regex: YOUTUBE_URL_REGEX
|
||||||
|
}
|
||||||
|
|
||||||
export const EmbeddedEmojiParser: TContentParser = {
|
export const EmbeddedEmojiParser: TContentParser = {
|
||||||
type: 'emoji',
|
type: 'emoji',
|
||||||
regex: EMOJI_SHORT_CODE_REGEX
|
regex: EMOJI_SHORT_CODE_REGEX
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { YouTubePlayer } from '@/types/youtube'
|
||||||
|
|
||||||
|
type Media = HTMLMediaElement | YouTubePlayer
|
||||||
|
|
||||||
class MediaManagerService {
|
class MediaManagerService {
|
||||||
static instance: MediaManagerService
|
static instance: MediaManagerService
|
||||||
|
|
||||||
private currentMedia: HTMLMediaElement | null = null
|
private currentMedia: Media | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!MediaManagerService.instance) {
|
if (!MediaManagerService.instance) {
|
||||||
@@ -10,17 +14,20 @@ class MediaManagerService {
|
|||||||
return MediaManagerService.instance
|
return MediaManagerService.instance
|
||||||
}
|
}
|
||||||
|
|
||||||
pause(media: HTMLMediaElement) {
|
pause(media: Media | null) {
|
||||||
|
if (!media) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isPipElement(media)) {
|
if (isPipElement(media)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.currentMedia === media) {
|
if (this.currentMedia === media) {
|
||||||
this.currentMedia = null
|
this.currentMedia = null
|
||||||
}
|
}
|
||||||
media.pause()
|
pause(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
autoPlay(media: HTMLMediaElement) {
|
autoPlay(media: Media) {
|
||||||
if (
|
if (
|
||||||
document.pictureInPictureElement &&
|
document.pictureInPictureElement &&
|
||||||
isMediaPlaying(document.pictureInPictureElement as HTMLMediaElement)
|
isMediaPlaying(document.pictureInPictureElement as HTMLMediaElement)
|
||||||
@@ -30,19 +37,22 @@ class MediaManagerService {
|
|||||||
this.play(media)
|
this.play(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
play(media: HTMLMediaElement) {
|
play(media: Media | null) {
|
||||||
|
if (!media) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (document.pictureInPictureElement && document.pictureInPictureElement !== media) {
|
if (document.pictureInPictureElement && document.pictureInPictureElement !== media) {
|
||||||
;(document.pictureInPictureElement as HTMLMediaElement).pause()
|
;(document.pictureInPictureElement as HTMLMediaElement).pause()
|
||||||
}
|
}
|
||||||
if (this.currentMedia && this.currentMedia !== media) {
|
if (this.currentMedia && this.currentMedia !== media) {
|
||||||
this.currentMedia.pause()
|
pause(this.currentMedia)
|
||||||
}
|
}
|
||||||
this.currentMedia = media
|
this.currentMedia = media
|
||||||
if (isMediaPlaying(media)) {
|
if (isMediaPlaying(media)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentMedia.play().catch((error) => {
|
play(this.currentMedia).catch((error) => {
|
||||||
console.error('Error playing media:', error)
|
console.error('Error playing media:', error)
|
||||||
this.currentMedia = null
|
this.currentMedia = null
|
||||||
})
|
})
|
||||||
@@ -52,13 +62,37 @@ class MediaManagerService {
|
|||||||
const instance = new MediaManagerService()
|
const instance = new MediaManagerService()
|
||||||
export default instance
|
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
|
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) {
|
if (document.pictureInPictureElement === media) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return (media as any).webkitPresentationMode === 'picture-in-picture'
|
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