mirror of
https://github.com/dergigi/boris.git
synced 2026-01-21 07:44:56 +01:00
feat: add video embed setting and processor
- Add renderVideoLinksAsEmbeds setting to UserSettings interface - Add checkbox control in ReadingDisplaySettings component - Create VideoEmbedProcessor component to handle video link embedding - Integrate VideoEmbedProcessor into ContentPanel for article rendering - Support .mp4, .webm, .ogg, .mov, .avi, .mkv, .m4v video formats - Use ReactPlayer for embedded video playback - Default to false (render as links) - When enabled, video links are rendered as embedded players
This commit is contained in:
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import VideoEmbedProcessor from './VideoEmbedProcessor'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
@@ -843,10 +844,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
<VideoEmbedProcessor
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
@@ -858,10 +860,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
<VideoEmbedProcessor
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
fullWidthImages: false,
|
||||
renderVideoLinksAsEmbeds: false,
|
||||
syncReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
hideBookmarksWithoutCreationDate: true,
|
||||
|
||||
@@ -72,6 +72,19 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
|
||||
<input
|
||||
id="renderVideoLinksAsEmbeds"
|
||||
type="checkbox"
|
||||
checked={settings.renderVideoLinksAsEmbeds === true}
|
||||
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Render video links as embeds</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
|
||||
131
src/components/VideoEmbedProcessor.tsx
Normal file
131
src/components/VideoEmbedProcessor.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useMemo, forwardRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
|
||||
interface VideoEmbedProcessorProps {
|
||||
html: string
|
||||
renderVideoLinksAsEmbeds: boolean
|
||||
className?: string
|
||||
onMouseUp?: (e: React.MouseEvent) => void
|
||||
onTouchEnd?: (e: React.TouchEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that processes HTML content and optionally embeds video links
|
||||
* as ReactPlayer components when renderVideoLinksAsEmbeds is enabled
|
||||
*/
|
||||
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
||||
html,
|
||||
renderVideoLinksAsEmbeds,
|
||||
className,
|
||||
onMouseUp,
|
||||
onTouchEnd
|
||||
}, ref) => {
|
||||
const processedHtml = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return html
|
||||
}
|
||||
|
||||
// Find all video URLs in the HTML content
|
||||
const videoUrlPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||
const videoUrls = html.match(videoUrlPattern) || []
|
||||
|
||||
// Also check for video URLs that might not have extensions but are classified as video
|
||||
const allUrlPattern = /https?:\/\/[^\s<>"']+/gi
|
||||
const allUrls = html.match(allUrlPattern) || []
|
||||
const videoUrlsWithoutExt = allUrls.filter(url => {
|
||||
const classification = classifyUrl(url)
|
||||
return classification.type === 'video' && !videoUrls.includes(url)
|
||||
})
|
||||
|
||||
const allVideoUrls = [...videoUrls, ...videoUrlsWithoutExt]
|
||||
|
||||
if (allVideoUrls.length === 0) {
|
||||
return html
|
||||
}
|
||||
|
||||
// Replace video URLs with placeholder divs that we'll replace with ReactPlayer
|
||||
let processedHtml = html
|
||||
allVideoUrls.forEach((url, index) => {
|
||||
const placeholder = `__VIDEO_EMBED_${index}__`
|
||||
processedHtml = processedHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
})
|
||||
|
||||
return processedHtml
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
const videoUrls = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return []
|
||||
}
|
||||
|
||||
const videoUrlPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||
const videoUrls = html.match(videoUrlPattern) || []
|
||||
|
||||
const allUrlPattern = /https?:\/\/[^\s<>"']+/gi
|
||||
const allUrls = html.match(allUrlPattern) || []
|
||||
const videoUrlsWithoutExt = allUrls.filter(url => {
|
||||
const classification = classifyUrl(url)
|
||||
return classification.type === 'video' && !videoUrls.includes(url)
|
||||
})
|
||||
|
||||
return [...videoUrls, ...videoUrlsWithoutExt]
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
// If no video embedding is enabled, just render the HTML normally
|
||||
if (!renderVideoLinksAsEmbeds || videoUrls.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Split the HTML by video placeholders and render with embedded players
|
||||
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
|
||||
{parts.map((part, index) => {
|
||||
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
||||
if (videoMatch) {
|
||||
const videoIndex = parseInt(videoMatch[1])
|
||||
const videoUrl = videoUrls[videoIndex]
|
||||
if (videoUrl) {
|
||||
return (
|
||||
<div key={index} className="reader-video" style={{ margin: '1rem 0' }}>
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Regular HTML content
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
VideoEmbedProcessor.displayName = 'VideoEmbedProcessor'
|
||||
|
||||
export default VideoEmbedProcessor
|
||||
Reference in New Issue
Block a user