Jake Vanderwerf
2025-10-01 37178128edb71f07c346d9e9670eb761d98947ae
Video Block
10 files added
2 files modified
574 ■■■■■ changed files
inc/blocks/RegisterBlocks.php 1 ●●●●● patch | view | raw | blame | history
inc/blocks/VideoCoverBlock.php 124 ●●●●● patch | view | raw | blame | history
inc/blocks/_setup.php 18 ●●●●● patch | view | raw | blame | history
src/video/block.json 72 ●●●●● patch | view | raw | blame | history
src/video/edit.js 218 ●●●●● patch | view | raw | blame | history
src/video/editor.scss 62 ●●●●● patch | view | raw | blame | history
src/video/index.js 9 ●●●●● patch | view | raw | blame | history
src/video/index.php patch | view | raw | blame | history
src/video/render.php 1 ●●●●● patch | view | raw | blame | history
src/video/save.js 22 ●●●●● patch | view | raw | blame | history
src/video/style.scss 35 ●●●●● patch | view | raw | blame | history
src/video/view.js 12 ●●●●● patch | view | raw | blame | history
inc/blocks/RegisterBlocks.php
@@ -12,6 +12,7 @@
require(JVB_DIR . '/build/list/render.php');
require(JVB_DIR . '/build/summary/render.php');
require(JVB_DIR . '/build/forms/render.php');
require(JVB_DIR . '/build/menu/render.php');
function jvbRegisterBlocks():void
{
inc/blocks/VideoCoverBlock.php
New file
@@ -0,0 +1,124 @@
<?php
namespace JVBase\blocks;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Video Cover Block Class
 *
 * Handles registration and rendering of self-hosted video cover blocks
 * with poster images and multiple video format support
 */
class VideoCoverBlock
{
    protected static ?VideoCoverBlock $instance = null;
    protected string $path = JVB_DIR . '/build/video-cover';
    public static function getInstance(): VideoCoverBlock
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    public function __construct()
    {
        add_action('init', [$this, 'registerBlock']);
    }
    public function registerBlock(): void
    {
        register_block_type($this->path, [
            'render_callback' => [$this, 'render']
        ]);
    }
    /**
     * Render the video cover block
     */
    public function render($attributes, $content): string
    {
        // Extract attributes with defaults
        $poster_id = $attributes['posterId'] ?? 0;
        $video_sources = $attributes['videoSources'] ?? [];
        $mobile_sources = $attributes['mobileSources'] ?? [];
        $css_class = $attributes['className'] ?? '';
        $fade_class = $attributes['fadeEffect'] ?? false ? 'fade' : '';
        //Get date of current post
        global $post;
        $date = date('c',strtotime($post->post_date));
        $title = $attributes['title'] ?? $post->post_title;
        $description = $attributes['description'] ?? $post->post_excerpt;
        // If no video sources, return empty
        if (empty($video_sources)) {
            return '';
        }
        // Get poster URL
        $poster_url = $poster_id ? wp_get_attachment_url($poster_id) : '';
        // Build video tag
        $classes = trim("video-cover {$fade_class} {$css_class}");
        $html = '<section class="'.esc_attr($classes).'">
        <script type="application/ld+json">
        {
            "@context": "https://schema.org/",
            "@type": "VideoObject",
            "name": "'.$title.'",
            "thumbnailUrl": "'.$poster_url.'",
            "contentUrl": "'.$video_sources[0]['url'].'",
            "description": "'.$description.'",
            "uploadDate": "'.$date.'"
        }
    </script>
    <div class="wrap">
        <div class="video-container">';
        $html .= '<video';
        $html .= ' muted loop playsinline autoplay';
        if ($poster_url) {
            $html .= ' poster="' . esc_url($poster_url) . '"';
        }
        $html .= '>';
        // Add mobile sources first (lower resolution)
        foreach ($mobile_sources as $source) {
            if (!empty($source['url']) && !empty($source['mime'])) {
                $html .= '<source';
                $html .= ' src="' . esc_url($source['url']) . '"';
                $html .= ' type="' . esc_attr($source['mime']) . '"';
                $html .= ' media="(max-width: 767px)"';
                $html .= '>';
            }
        }
        // Add desktop sources
        foreach ($video_sources as $source) {
            if (!empty($source['url']) && !empty($source['mime'])) {
                $html .= '<source';
                $html .= ' src="' . esc_url($source['url']) . '"';
                $html .= ' type="' . esc_attr($source['mime']) . '"';
                // Add media query for desktop if mobile sources exist
                if (!empty($mobile_sources)) {
                    $html .= ' media="(min-width: 768px)"';
                }
                $html .= '>';
            }
        }
        $html .= '</video>';
        $html .= '</div></div><div class="inner-wrap"></div></section>';
        return $html;
    }
}
inc/blocks/_setup.php
@@ -1,7 +1,7 @@
<?php
use JVBase\utility\Features;
require(JVB_DIR . '/inc/blocks/RegisterBlocks.php');
//require(JVB_DIR . '/inc/blocks/RegisterBlocks.php');
require(JVB_DIR . '/inc/blocks/CustomBlocks.php');
if (Features::forSite()->has('feed_block')) {
@@ -17,5 +17,21 @@
require(JVB_DIR . '/inc/blocks/SummaryBlock.php');
new JVBase\blocks\SummaryBlock();
require(JVB_DIR . '/inc/blocks/VideoCoverBlock.php');
new JVBase\blocks\VideoCoverBlock();
require(JVB_DIR . '/inc/blocks/FormBlock.php');
new JVBase\blocks\FormBlock();
function jvbRegisterBlockCategory(array $categories):array
{
    return array_merge($categories, [
        [
            'slug' => 'jvb',
            'title' => get_bloginfo('name'),
            'icon' => 'art'
        ]
    ]);
}
add_filter('block_categories_all', 'jvbRegisterBlockCategory');
src/video/block.json
New file
@@ -0,0 +1,72 @@
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "jvb/video-cover",
    "version": "1.0.0",
    "title": "Video Cover",
    "category": "jvb",
    "icon": "video-alt3",
    "description": "Self-hosted video cover with poster and multiple format support",
    "supports": {
        "html": false,
        "align": ["wide", "full"],
        "spacing": {
            "margin": true,
            "padding": true
        }
    },
    "attributes": {
        "posterId": {
            "type": "number",
            "default": 0
        },
        "posterUrl": {
            "type": "string",
            "default": ""
        },
        "videoSources": {
            "type": "array",
            "default": [],
            "items": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "number"
                    },
                    "url": {
                        "type": "string"
                    },
                    "mime": {
                        "type": "string"
                    }
                }
            }
        },
        "mobileSources": {
            "type": "array",
            "default": [],
            "items": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "number"
                    },
                    "url": {
                        "type": "string"
                    },
                    "mime": {
                        "type": "string"
                    }
                }
            }
        },
        "fadeEffect": {
            "type": "boolean",
            "default": false
        }
    },
    "textdomain": "jvb",
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css"
}
src/video/edit.js
New file
@@ -0,0 +1,218 @@
import { __ } from '@wordpress/i18n';
import {
    useBlockProps,
    InspectorControls,
    MediaUpload,
    MediaUploadCheck,
    InnerBlocks
} from '@wordpress/block-editor';
import {
    PanelBody,
    Button,
    ToggleControl,
    BaseControl
} from '@wordpress/components';
import './editor.scss';
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg', 'video/ogv'];
export default function Edit({ attributes, setAttributes }) {
    const {
        posterId,
        posterUrl,
        videoSources,
        mobileSources,
        fadeEffect
    } = attributes;
    const blockProps = useBlockProps({
        className: 'video-cover-editor'
    });
    const onSelectPoster = (media) => {
        setAttributes({
            posterId: media.id,
            posterUrl: media.url
        });
    };
    const onSelectVideo = (media, isMobile = false) => {
        const newSource = {
            id: media.id,
            url: media.url,
            mime: media.mime
        };
        if (isMobile) {
            // Check if this format already exists in mobile sources
            const exists = mobileSources.some(s => s.mime === media.mime);
            if (!exists) {
                setAttributes({
                    mobileSources: [...mobileSources, newSource]
                });
            }
        } else {
            // Check if this format already exists in desktop sources
            const exists = videoSources.some(s => s.mime === media.mime);
            if (!exists) {
                setAttributes({
                    videoSources: [...videoSources, newSource]
                });
            }
        }
    };
    const removeVideoSource = (index, isMobile = false) => {
        if (isMobile) {
            const updated = [...mobileSources];
            updated.splice(index, 1);
            setAttributes({ mobileSources: updated });
        } else {
            const updated = [...videoSources];
            updated.splice(index, 1);
            setAttributes({ videoSources: updated });
        }
    };
    const renderVideoSourceList = (sources, isMobile = false) => {
        if (sources.length === 0) return null;
        return (
            <ul className="video-source-list">
                {sources.map((source, index) => (
                    <li key={index} className="video-source-item">
                        <span className="video-source-mime">{source.mime}</span>
                        <Button
                            isDestructive
                            isSmall
                            onClick={() => removeVideoSource(index, isMobile)}
                        >
                            {__('Remove', 'jvb')}
                        </Button>
                    </li>
                ))}
            </ul>
        );
    };
    return (
        <>
            <InspectorControls>
                <PanelBody title={__('Video Settings', 'jvb')} initialOpen={true}>
                    <BaseControl
                        label={__('Poster Image', 'jvb')}
                        help={__('Image shown while video loads', 'jvb')}
                    >
                        <MediaUploadCheck>
                            <MediaUpload
                                onSelect={onSelectPoster}
                                allowedTypes={['image']}
                                value={posterId}
                                render={({ open }) => (
                                    <>
                                        {posterUrl && (
                                            <img
                                                src={posterUrl}
                                                alt={__('Poster preview', 'jvb')}
                                                style={{ maxWidth: '100%', marginBottom: '10px' }}
                                            />
                                        )}
                                        <Button
                                            onClick={open}
                                            variant={posterUrl ? 'secondary' : 'primary'}
                                        >
                                            {posterUrl
                                                ? __('Change Poster', 'jvb')
                                                : __('Select Poster', 'jvb')}
                                        </Button>
                                        {posterUrl && (
                                            <Button
                                                isDestructive
                                                onClick={() => setAttributes({ posterId: 0, posterUrl: '' })}
                                                style={{ marginLeft: '10px' }}
                                            >
                                                {__('Remove', 'jvb')}
                                            </Button>
                                        )}
                                    </>
                                )}
                            />
                        </MediaUploadCheck>
                    </BaseControl>
                    <BaseControl
                        label={__('Desktop Video Sources', 'jvb')}
                        help={__('Add multiple formats for better browser support', 'jvb')}
                    >
                        {renderVideoSourceList(videoSources, false)}
                        <MediaUploadCheck>
                            <MediaUpload
                                onSelect={(media) => onSelectVideo(media, false)}
                                allowedTypes={ALLOWED_VIDEO_TYPES}
                                render={({ open }) => (
                                    <Button
                                        onClick={open}
                                        variant="secondary"
                                    >
                                        {__('Add Desktop Video', 'jvb')}
                                    </Button>
                                )}
                            />
                        </MediaUploadCheck>
                    </BaseControl>
                    <BaseControl
                        label={__('Mobile Video Sources (Optional)', 'jvb')}
                        help={__('Smaller videos for mobile devices', 'jvb')}
                    >
                        {renderVideoSourceList(mobileSources, true)}
                        <MediaUploadCheck>
                            <MediaUpload
                                onSelect={(media) => onSelectVideo(media, true)}
                                allowedTypes={ALLOWED_VIDEO_TYPES}
                                render={({ open }) => (
                                    <Button
                                        onClick={open}
                                        variant="secondary"
                                    >
                                        {__('Add Mobile Video', 'jvb')}
                                    </Button>
                                )}
                            />
                        </MediaUploadCheck>
                    </BaseControl>
                    <ToggleControl
                        label={__('Fade Effect', 'jvb')}
                        help={__('Add fade class to video element', 'jvb')}
                        checked={fadeEffect}
                        onChange={(value) => setAttributes({ fadeEffect: value })}
                    />
                </PanelBody>
            </InspectorControls>
            <div {...blockProps}>
                {posterUrl ? (
                    <div className="video-cover-preview">
                        <img src={posterUrl} alt={__('Video poster', 'jvb')} />
                        <div className="video-overlay">
                            <p>
                                {videoSources.length} {__('desktop source(s)', 'jvb')}
                                {mobileSources.length > 0 &&
                                    `, ${mobileSources.length} ${__('mobile source(s)', 'jvb')}`
                                }
                            </p>
                        </div>
                    </div>
                ) : (
                    <div className="video-cover-placeholder">
                        <p>{__('Configure video sources in the sidebar →', 'jvb')}</p>
                    </div>
                )}
            </div>
            <div className="inner-blocks">
                <InnerBlocks />
            </div>
        </>
    );
}
src/video/editor.scss
New file
@@ -0,0 +1,62 @@
.video-cover-editor {
    min-height: 200px;
    background: #f0f0f0;
    border: 2px dashed #ccc;
    border-radius: 4px;
    .video-cover-preview {
        position: relative;
        width: 100%;
        img {
            width: 100%;
            height: auto;
            display: block;
        }
        .video-overlay {
            position: absolute;
            bottom: 0;
            left: 0;
            right: 0;
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 10px;
            font-size: 14px;
            p {
                margin: 0;
            }
        }
    }
    .video-cover-placeholder {
        display: flex;
        align-items: center;
        justify-content: center;
        min-height: 200px;
        color: #666;
        font-size: 16px;
    }
}
.video-source-list {
    list-style: none;
    margin: 10px 0;
    padding: 0;
    .video-source-item {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 8px 12px;
        background: #f5f5f5;
        border-radius: 4px;
        margin-bottom: 5px;
        .video-source-mime {
            font-family: monospace;
            font-size: 13px;
        }
    }
}
src/video/index.js
New file
@@ -0,0 +1,9 @@
import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import metadata from './block.json';
registerBlockType(metadata.name, {
    edit: Edit,
    save: () => null // Server-side rendering
});
src/video/index.php
src/video/render.php
New file
@@ -0,0 +1 @@
<?php
src/video/save.js
New file
@@ -0,0 +1,22 @@
/**
 * React hook that is used to mark the block wrapper element.
 * It provides all the necessary props like the class name.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
 */
import { useBlockProps } from '@wordpress/block-editor';
/**
 * The save function defines the way in which the different attributes should
 * be combined into the final markup, which is then serialized by the block
 * editor into `post_content`.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#save
 *
 * @return {WPElement} Element to render.
 */
export default function save() {
    // This is a dynamic block that is rendered on the server side
    // Return null to let WordPress handle the saving and rendering
    return null;
}
src/video/style.scss
New file
@@ -0,0 +1,35 @@
.video-cover {
    width: 100%;
    height: auto;
    display: block;
    object-fit: cover;
    &.fade {
        animation: fadeIn 1s ease-in;
    }
}
@keyframes fadeIn {
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}
.video-cover video {
    pointer-events: none;
    position: absolute;
    top: 0;
    left: 50%;
    opacity: 0.85;
    mix-blend-mode: multiply;
    transform: translate(-40%,-10%);
    height: 105vh;
    width: 300vw!important;
    filter: grayscale(100%) contrast(1);
    flex: 1 0 100%;
    max-width: none;
}
src/video/view.js
New file
@@ -0,0 +1,12 @@
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            loadVideo(entry.target);
            observer.unobserve(entry.target);
        }
    });
});
document.querySelectorAll('.video-container .placeholder').forEach(el => {
    observer.observe(el);
});