import React, { useState, useEffect, useRef } from 'react' import ReactPlayer from 'react-player' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons' import { RelayPool } from 'applesauce-relay' import { IAccount } from 'applesauce-accounts' import { UserSettings } from '../services/settingsService' import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService' import { buildNativeVideoUrl } from '../utils/videoHelpers' import { getYouTubeThumbnail } from '../utils/imagePreview' // Helper function to get Vimeo thumbnail const getVimeoThumbnail = (url: string): string | null => { const vimeoMatch = url.match(/vimeo\.com\/(\d+)/) if (!vimeoMatch) return null const videoId = vimeoMatch[1] return `https://vumbnail.com/${videoId}.jpg` } import { createWebsiteReaction, hasMarkedWebsiteAsRead } from '../services/reactionService' import { unarchiveWebsite } from '../services/unarchiveService' import ReaderHeader from './ReaderHeader' interface VideoViewProps { videoUrl: string title?: string image?: string summary?: string published?: number settings?: UserSettings relayPool?: RelayPool | null activeAccount?: IAccount | null onOpenHighlights?: () => void } const VideoView: React.FC = ({ videoUrl, title, image, summary, published, settings, relayPool, activeAccount, onOpenHighlights }) => { const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false) const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false) const [showCheckAnimation, setShowCheckAnimation] = useState(false) const [showVideoMenu, setShowVideoMenu] = useState(false) const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false) const [videoDurationSec, setVideoDurationSec] = useState(null) const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null) const videoMenuRef = useRef(null) // Load YouTube metadata when applicable useEffect(() => { (async () => { try { if (!videoUrl) return setYtMeta(null) const id = extractYouTubeId(videoUrl) if (!id) return setYtMeta(null) const locale = navigator?.language?.split('-')[0] || 'en' const data = await getYouTubeMeta(id, locale) if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript }) } catch { setYtMeta(null) } })() }, [videoUrl]) // Check if video is marked as watched useEffect(() => { const checkWatchedStatus = async () => { if (!activeAccount || !videoUrl) return setIsCheckingWatchedStatus(true) try { const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false setIsMarkedAsWatched(isWatched) } catch (error) { console.warn('Failed to check watched status:', error) } finally { setIsCheckingWatchedStatus(false) } } checkWatchedStatus() }, [activeAccount, videoUrl, relayPool]) // Handle click outside to close menu useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node if (videoMenuRef.current && !videoMenuRef.current.contains(target)) { setShowVideoMenu(false) } } if (showVideoMenu) { document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } } }, [showVideoMenu]) // Check menu position for upward opening useEffect(() => { const checkMenuPosition = (menuRef: React.RefObject, setOpenUpward: (upward: boolean) => void) => { if (!menuRef.current) return const rect = menuRef.current.getBoundingClientRect() const viewportHeight = window.innerHeight const spaceBelow = viewportHeight - rect.bottom const spaceAbove = rect.top // Open upward if there's more space above and less space below setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200) } if (showVideoMenu) { checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward) } }, [showVideoMenu]) const formatDuration = (totalSeconds: number): string => { const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = Math.floor(totalSeconds % 60) const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes) const ss = String(seconds).padStart(2, '0') return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}` } const handleMarkAsWatched = async () => { if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return setIsCheckingWatchedStatus(true) setShowCheckAnimation(true) try { if (isMarkedAsWatched) { // Unmark as watched if (relayPool) { await unarchiveWebsite(videoUrl, activeAccount, relayPool) } setIsMarkedAsWatched(false) } else { // Mark as watched if (relayPool) { await createWebsiteReaction(videoUrl, activeAccount, relayPool) } setIsMarkedAsWatched(true) } } catch (error) { console.warn('Failed to update watched status:', error) } finally { setIsCheckingWatchedStatus(false) setTimeout(() => setShowCheckAnimation(false), 1000) } } const toggleVideoMenu = () => setShowVideoMenu(v => !v) const handleOpenVideoExternal = () => { window.open(videoUrl, '_blank', 'noopener,noreferrer') setShowVideoMenu(false) } const handleOpenVideoNative = () => { const native = buildNativeVideoUrl(videoUrl) if (native) { window.location.href = native } else { window.location.href = videoUrl } setShowVideoMenu(false) } const handleCopyVideoUrl = async () => { try { await navigator.clipboard.writeText(videoUrl) } catch (e) { console.warn('Clipboard copy failed', e) } finally { setShowVideoMenu(false) } } const handleShareVideoUrl = async () => { try { if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise }).share) { await (navigator as { share: (d: { title?: string; url?: string }) => Promise }).share({ title: ytMeta?.title || title || 'Video', url: videoUrl }) } else { await navigator.clipboard.writeText(videoUrl) } } catch (e) { console.warn('Share failed', e) } finally { setShowVideoMenu(false) } } const displayTitle = ytMeta?.title || title const displaySummary = ytMeta?.description || summary const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null // Get video thumbnail for cover image const youtubeThumbnail = getYouTubeThumbnail(videoUrl) const vimeoThumbnail = getVimeoThumbnail(videoUrl) const videoThumbnail = youtubeThumbnail || vimeoThumbnail const displayImage = videoThumbnail || image return ( <>
setVideoDurationSec(Math.floor(d))} />
{displaySummary && (
{displaySummary}
)} {ytMeta?.transcript && (

Transcript

{ytMeta.transcript}
)}
{showVideoMenu && (
)}
{activeAccount && (
)} ) } export default VideoView