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