diff --git a/package.json b/package.json index 8fc640eb07..13087f4009 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@babel/preset-typescript": "^7.26.0", "@babel/runtime": "^7.26.7", "@ecency/render-helper": "^2.4.24", - "@ecency/sdk": "^2.0.30", + "@ecency/sdk": "^2.0.31", "@esteemapp/dhive": "0.15.0", "@esteemapp/react-native-autocomplete-input": "^4.2.1", "@esteemapp/react-native-multi-slider": "^1.1.0", diff --git a/src/components/postHtmlRenderer/postHtmlRenderer.tsx b/src/components/postHtmlRenderer/postHtmlRenderer.tsx index 8b9e52069f..4771c85288 100644 --- a/src/components/postHtmlRenderer/postHtmlRenderer.tsx +++ b/src/components/postHtmlRenderer/postHtmlRenderer.tsx @@ -440,6 +440,15 @@ export const PostHtmlRenderer = memo( [_minTableColWidth, contentWidth], ); + // Extract thumbnail for 3Speak video orientation detection + const _speakThumbnail = useMemo(() => { + if (metadata?.type === '3speak/video' || metadata?.video?.info?.platform === '3speak') { + const images = metadata?.image; + return Array.isArray(images) ? images[0] : images; + } + return undefined; + }, [metadata]); + // iframe renderer for rendering iframes in body const _iframeRenderer = useCallback( function IframeRenderer(props) { @@ -454,12 +463,18 @@ export const PostHtmlRenderer = memo( }; return ; } else { + const isSpeakEmbed = /3speak\.tv/i.test(iframeProps.source.uri || ''); return ( - + ); } }, - [isComment, handleVideoPress, contentWidth], + [isComment, handleVideoPress, contentWidth, _speakThumbnail], ); const tagsStyles = useMemo( diff --git a/src/components/postView/children/postReadingMetadata.tsx b/src/components/postView/children/postReadingMetadata.tsx index f79c5f1cd3..c7a8adb3cd 100644 --- a/src/components/postView/children/postReadingMetadata.tsx +++ b/src/components/postView/children/postReadingMetadata.tsx @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl'; import { SheetManager } from 'react-native-actions-sheet'; import { useAiAssist } from '@ecency/sdk'; import { Icon } from '../../icon'; +import { DropdownButton } from '../../dropdownButton'; import { TTSControls } from '../../textToSpeech/ttsControls'; import { extractPlainTextForTTS, @@ -12,7 +13,9 @@ import { countWords, estimateReadingMinutes, } from '../../../utils/textToSpeech'; -import { useAuth } from '../../../hooks'; +import { getTranslation, fetchSupportedLangs } from '../../../providers/translation/translation'; +import { useAuth, useAppSelector } from '../../../hooks'; +import { selectLanguage } from '../../../redux/selectors'; import { SheetNames } from '../../../navigation/sheets'; interface PostReadingMetadataProps { @@ -86,6 +89,68 @@ const PostReadingMetadataComponent = ({ post }: PostReadingMetadataProps) => { } }, [username, plainText, assistMutation, intl]); + // Translation state + const appLang = useAppSelector(selectLanguage); + const [showTranslate, setShowTranslate] = useState(false); + const [translatedSummary, setTranslatedSummary] = useState(''); + const [isTranslating, setIsTranslating] = useState(false); + const [languages, setLanguages] = useState<{ name: string; code: string }[]>([]); + const [targetLang, setTargetLang] = useState<{ name: string; code: string } | null>(null); + + // Load languages when translate is toggled + useEffect(() => { + if (showTranslate && languages.length === 0) { + fetchSupportedLangs() + .then((res) => { + if (res?.length) { + const langs = res.map((item: any) => ({ code: item.code, name: item.name })); + setLanguages(langs); + + // Auto-select target language based on app language + const appCode = appLang?.split('-')[0]; + const match = langs.find((l: any) => l.code === appCode); + if (!targetLang) { + setTargetLang(match || { name: 'English', code: 'en' }); + } + } + }) + .catch((err) => { + console.warn('Failed to fetch languages:', err); + }); + } + }, [showTranslate]); + + // Translate when target language changes + useEffect(() => { + if (!showTranslate || !summary || !targetLang) return; + let cancelled = false; + setIsTranslating(true); + setTranslatedSummary(''); + getTranslation(summary, 'auto', targetLang.code) + .then((res) => { + if (!cancelled && res?.translatedText) { + setTranslatedSummary(res.translatedText); + } + }) + .catch((err) => { + if (!cancelled) console.warn('Translation failed:', err); + }) + .finally(() => { + if (!cancelled) setIsTranslating(false); + }); + return () => { + cancelled = true; + }; + }, [showTranslate, summary, targetLang]); + + // Reset translation when summary changes + useEffect(() => { + setShowTranslate(false); + setTranslatedSummary(''); + }, [summary]); + + const langOptions = useMemo(() => languages.map((l) => l.name), [languages]); + // Don't show if post has no readable content or less than 10 words if (!hasReadableContent(post) || wordCount < 10) { return null; @@ -162,7 +227,32 @@ const PostReadingMetadataComponent = ({ post }: PostReadingMetadataProps) => { {!!summary && ( - {summary} + + {showTranslate && translatedSummary ? translatedSummary : summary} + + {showTranslate && isTranslating && ( + + )} + + setShowTranslate(!showTranslate)}> + + {showTranslate + ? intl.formatMessage({ id: 'ai_assist.hide_translation' }) + : intl.formatMessage({ id: 'post_dropdown.translate' })} + + + {showTranslate && languages.length > 0 && ( + setTargetLang(languages[index])} + options={langOptions} + textStyle={styles.langDropdownText} + /> + )} + )} @@ -258,6 +348,34 @@ const styles = EStyleSheet.create({ color: '$primaryBlack', lineHeight: 20, }, + translateRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: '$iconColor', + gap: 12, + }, + translateLink: { + fontSize: 13, + fontWeight: '600', + color: '$primaryBlue', + }, + translateLoader: { + marginTop: 8, + }, + langDropdown: { + borderWidth: 1, + borderColor: '$iconColor', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 4, + }, + langDropdownText: { + fontSize: 12, + color: '$primaryBlack', + }, }); // Memoize to prevent unnecessary re-renders that could cause infinite loops in FlashList diff --git a/src/components/videoPlayer/videoPlayerView.tsx b/src/components/videoPlayer/videoPlayerView.tsx index b83b974f15..7ca7aa20d3 100644 --- a/src/components/videoPlayer/videoPlayerView.tsx +++ b/src/components/videoPlayer/videoPlayerView.tsx @@ -1,5 +1,11 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, StyleSheet, ActivityIndicator, useWindowDimensions } from 'react-native'; +import { + View, + StyleSheet, + ActivityIndicator, + useWindowDimensions, + Image as RNImage, +} from 'react-native'; import WebView from 'react-native-webview'; import YoutubeIframe, { InitialPlayerParams } from 'react-native-youtube-iframe'; @@ -17,6 +23,8 @@ interface VideoPlayerProps { uri?: string; // prop for youtube player disableAutoplay?: boolean; + // thumbnail URL used to detect portrait video orientation + thumbnailUrl?: string; } const VideoPlayer = ({ @@ -26,6 +34,7 @@ const VideoPlayer = ({ contentWidth, mode, disableAutoplay, + thumbnailUrl, }: VideoPlayerProps) => { const dim = useWindowDimensions(); const videoPlayer = useRef(null); @@ -40,15 +49,40 @@ const VideoPlayer = ({ const lockedOrientation = useAppSelector((state) => state.ui.lockedOrientation); const playerWidth = contentWidth || dim.width; - const defaultHeight = playerWidth * (9 / 16); - const [playerHeight, setPlayerHeight] = useState(defaultHeight); + const [playerHeight, setPlayerHeight] = useState(playerWidth * (9 / 16)); const checkSrcRegex = /(.*?)\.(mp4|webm|ogg)$/gi; const isExtensionType = mode === 'uri' ? uri.match(checkSrcRegex) : false; - // Reset height when URI changes + // Reset height when URI changes; detect portrait from thumbnail if available useEffect(() => { + let isActive = true; setPlayerHeight(playerWidth * (9 / 16)); - }, [uri, playerWidth]); + + if (thumbnailUrl) { + // Load the thumbnail to detect portrait/square orientation + RNImage.getSize( + thumbnailUrl, + (w: number, h: number) => { + if (!isActive) return; + if (w > 0 && h > 0) { + const ratio = h / w; + if (ratio > 1.05) { + // Portrait video — use 3:4 container (matching website) + // Cap at 4/3 so the embed player controls remain accessible + const cappedRatio = Math.min(ratio, 4 / 3); + setPlayerHeight(playerWidth * cappedRatio); + } + } + }, + () => { + // Ignore thumbnail load errors + }, + ); + } + return () => { + isActive = false; + }; + }, [uri, playerWidth, thumbnailUrl]); useEffect(() => { if (isFullScreen) { @@ -189,36 +223,42 @@ const VideoPlayer = ({ ); }; - const htmlIframeVideoPlayer = (_uri) => + // Escape URI for safe HTML attribute interpolation + const _sanitizeUri = (raw: string) => + raw.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + + const htmlIframeVideoPlayer = (_uri: string) => ` - + -
- -
+