feat: add YouTube embedded player (#460)

Co-authored-by: Daniel  Vergara <daniel.omar.vergara@gmail.com>
This commit is contained in:
Cody Tseng
2025-07-30 10:55:11 +08:00
committed by GitHub
parent 830362b941
commit 5a28233856
8 changed files with 191 additions and 10 deletions

View File

@@ -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} />}

View File

@@ -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

View 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
}

View File

@@ -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/']

View File

@@ -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

View File

@@ -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()
}

45
src/types/youtube.d.ts vendored Normal file
View 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 {}