diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index de56ad53..6c5d39f2 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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 } + if (node.type === 'youtube') { + return + } return null })} {lastNormalUrl && } diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index 94d07102..fd441153 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -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 diff --git a/src/components/YoutubeEmbeddedPlayer/index.tsx b/src/components/YoutubeEmbeddedPlayer/index.tsx new file mode 100644 index 00000000..6086cba9 --- /dev/null +++ b/src/components/YoutubeEmbeddedPlayer/index.tsx @@ -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(null) + const containerRef = useRef(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 ( + + {url} + + ) + } + + return ( +
+
+
+ ) +} + +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 +} diff --git a/src/constants.ts b/src/constants.ts index 9e96d8f5..04d4a067 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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/'] diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts index c3455055..10349c38 100644 --- a/src/lib/content-parser.ts +++ b/src/lib/content-parser.ts @@ -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 diff --git a/src/services/media-manager.service.ts b/src/services/media-manager.service.ts index 5e1b3fbf..7e0b5ecb 100644 --- a/src/services/media-manager.service.ts +++ b/src/services/media-manager.service.ts @@ -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() +} diff --git a/src/types.ts b/src/types/index.d.ts similarity index 100% rename from src/types.ts rename to src/types/index.d.ts diff --git a/src/types/youtube.d.ts b/src/types/youtube.d.ts new file mode 100644 index 00000000..70bccee7 --- /dev/null +++ b/src/types/youtube.d.ts @@ -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 {}