XRV (Xroad Video)
Privacy-first WordPress video gallery by Crossroad Media. A click-to-load YouTube facade that eliminates the consent-banner overlays, black/blank players, and pre-consent Google requests that Smash Balloon YouTube Feed plus a cookie manager produce. Preoads nothing from YouTube. Local thumbnails + self-generating VideoObject schema.
Install
Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/crossroad-media/xroad-videos/releases/download/v2.11.3/xroad-videos.zipCrossroad Videos (xroad-videos)
A single-file WordPress plugin by Crossroad Media. A privacy-first, click-to-load video gallery and a drop-in alternative to Smash Balloon YouTube Feed for any site running a cookie/consent manager. Supports YouTube, Vimeo, Wistia, Loom, Dailymotion, and self-hosted MP4 / WebM behind one facade, with geo-aware GDPR consent modes, a built-in bulk importer, a site-wide settings page, and multiple layouts.
Why it exists
Smash Balloon YouTube Feed (and standard YouTube embeds) fire requests to youtube.com / i.ytimg.com / google.com on page load, before consent. When a consent manager (CookieYes, Osano, Cookiebot, Complianz, etc.) is present, it tries to intercept those requests, and that interception is what produces the symptoms operators keep reporting:
- a consent warning overlay sitting on top of the player,
- a black or blank player that never loads,
- cascade JavaScript failures when the CMP and the feed's consent shim collide,
- and corrupted GA4 attribution when a page-refresh "fix" is used to force the player to load.
Every one of those has the same root cause: third-party requests firing before a deliberate user action.
This plugin inverts that. Every card starts as a local poster image + a play button: pure first-party HTML/CSS, zero requests to any Google domain, zero cookies, zero localStorage. Because nothing third-party fires before interaction, the consent manager has nothing to block, so no banner, overlay, or black player can appear. Only on click does it inject a youtube-nocookie.com iframe (the click is the consent) and push a video_play event to the dataLayer. Storing thumbnails locally hardens the guarantee: even the poster makes no call to i.ytimg.com.
This is the "facade" pattern.
What it does
- Curated CPT
xroad_video. Editors paste a video URL from any supported host and drag to reorder; the provider is auto-detected and no API key is required. - Built-in bulk importer (Videos → Import). Paste a list of URLs, point at a channel or playlist (optionally with a free API key for durations and upload dates), or upload a JSON file carrying full metadata and taxonomy terms. A dry-run preview shows new vs. already-in-library before anything is written, then the import runs in batched AJAX with a progress bar.
- Local thumbnail sideload on save, so the grid references
/wp-content/uploads/and never calls the host's image CDN. YouTube uses its predictable poster URLs (maxres with hqdefault fallback, checked by HTTP status); every other host supplies its poster, and for all but YouTube its duration, from its own no-key oEmbed endpoint, which also prefills the title on first save. - Layouts. A featured carousel, a browse grid, or both in one shortcode (
layout="library"), with lightbox or inline playback. Filtering (dropdown selects or chips for Series / Audience / Topic), keyword search, and sort (including shortest or longest by duration) are pure client-side toggles, so they run instantly with no network round-trip. Paged browse adds a Load More button and an optional Subscribe button, and the grid steps 3 to 2 to 1 columns on smaller screens. - Geo-aware consent modes. Global (recommended), Strict GDPR, or No consent integration. Global shows a dismissible opt-in prompt only to EU/UK/EEA/CH visitors (resolved from an edge country header) and stays frictionless for everyone else, including US / CCPA, since the facade shares no data with YouTube until a click. Whenever a prompt is required the plugin makes zero contact with any Google domain until the visitor accepts. See Privacy, consent & GDPR below.
- Site-wide settings page (Videos → Settings). Set the consent mode, privacy URL, filter style, per-page counts, Subscribe URL, and YouTube Data API key once; every gallery inherits them, and any shortcode or block attribute still overrides.
- Self-generating VideoObject JSON-LD inside a
CollectionPage/ItemList, merging with the site's Organization node via thexrv_org_idfilter. Single-video pages emit a standaloneVideoObjectwith transcript and key-momentClips for rich-result and AI-citation eligibility. - Shortcode, block, and block sidebar controls under the collision-proof
xroadnamespace. The Gutenberg block exposes Layout, Browse, Privacy, and pre-filter panels through InspectorControls, with no build step. - Three REST-exposed taxonomies (
xrv_series,xrv_audience,xrv_topic); sites define their own terms. - Multi-source. One
_xrv_providerswitch routes YouTube, Vimeo, Wistia, Loom, Dailymotion, and self-hosted files, auto-detected from the pasted URL. Hosted videos load on click as the host's privacy-enhanced iframe (Vimeo withdnt=1); self-hosted files play in a native<video>element with zero third-party contact ever. Vimeo unlisted and domain-private videos keep their privacy hash, and galleries can mix sources in a single grid.
No page builder, ACF, jQuery, or build step. The inline-asset architecture (CSS, JS, and SVG emitted once per request) survives a performance plugin's unused-CSS pass and moves between themes unchanged.
Usage
[xroad-videos]
[xroad-videos layout="library" per_page="9" subscribe_url="https://youtube.com/@yourchannel"]
[xroad-videos series="webinars" columns="3" filter_ui="chips" consent_notice="geo" privacy_url="/privacy-policy/"]
Block: xroad/videos (a PHP-rendered dynamic block that shares the shortcode's render path). Every attribute below is also a block sidebar control, and anything left blank inherits the site-wide default from Videos → Settings, which a shortcode or block attribute always overrides.
Sources
Editors never pick a provider by hand: paste a URL and the host is detected automatically (the Provider selector defaults to Auto-detect). Supported sources:
| Source | Paste | Metadata without an API key |
|---|---|---|
| YouTube | watch, youtu.be, Shorts, or embed URL |
title, poster |
| Vimeo | vimeo.com/... (including unlisted vimeo.com/{id}/{hash}) |
title, poster, duration, description |
| Wistia | a wistia.com/medias/... URL |
title, poster, duration |
| Loom | loom.com/share/... |
title, poster, duration |
| Dailymotion | dailymotion.com/video/... or dai.ly/... |
title, poster |
| TikTok | a tiktok.com/@user/video/... link (vertical, rendered 9:16) |
title, poster |
| Self-hosted file | a direct .mp4 or .webm URL (pick Self-hosted file) |
none; set the title and upload a poster yourself |
Hosted videos load on click as the host's privacy-enhanced player (Vimeo uses dnt=1 with no title or byline chrome); self-hosted files play in a native <video> element, so they make no third-party contact at any point. Galleries can mix sources freely in one grid. The bulk importer and channel sync are currently YouTube-only; extending bulk import to every provider is a planned roadmap item.
Content and filtering
| Attribute | Default | What it does |
|---|---|---|
series, audience, topic |
all | Pre-filter to one or more taxonomy term slugs (comma-separated). |
limit |
all | Cap how many videos render. |
filter_ui |
select |
Facet filter style: select (dropdowns) or chips (clickable rows). |
controls |
true |
false hides the search / sort / filter bar. |
Layout
| Attribute | Default | What it does |
|---|---|---|
layout |
grid |
grid, carousel, or library (a featured carousel above a browse grid). |
columns |
responsive | Fixed column count; blank gives responsive columns (masonry for grid, 3 for library/carousel). |
featured_limit |
6 |
How many videos feed the featured carousel. |
heading |
none | Optional centered section title above a grid or carousel. |
playback |
lightbox |
lightbox pops the video into a centered overlay; inline plays it in the card. |
per_page |
9 |
Cards shown before a Load More button appears. |
load_more |
3 |
How many more cards each Load More click reveals. |
subscribe_url, subscribe_label |
off | Show a Subscribe button under the grid when subscribe_url is set; subscribe_label sets its text. |
Privacy and consent
| Attribute | Default | What it does |
|---|---|---|
consent_notice |
off |
geo = Global (opt-in prompt for EU/UK/EEA/CH; one-click + facade elsewhere, including US / CCPA), strict = Strict GDPR (opt-in prompt for everyone), off = no consent layer. (light = legacy on-video caption, still honored.) See Privacy, consent & GDPR. |
consent_text |
built-in | Body text of the consent prompt. |
consent_button |
Load video |
Accept-button label. |
consent_decline |
No thanks |
Decline-button label. |
privacy_url |
WP privacy page | Privacy-policy link shown in the prompt. |
Install
Single-file plugin. Either upload xroad-videos.php to wp-content/plugins/xroad-videos/, or zip the folder (xroad-videos/xroad-videos.php) and upload via Plugins → Add New → Upload. Activate; the CPT and taxonomies register on activation.
Configuration filters
| Filter | Purpose |
|---|---|
xrv_org_id |
Pin the publisher @id to your SEO plugin's exact Organization @id so the two schema nodes merge instead of competing. |
xrv_seed_terms |
Pre-seed taxonomy terms on activation: return [ taxonomy => [ slug => name ] ]. Idempotent. |
xrv_synonym_map |
Extend the keyword index: return [ term-slug => 'extra search aliases' ]. |
xrv_video_schema |
Modify a single VideoObject node (add transcript, regionsAllowed, about, etc.). |
xrv_list_name |
Override the CollectionPage / ItemList name. |
xrv_consent_required |
Override the geo consent decision for the current request (force a region, plug in MaxMind, defer to your CMP, and so on). Return true to require the consent prompt, false to allow one-click play. |
Example, merging schema with Yoast/Rank Math's Organization node:
add_filter( 'xrv_org_id', fn() => 'https://example.com/#organization' );
Verify the privacy guarantee
Open a page using the gallery in DevTools (Network tab, cache disabled) and confirm zero requests to youtube.com / youtube-nocookie.com / google.com / i.ytimg.com before any click, and zero YouTube cookies / localStorage. Repeat with your consent manager active and confirm no banner, overlay, or black screen. If any Google-domain request fires pre-click, that's a bug; open an issue.
Privacy, consent & GDPR
The gallery is a click-to-load facade: every card is a local first-party poster + a play button. Nothing (no request, cookie, connection, or localStorage) reaches the video host until a visitor deliberately clicks. On click it injects the host's privacy-enhanced player (a youtube-nocookie.com iframe for YouTube, player.vimeo.com with dnt=1 for Vimeo, the equivalent for Wistia / Loom / Dailymotion, or a native <video> for self-hosted files); that click is the consent that loads the embed.
On top of that baseline, the Consent mode (Settings → Videos → Settings, or consent_notice=) sets how consent is obtained:
| Mode | consent_notice |
What the visitor gets | Pre-click contact with the host |
|---|---|---|---|
| Global (recommended) | geo |
EU/UK/EEA/CH visitors get the opt-in "Load video" prompt; everyone else (including US / CCPA) plays in one click | none for prompted visitors; preconnect for others |
| Strict GDPR | strict |
opt-in prompt for every visitor, worldwide | none for anyone |
| No consent integration | off |
no prompt or notice | preconnect on hover (connection only, no data/cookies) |
The opt-in prompt is declinable (× / "No thanks"), so refusing is as easy as accepting. Global satisfies GDPR where it applies (a prior-consent gate for EU/UK/EEA/CH) and the US notice-and-opt-out model elsewhere, because the facade shares no data with YouTube until the click. The legacy light value (an on-video caption) is still honored if set via shortcode. (Informational only, not legal advice.)
Key points:
- The plugin sets no cookies of its own. The only client-side storage it writes is a first-party
sessionStorageflag caching the geo decision. That is not a cookie, not an identifier, and it clears on tab close. - Dismissible consent. The prompt has an
×and a "No thanks" button; declining closes it and loads nothing, so refusing is as easy as accepting. - Zero pre-click contact when gated. Whenever a prompt is required (Strict for everyone; Compliance for EU/UK/EEA/CH), the hover
preconnectis suppressed too, so the visitor's browser makes no DNS/TLS/HTTP contact with any Google domain until they accept. - Geo detection reads an edge country header (
CF-IPCountry/ WP Engine / CloudFront) via a cache-safe REST call (/wp-json/xrv/v1/region); the page itself stays fully cacheable. With no header present it fails safe to showing the prompt to everyone. Override the logic with thexrv_consent_requiredfilter. - Scope. This governs the video player only. Other site trackers (analytics, ad tags, consent managers) are independent; gate those at your CMP / Google Consent Mode. The facade also doesn't forward video-viewing data anywhere (relevant to the US VPPA): the
video_playdataLayer event is first-party; don't wire it to a third party with an identifier. - Not legal advice. The click-to-load facade with an informed, dismissible prompt is the widely-recognized compliant pattern, but your DPO/counsel makes the final determination for your jurisdiction and content.
Settings for the prompt: consent_text, consent_button (accept label), consent_decline (decline label), privacy_url (defaults to your WordPress privacy page).
Analytics (optional)
On play, the plugin pushes to window.dataLayer:
{ event: 'video_play', video_provider, video_id, video_title, video_series }
Wire it in GTM with a Custom Event trigger on video_play and a GA4 event tag reading those data-layer variables. Because the facade never refreshes the page, it avoids the attribution corruption that page-refresh consent workarounds cause.
Changelog
2.10.0
Front-end + admin. Two lightbox upgrades: the pop-out modal becomes a per-device choice, and it can now carry a details panel (title, date, collapsible description) under the player. The zero-third-party-request-before-click guarantee is untouched.
- Device-scoped playback. XRV → Settings → Browse defaults → Play videos in now offers: lightbox on all devices, lightbox on desktop / inline on mobile, lightbox on mobile / inline on desktop, or inline everywhere. The XRV Video block and
[xroad-videos playback="…"]accept the same four values (plus Site default), so any gallery can override the site setting. - Decided in the browser, not on the server. The desktop / mobile split is resolved client-side by viewport width (under 768 px is mobile) at the moment of the click, so the rendered HTML is identical for every visitor and the page stays fully cacheable — no UA sniffing, no cache fragmentation. A rotate or resize lands the next play in the right mode with no reload.
- Lightbox details panel. A watch-page-style caption can now sit under the player in the lightbox: the title, a relative upload date (“2 months ago”), and the description collapsed to four lines behind a Description toggle. On by default; turn it off at Settings → Browse defaults → Lightbox details, or per gallery with
[xroad-videos lightbox_details="false"]/ the block toggle. The panel is text rendered from the video’s own stored metadata — no view count, and no extra network calls. Shorts stay player-only.
2.9.0
Adds Collections — build more than one curated gallery and drop each anywhere on the site, including the hero. Removes the 2.8.0 privacy self-test. Existing single galleries are unchanged; the zero-third-party-request-before-click guarantee is untouched.
- Collections (new Collections screen under XRV Video). A collection is a saved, placeable gallery: hand-pick and order specific videos, choose a layout (grid / carousel / library), and drop it with
[xroad-videos collection="slug"]or the XRV Video block's Collection slug field — in a page, a widget, an FSE template part, or the theme hero. Presentation (columns, heading, etc.) is set on the shortcode/block at each placement. Dependency-free editor (no wp.media, no build step). idsattribute.[xroad-videos ids="12,7,30"]renders an exact, ordered set viapost__in— what collections ride on, usable on its own too.- Multiple galleries per page. The facade is now fully instance-scoped (wrapper and every control are class- or per-instance-id based), so two carousels plus a filtered grid coexist on one page with independent search / sort / filter.
- Removed: the privacy self-test shipped in 2.8.0 — low practical value in an isolated render; may return later as a real-page check.
2.8.0
Adds a Privacy self-test that turns the zero-third-party guarantee from a claim into an in-product proof. Front-end harness + admin diagnostic; no new dependencies, no front-end weight on real pages.
- Resource-Timing privacy self-test (Settings → Privacy self-test). One click renders your real gallery in an isolated, admin-only frame, reads the browser's
performance.getEntriesByType('resource')log without ever dispatching a play, and reports a clear PASS/FAIL — naming by URL any pre-click request to a video-provider host (YouTube, Vimeo, Wistia, Loom, Dailymotion, TikTok and their CDNs). It is both the strongest privacy demo for a regulated-industry buyer and a standing regression guard: a future change that leaked a pre-click request turns this red. Requests to other origins you control (e.g. an image CDN) are reported but do not fail the test. - Verify the detector. A negative-control mode loads a deliberately leaky page (one planted provider request) so you can watch the detector catch a real leak — the only place XRV ever contacts a provider without a click, and only when an admin explicitly runs it.
- Doc-hygiene: corrected the stale note that multi-source bulk import was "planned for 2.5."
2.7.1
Admin-only. Enhanced Watch Page Controls: finer control over each video's standalone page, plus a clearer home for the bulk switch.
- Editable watch page slug in the editor. Ticking "Give this video its own watch page" now reveals an editable URL-slug field, so the standalone page's address is set inline (kept unique by WordPress). It hides when the video is gallery-only.
- Watch Page Controls, relocated. The bulk on/off card (renamed from "Bulk watch pages") now sits beside the Video URLs card in Settings, with its own nav chip, grouped where it belongs since watch pages are the per-video URLs.
2.7.0
Admin-only. Bulk controls for the two things a privacy-first curated library has that a feed plugin does not: per-video SEO watch pages and local-poster integrity.
- Bulk Actions on All Videos. The native Bulk Actions dropdown gains Watch page: turn off / turn on (set a selected set's standalone-page state in one go) and Rebuild local posters (re-pull each selected video's poster from its provider, swap in the fresh copy, and drop the stale one — the repair tool for broken or missing thumbnails). A new Watch page column shows each video's On/Off state at a glance.
- One-click whole-library toggle. Settings gains Turn off all watch pages / Turn all back on buttons that apply to every video at once, for when you want the library gallery-only without selecting row by row.
2.6.0
Admin-only. Extends the bulk importer so the watch page state is part of the import record, making it controllable per video from a file.
watch_pageimport field. A.jsonimport record now accepts"watch_page": "0"(no standalone watch page) or"1"(force on); omit it to leave the video's current setting untouched. Accepts string, number, or boolean (false/"no"/"off"all map to off), so a hand-authored file can switch watch pages off without knowing the stored value. Paired with Overwrite, a re-import flips videos already in the library; on a fresh import it sets the state at creation. The URL/channel/playlist paste paths are unaffected (they carry no such field, so the default stays on).
2.5.1
Reworks the per-video "dedicated page" control into a clearer watch page toggle.
- Watch page toggle (on by default). Each video can have its own standalone watch page — its own URL, with the embedded player and VideoObject schema — and the gallery card title links to it. The card still plays in place in the modal; the title link is a separate affordance, not a bypass. Untick to keep a video gallery-only: its title stops linking and its standalone URL redirects home (filterable via
xrv_watch_page_off_url/xrv_watch_page_off_status). - The old free-text "dedicated page URL" field is retired from the editor. Any URL already saved on a video is still honored as the title's link target, so nothing breaks.
2.5.0
Two new front-end capabilities plus a per-video editor pass.
- Embed any video anywhere.
[xroad-video url="..."]now renders the privacy-first click-to-load facade for any supported URL with no library entry needed, so an editor can drop a video into any post or page by pasting its link. Optionalposter(a local attachment id or URL) andtitleattributes; the poster is sideloaded locally on first render and cached, so the embed still makes zero third-party requests before the click. Curated library videos ([xroad-video id="123"]) keep their richer VideoObject schema. - TikTok support. TikTok joins YouTube, Vimeo, Wistia, Loom, and Dailymotion as an auto-detected source. Paste a
tiktok.com/@user/video/...link; it renders vertical (9:16) and loads on click as TikTok's native iframe player (noembed.js), poster fetched keyless via oEmbed. Works in galleries, single embeds, and the URL embed above.
A per-video editor pass focused on a calmer, more intuitive add/edit flow. Admin-facing; the front-end gains optional mobile posters and auto-generated image renditions.
- Single-field editor. The
xroad_videopost type no longer loads the full block editor body (videos are defined by their meta, not post content). The Add/Edit screen is now just a title field plus the Video Details box. - Opt-in dedicated-page link. The "Dedicated page URL" field is hidden behind a checkbox, so it only appears when you actually want a card to link out — a clearer default.
- Friendly duration entry. Type the running time as
minutes:secondsand the schema-accurate ISO 8601 value (PT12M30S) fills itself. A help accordion explains the format and links to a converter. - Native upload-date picker. Upload date is now a real date picker instead of a hand-typed
YYYY-MM-DDstring. - Poster images, expanded. The poster control is split into a clear Primary poster (16:9, grids/desktop) and an optional Mobile poster (a portrait/square crop shown ≤600px, served via
<picture>and still 100% local — no third-party calls). Recommended specs live in an inline help accordion. - Auto-generated renditions. Registered
xrv-poster(1280×720) andxrv-poster-mobile(720×960) image sizes, so WordPress generates desktop and mobile crops of every poster on upload.
2.4.0
A full admin polish pass. One shared design system now brands every screen, and a bold-carat Q&A accordion puts answers one click away. Admin-facing only; no front-end or behavior change.
- One design system across every screen. A single, scoped admin stylesheet (Crossroad palette, card system, buttons, badges, line-icons) is now shared by Settings, Import, and the per-video editor, so the whole plugin reads as one finished product. The Import screen, previously stock WordPress chrome, now matches Settings — branded header lockup, cards, and a 1-2-3 step indicator for the import workflow.
- Bold-carat Q&A accordions. Each screen gains a Questions & answers card: common admin questions (consent mode, API key, URL/SEO, thumbnails, Shorts, duplicates) expand inline with a bold rotating carat. The per-video editor's Rich video schema disclosure and contextual help use the same pattern.
- Premium page headers. Every screen opens with a logo-locked hero (eyebrow, title, one-line summary, version chip) instead of a bare
<h1>. - Sharper navigation. The Settings pill nav gains section icons and a scroll-spy active state that tracks the section in view.
- Tidied structure. The on-demand Run a sync now control is now its own card (it previously floated outside the card grid), and section headers carry consistent icon chips.
- Single source of version truth. A new
XRV_VERSIONconstant feeds the script handles and update check.
2.3.0
Faster first paint and a live edge-region check. The front-end gains an LCP optimization; the new diagnostic is admin-only.
- Faster first paint. The first card in a grid now loads its poster with
loading="eager"andfetchpriority="high"(the LCP image); every other card stays lazy. No markup or API change for callers. - Edge check (Settings, Privacy & consent). A new admin-only diagnostic runs a live region check against the REST endpoint, times the round trip with a count-up, names the detected country, and contrasts XRV (zero extra calls, zero cookies, cache intact) with a typical geo-IP plugin. It shows a one-toggle Cloudflare hint when no region header is present. The front-end gallery is unchanged.
2.2.2
Improved UI aesthetics. Admin-facing only; no front-end or behavior change.
- Settings cards de-cluttered. Removed the decorative
{brace accent that ran down the left edge of every Settings card and tightened the reclaimed left gutter so card content now aligns symmetrically with the right edge.
2.2.0
Admin UX cleanup from a full review. No front-end behavior change; admin code got smaller, not bigger.
- Settings consolidated. The separate Default poster, Play button, and Shorts cards are now one Appearance card (one nav pill instead of three).
- Consent copy de-scared. "No Consent Integration" is now "Facade only (no prompt)," and the card leads with "all three modes are cookie-free until the click" so the safe option no longer reads as unsafe. The geo-source diagnostic is hidden unless the Global mode is selected (it is irrelevant otherwise).
- Block trimmed. The block Inspector no longer duplicates site policy/look — filter style, card text, consent mode, and privacy URL now simply inherit the site defaults; the block keeps the genuinely per-instance controls (layout, columns, playback, heading, shorts, per-page, load-more, subscribe, pre-filter terms).
- Shortcode footgun fixed. Using the singular
[xroad-video]without a valididnow shows editors a visible hint pointing to the correct usage (and to the plural[xroad-videos]for galleries), instead of failing silently. - Dead code removed. Deleted the unreachable
lightconsent mode and the vestigial write-oncelockedflag. - URL safety upgraded. Settings → Video URLs now shows how many published videos use the current base, warns prominently when any are live, and rewrite rules auto-flush on version change so a changed base resolves without a manual re-save. Menu label is now "XRV Video" for clarity.
2.1.2
Rebrands the product to XRV (Xroad Video) and makes the URL base editable. Admin-facing only; no breaking changes.
- Rebrand to XRV. The admin menu, Settings title, plugin name, and block are now XRV (the maker, Crossroad Media, stays as author/logo). Internal identifiers are deliberately unchanged — the post type (
xroad_video), shortcodes ([xroad-videos],[xroad-video]), block name (xroad/videos), option keys, and text domain stay the same, so existing content, embeds, and URLs keep working. - Default URL base is now
/video/(was/xroad-video/) for a cleaner single-video path. Fresh installs pick this up automatically. - The video URL base is now editable in Settings → Video URLs, rather than write-once. Each change is gated behind a confirmation box, an inline warning, and a two-part test: before changing a base that is already live, check (1) organic/search traffic to that path and (2) inbound or internal links to it. If either exists, it is a showstopper — don't change the base without a full 301-redirect migration first. (Also avoid a base that collides with an existing page slug.)
2.1.1
Finishes the Shorts story end-to-end, refines the admin UI, and bounds bulk imports. No new dependencies; the only front-end weight added is the swipe-shelf CSS.
-
Import is capped to 50 videos per run. A pasted playlist or channel of thousands no longer fires thousands of metadata + Shorts-probe + thumbnail requests at once — the preview reports the overflow ("N more found — capped to 50 per import") and the run step enforces it server-side. Run the import again to add the rest. Self-hosted MP4/WebM stays one file at a time (one video per post).
-
Admin brand accent fixed. The card
{accent is now uniformly scaled and vertically centered (background-size:contain) instead of stretched to full card height — stretching a stroked brace distorted it into a thin wobbly line. -
Synced/imported Shorts are auto-flagged. The channel sync and the bulk importer now detect a Short for each new video (a one-time HEAD probe of the canonical
/shorts/URL — the Data API exposes no aspect ratio) and store the flag, so Shorts pulled in automatically render vertical with a portrait poster instead of needing a manual/shorts/paste or re-save. -
Swipe shelf for
shorts="only". A verticals-only gallery now lays out as a horizontal, thumb-swipeable scroll-snap strip (the familiar Shorts shelf) rather than a grid of tall cards. Pure CSS, scoped to that one layout. -
Block-editor Shorts control. The Crossroad Videos block gains a YouTube Shorts selector (Site default / Show alongside / Only / Hide) in its Browse panel, mirroring the shortcode
shortsattribute. Blank inherits the Settings default (the render now falls back to the site default rather than forcingall). -
DM Sans on the admin chrome. The Settings UI now prefers DM Sans where it's present on the system, falling back to the native UI sans stack. Deliberately not bundled: Google Sans (the brand display face) is proprietary and not licensed for self-hosting, and shipping font binaries would contradict the plugin's zero-dependency, no-third-party-request posture. The palette and logo already carry the brand; this adds the typeface for anyone who has it at zero cost.
2.1.0
Closes the gaps raised in a video-infrastructure review. All four additions reuse existing rendering; no new dependencies, front-end payload effectively unchanged.
- YouTube Shorts. Shorts are auto-detected from their
/shorts/URL and treated as the vertical (9:16) videos they are: a portrait poster (sideloaded from YouTube's original-aspectoardefaultimage, not a letterboxed 16:9 frame), a 9:16 facade, and a portrait lightbox that pops on click and loops (the Shorts feel). Ininlineplayback they play in their vertical card instead. Ashortsattribute —all(default),only(a verticals-only shelf), orhide— plus a default in the new Settings → Shorts tab controls how each gallery treats them. This directly resolves the common WordPress Shorts headaches (black-bar aspect ratio, theme/oEmbed wrapper conflicts, letterboxed thumbnails, manual/shorts/→/embed/conversion, pre-consent cookies, heavy on-load iframes) — the facade architecture pre-empts most by design. (Channel-imported shorts normalize to a watch URL, so flag those by adding via the/shorts/URL or a re-save.) - Selectable video URL structure (write-once). Settings → Video URLs lets you choose the single-post base (e.g.
/video/{slug}) and an optional collection/archive base (e.g./videos/) — any combination. It's a deliberate, one-time choice: once locked it can't be changed (live URLs and SEO depend on it), and locking flushes rewrite rules once. Defaults preserve the historical/xroad-video/base, so existing installs are unchanged until an admin sets a structure. - Single-video embed. New
[xroad-video id="123"]shortcode drops one video inline anywhere — same privacy-first click-to-load facade and VideoObject schema as its dedicated page, for editorial placements like an About page that sit a specific video in the page flow (replacing rawyoutube-nocookieiframes that otherwise contact Google on page load). Reusesxrv_render_single(), so it works for every 2.0 source. Embed by post ID; URL-paste resolution is a documented upgrade path. Addplayback="lightbox"to pop the video in a centered modal (matching a Divi/Magnific video-popup) instead of playing in place. - Full description on dedicated pages. A video's own page now shows its complete description (the card line-clamp is lifted only in single-video context), so there's a place to read the full text the grid preview truncates.
- Incomplete videos stay out of galleries. A video with no resolved platform ID can't play, so it is no longer rendered as a dead, unclickable card — it is skipped until a working URL is added. Fixes the "some videos show with no thumbnail and aren't clickable" report at the source.
- Settings UI redesign. The flat run of option rows is now a set of clearly-headed cards with a sticky section nav (Privacy, Browse, API key, Default poster, Play button, Auto-sync), so groups are scannable and harder to miss. The long consent-mode explainer collapses into a disclosure so the default view stays compact. The video editor's Video Details box now tucks the optional transcript/chapters ("Rich video schema") behind a disclosure too, so the core fields lead. Pure CSS / native
<details>— no admin JS added. (The Import screen was left as-is: it's a focused task flow, not a list.) The admin chrome is themed to the Crossroad Media brand palette (deep purple lead, brand-blue support, warm-gray neutrals, brand radii + signature shadow; orange left reserved) — applied to the plugin's own UI only; the front-end gallery still inherits each tenant's brand. No web fonts loaded (keeps the zero-dependency, no-third-party-request posture). Also de-duplicated the YouTube provider-meta write shared by the importer and channel sync into one helper. - Play-button color control. Settings → Play button color adds a brand color combo (idle + hover) for the click-to-load play icon, via two native color pickers. Tick to override; left off, the button inherits the theme's
--xrv-primary/--xrv-actionbrand tokens. Emits two scoped CSS variables (--xrv-play,--xrv-play-hover) only when set, so it never affects the other elements that share the brand tokens. SECURITY.mdadded: disclosure contact, security posture (no secrets in the repo, scheme-guarded fetches, escaped output, gated writes), and the recommended YouTube API-key restriction for site operators.
2.0.1.beta
Beta release for testing the playlist picker before 2.0.1 stable. Adds a YouTube playlist picker to the importer. On the Import screen, enter a channel (URL, @handle, or name) and click List playlists to see that channel's public playlists with their video counts; pick one and it loads into the Videos box, ready to preview and import. Saves you finding and pasting an exact ?list= playlist URL. Needs the same free YouTube Data API key the importer already uses; admin-only, no front-end change.
2.0.0
Multi-source. The gallery is no longer YouTube-only: it now plays Vimeo, Wistia, Loom, Dailymotion, and self-hosted MP4 / WebM files through the same click-to-load facade, with the provider auto-detected from the pasted URL. Still a single-file, zero-dependency plugin, and the zero-third-party-request-until-click guarantee holds for every source (self-hosted files make no third-party contact at all). No new API keys: each host's title, poster, and (for all but YouTube) duration come from its no-key oEmbed endpoint.
- Five new providers via the existing
_xrv_providerseam. Vimeo, Wistia, Loom, Dailymotion, and self-hosted files join YouTube. Each routes through the same switch for ID parsing, embed URL, oEmbed endpoint, and poster source, so the renderer, schema, lightbox, consent modes, layouts, and search/sort are unchanged and behave identically across sources. Galleries can mix providers in one grid. - Auto-detect provider. The editor's Provider selector defaults to Auto-detect: paste a URL and the host is recognized from it, so editors never choose a provider by hand. The selector is still there to force a choice and to pick Self-hosted file.
- Self-hosted files (
provider:file). Paste a direct MP4 / WebM URL and the card plays it in a native<video>element on click. No iframe, no oEmbed, no external request at any point: the most private option, and a natural fit with the password-gate stack for client review reels. Upload a poster image in the editor, since self-hosted files have no remote thumbnail to fetch. - Vimeo privacy hash. Unlisted and domain-private Vimeo videos (
vimeo.com/{id}/{hash}) keep their hash, so the on-click embed is reconstructed correctly. Vimeo plays withdnt=1and no title / byline / portrait chrome. - No third-party thumbnail relay. Posters for the new hosts are sideloaded from each host's own oEmbed
thumbnail_urland stored locally like the YouTube poster, so there is no dependency on any external thumbnail service. - Duration normalization. oEmbed durations (returned in seconds) are normalized to ISO 8601 on save, so the existing clock display and shortest/longest sort work across every source with no other change.
- Scope. The bulk importer and automatic channel sync stay YouTube-only in this release; multi-source bulk import is planned for 2.5. The enterprise tier (Brightcove, Kaltura) is intentionally out of scope, since composite account/player identity needs a providers settings panel.
- Payload. Front-end inline assets are about 30.3 KB raw / 8.7 KB gzipped per page (the provider routing adds roughly 1.2 KB of JS over 1.0.9); admin and importer code is unchanged, and everything is still emitted once per request.
1.0.9
Adds automatic channel sync: the plugin can poll a YouTube channel or playlist for new uploads and add them to the library, either on a schedule you set or on demand. Still a single-file, zero-dependency plugin; the sync engine and its controls are admin-only and add nothing to the front-end payload (unchanged at about 29 KB raw / 8.3 KB gzipped per page).
- Automatic channel sync (Videos → Settings → Automatic channel sync). Point it at a channel URL (
/@handle,/channel/UC…,/user/…) or a?list=…playlist and it scans the most recent uploads, adding any video not already in the library. Dedup is by video ID (_xrv_video_id), so a run is always idempotent and safe to repeat. Each new video gets its title, duration, upload date, description, and a sideloaded thumbnail, exactly like the importer. Requires the (free) YouTube Data API key already used by Import. - **Schedu
This README is longer than the copy stored here. Read the rest on GitHub →
Releases
Newest 25 of 29 recorded releases. Each count is every asset in that release; expand a row for the breakdown.