diff --git a/src/components/XEmbeddedPost/Post.tsx b/src/components/XEmbeddedPost/Post.tsx new file mode 100644 index 0000000..fe5ddf7 --- /dev/null +++ b/src/components/XEmbeddedPost/Post.tsx @@ -0,0 +1,130 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { toExternalContent } from '@/lib/link' +import { cn, isTouchDevice } from '@/lib/utils' +import { useSecondaryPage } from '@/PageManager' +import { useTheme } from '@/providers/ThemeProvider' +import { MessageCircle } from 'lucide-react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface PostProps { + tweetId: string + url: string + className?: string + embedded?: boolean +} + +const Post = memo(({ tweetId, url, className, embedded = true }: PostProps) => { + const { t } = useTranslation() + const { theme } = useTheme() + const { push } = useSecondaryPage() + const supportTouch = useMemo(() => isTouchDevice(), []) + const [loaded, setLoaded] = useState(false) + const loadingRef = useRef(false) + const containerRef = useRef(null) + const unmountedRef = useRef(false) + + useEffect(() => { + unmountedRef.current = false + + if (!tweetId || !containerRef.current || loadingRef.current) return + loadingRef.current = true + + // Load Twitter widgets script if not already loaded + if (!window.twttr) { + const script = document.createElement('script') + script.src = 'https://platform.twitter.com/widgets.js' + script.async = true + script.onload = () => { + if (!unmountedRef.current) { + embedTweet() + } + } + script.onerror = () => { + if (!unmountedRef.current) { + console.error('Failed to load Twitter widgets script') + loadingRef.current = false + } + } + document.body.appendChild(script) + } else { + embedTweet() + } + + function embedTweet() { + if (!containerRef.current || !window.twttr || !tweetId || unmountedRef.current) return + + window.twttr.widgets + .createTweet(tweetId, containerRef.current, { + theme: theme === 'light' ? 'light' : 'dark', + dnt: true, // Do not track + conversation: 'none' // Hide conversation thread + }) + .then((element: HTMLElement | undefined) => { + if (unmountedRef.current) return + if (element) { + setTimeout(() => { + if (!unmountedRef.current) { + setLoaded(true) + } + }, 100) + } else { + console.error('Failed to embed tweet') + } + }) + .catch((error) => { + if (!unmountedRef.current) { + console.error('Error embedding tweet:', error) + } + }) + .finally(() => { + loadingRef.current = false + }) + } + + return () => { + unmountedRef.current = true + // Clear the container to prevent memory leaks + if (containerRef.current) { + containerRef.current.innerHTML = '' + } + } + }, [tweetId, theme]) + + const handleViewComments = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + push(toExternalContent(url)) + }, + [url, push] + ) + + return ( +
+
+ {!loaded && } + {loaded && embedded && !supportTouch && ( + /* Hover overlay mask */ +
+
+ + {t('View Nostr comments')} +
+
+ )} +
+ ) +}) + +Post.displayName = 'XPost' + +export default Post diff --git a/src/components/XEmbeddedPost/index.tsx b/src/components/XEmbeddedPost/index.tsx index 72fb22d..3258132 100644 --- a/src/components/XEmbeddedPost/index.tsx +++ b/src/components/XEmbeddedPost/index.tsx @@ -1,13 +1,8 @@ -import { Skeleton } from '@/components/ui/skeleton' -import { toExternalContent } from '@/lib/link' -import { cn, isTouchDevice } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useTheme } from '@/providers/ThemeProvider' -import { MessageCircle } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ExternalLink from '../ExternalLink' +import Post from './Post' export default function XEmbeddedPost({ url, @@ -21,84 +16,21 @@ export default function XEmbeddedPost({ embedded?: boolean }) { const { t } = useTranslation() - const { theme } = useTheme() const { autoLoadMedia } = useContentPolicy() - const { push } = useSecondaryPage() - const supportTouch = useMemo(() => isTouchDevice(), []) - const [display, setDisplay] = useState(autoLoadMedia) - const [loaded, setLoaded] = useState(false) - const [error, setError] = useState(false) + const [display, setDisplay] = useState(autoLoadMedia || mustLoad) const { tweetId } = useMemo(() => parseXUrl(url), [url]) - const loadingRef = useRef(false) - const containerRef = useRef(null) useEffect(() => { - if (autoLoadMedia) { + if (autoLoadMedia || mustLoad) { setDisplay(true) - } else { - setDisplay(false) } - }, [autoLoadMedia]) + }, [autoLoadMedia, mustLoad]) - useEffect(() => { - if (!tweetId || !containerRef.current || (!mustLoad && !display) || loadingRef.current) return - loadingRef.current = true - - // Load Twitter widgets script if not already loaded - if (!window.twttr) { - const script = document.createElement('script') - script.src = 'https://platform.twitter.com/widgets.js' - script.async = true - script.onload = () => { - embedTweet() - } - script.onerror = () => { - setError(true) - loadingRef.current = false - } - document.body.appendChild(script) - } else { - embedTweet() - } - - function embedTweet() { - if (!containerRef.current || !window.twttr || !tweetId) return - - window.twttr.widgets - .createTweet(tweetId, containerRef.current, { - theme: theme === 'light' ? 'light' : 'dark', - dnt: true, // Do not track - conversation: 'none' // Hide conversation thread - }) - .then((element: HTMLElement | undefined) => { - if (element) { - setTimeout(() => setLoaded(true), 100) - } else { - setError(true) - } - }) - .catch(() => { - setError(true) - }) - .finally(() => { - loadingRef.current = false - }) - } - }, [tweetId, display, mustLoad, theme]) - - const handleViewComments = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - push(toExternalContent(url)) - }, - [url, push] - ) - - if (error || !tweetId) { + if (!tweetId) { return } - if (!mustLoad && !display) { + if (!display) { return (
-
- {!loaded && } - {loaded && embedded && !supportTouch && ( - /* Hover overlay mask */ -
-
- - {t('View Nostr comments')} -
-
- )} -
- ) + return } function parseXUrl(url: string): { tweetId: string | null } { diff --git a/src/components/YoutubeEmbeddedPlayer/Player.tsx b/src/components/YoutubeEmbeddedPlayer/Player.tsx new file mode 100644 index 0000000..33c1895 --- /dev/null +++ b/src/components/YoutubeEmbeddedPlayer/Player.tsx @@ -0,0 +1,170 @@ +import { cn } from '@/lib/utils' +import { useUserPreferences } from '@/providers/UserPreferencesProvider' +import mediaManager from '@/services/media-manager.service' +import { YouTubePlayer } from '@/types/youtube' +import { memo, useEffect, useRef, useState } from 'react' + +interface PlayerProps { + videoId: string + isShort: boolean + className?: string +} + +const Player = memo(({ videoId, isShort, className }: PlayerProps) => { + const { muteMedia, updateMuteMedia } = useUserPreferences() + const [initSuccess, setInitSuccess] = useState(false) + const playerRef = useRef(null) + const containerRef = useRef(null) + const wrapperRef = useRef(null) + const muteStateRef = useRef(muteMedia) + const playerIdRef = useRef(`yt-player-${Math.random().toString(36).substr(2, 9)}`) + const unmountedRef = useRef(false) + + useEffect(() => { + unmountedRef.current = false + + 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 = () => { + if (!unmountedRef.current) { + initPlayer() + } + } + } else { + initPlayer() + } + + let checkMutedInterval: NodeJS.Timeout | null = null + function initPlayer() { + try { + if (!videoId || !containerRef.current || !window.YT.Player || unmountedRef.current) return + + let currentMuteState = muteStateRef.current + // Use string ID to avoid React DOM manipulation conflicts + playerRef.current = new window.YT.Player(playerIdRef.current as any, { + videoId: videoId, + playerVars: { + mute: currentMuteState ? 1 : 0 + }, + events: { + onStateChange: (event: any) => { + if (unmountedRef.current) return + + if (event.data === window.YT.PlayerState.PLAYING) { + mediaManager.play(playerRef.current) + } else if ( + event.data === window.YT.PlayerState.PAUSED || + event.data === window.YT.PlayerState.ENDED + ) { + mediaManager.pause(playerRef.current) + } + }, + onReady: () => { + if (unmountedRef.current) { + playerRef.current?.destroy() + return + } + setInitSuccess(true) + checkMutedInterval = setInterval(() => { + if (playerRef.current && !unmountedRef.current) { + const mute = playerRef.current.isMuted() + if (mute !== currentMuteState) { + currentMuteState = mute + + if (mute !== muteStateRef.current) { + updateMuteMedia(currentMuteState) + } + } else if (muteStateRef.current !== mute) { + if (muteStateRef.current) { + playerRef.current.mute() + } else { + playerRef.current.unMute() + } + } + } + }, 200) + }, + onError: () => { + if (unmountedRef.current) return + console.error('YouTube player error') + } + } + }) + } catch (error) { + console.error('Failed to initialize YouTube player:', error) + return + } + } + + return () => { + unmountedRef.current = true + if (checkMutedInterval) { + clearInterval(checkMutedInterval) + checkMutedInterval = null + } + if (playerRef.current) { + try { + playerRef.current.destroy() + } catch { + // Ignore errors during cleanup + } + playerRef.current = null + } + } + }, [videoId]) + + useEffect(() => { + muteStateRef.current = muteMedia + }, [muteMedia]) + + useEffect(() => { + const wrapper = wrapperRef.current + + if (!wrapper || !initSuccess) return + + const observer = new IntersectionObserver( + ([entry]) => { + const player = playerRef.current + if (!player || unmountedRef.current) return + + if ( + !entry.isIntersecting && + [window.YT.PlayerState.PLAYING, window.YT.PlayerState.BUFFERING].includes( + player.getPlayerState() + ) + ) { + mediaManager.pause(player) + } + }, + { threshold: 1 } + ) + + observer.observe(wrapper) + + return () => { + observer.unobserve(wrapper) + } + }, [initSuccess]) + + return ( +
+
+
+ ) +}) + +Player.displayName = 'YoutubePlayer' + +export default Player diff --git a/src/components/YoutubeEmbeddedPlayer/index.tsx b/src/components/YoutubeEmbeddedPlayer/index.tsx index f6052c6..ff9ddbf 100644 --- a/src/components/YoutubeEmbeddedPlayer/index.tsx +++ b/src/components/YoutubeEmbeddedPlayer/index.tsx @@ -1,11 +1,8 @@ -import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useUserPreferences } from '@/providers/UserPreferencesProvider' -import mediaManager from '@/services/media-manager.service' -import { YouTubePlayer } from '@/types/youtube' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ExternalLink from '../ExternalLink' +import Player from './Player' export default function YoutubeEmbeddedPlayer({ url, @@ -18,141 +15,20 @@ export default function YoutubeEmbeddedPlayer({ }) { const { t } = useTranslation() const { autoLoadMedia } = useContentPolicy() - const { muteMedia, updateMuteMedia } = useUserPreferences() - const [display, setDisplay] = useState(autoLoadMedia) + const [display, setDisplay] = useState(autoLoadMedia || mustLoad) const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url]) - const [initSuccess, setInitSuccess] = useState(false) - const [error, setError] = useState(false) - const playerRef = useRef(null) - const containerRef = useRef(null) - const wrapperRef = useRef(null) - const muteStateRef = useRef(muteMedia) useEffect(() => { - if (autoLoadMedia) { + if (autoLoadMedia || mustLoad) { setDisplay(true) - } else { - setDisplay(false) } - }, [autoLoadMedia]) + }, [autoLoadMedia, mustLoad]) - useEffect(() => { - if (!videoId || !containerRef.current || (!mustLoad && !display)) 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() - } - - let checkMutedInterval: NodeJS.Timeout | null = null - function initPlayer() { - try { - if (!videoId || !containerRef.current || !window.YT.Player) return - - let currentMuteState = muteStateRef.current - playerRef.current = new window.YT.Player(containerRef.current, { - videoId: videoId, - playerVars: { - mute: currentMuteState ? 1 : 0 - }, - events: { - onStateChange: (event: any) => { - if (event.data === window.YT.PlayerState.PLAYING) { - mediaManager.play(playerRef.current) - } else if ( - event.data === window.YT.PlayerState.PAUSED || - event.data === window.YT.PlayerState.ENDED - ) { - mediaManager.pause(playerRef.current) - } - }, - onReady: () => { - setInitSuccess(true) - checkMutedInterval = setInterval(() => { - if (playerRef.current) { - const mute = playerRef.current.isMuted() - if (mute !== currentMuteState) { - currentMuteState = mute - - if (mute !== muteStateRef.current) { - updateMuteMedia(currentMuteState) - } - } else if (muteStateRef.current !== mute) { - if (muteStateRef.current) { - playerRef.current.mute() - } else { - playerRef.current.unMute() - } - } - } - }, 200) - }, - onError: () => setError(true) - } - }) - } catch (error) { - console.error('Failed to initialize YouTube player:', error) - setError(true) - return - } - } - - return () => { - if (playerRef.current) { - playerRef.current.destroy() - } - if (checkMutedInterval) { - clearInterval(checkMutedInterval) - checkMutedInterval = null - } - } - }, [videoId, display, mustLoad]) - - useEffect(() => { - muteStateRef.current = muteMedia - }, [muteMedia]) - - useEffect(() => { - const wrapper = wrapperRef.current - - if (!wrapper || !initSuccess) return - - const observer = new IntersectionObserver( - ([entry]) => { - const player = playerRef.current - if (!player) return - - if ( - !entry.isIntersecting && - [window.YT.PlayerState.PLAYING, window.YT.PlayerState.BUFFERING].includes( - player.getPlayerState() - ) - ) { - mediaManager.pause(player) - } - }, - { threshold: 1 } - ) - - observer.observe(wrapper) - - return () => { - observer.unobserve(wrapper) - } - }, [videoId, display, mustLoad, initSuccess]) - - if (error) { + if (!videoId) { return } - if (!mustLoad && !display) { + if (!display) { return (
- } - - return ( -
-
-
- ) + return } function parseYoutubeUrl(url: string) {