Skip to content

[6.x] Support more video providers, including Cloudflare Stream #11871

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d6edd27
Merge branch 'ui' into feature/add-cf-to-video-fieldtype
edalzell Jun 3, 2025
095fe97
Merge branch 'ui' into feature/add-cf-to-video-fieldtype
edalzell Jun 13, 2025
f35b6a1
video providers
edalzell Jun 13, 2025
e373489
move the provider/embed logic to the fieldtype
edalzell Jun 14, 2025
caa2ded
move the provider/embed logic to the fieldtype
edalzell Jun 14, 2025
63ed860
wip
edalzell Jun 16, 2025
8548d67
don’t know how to identify a CF stream id
edalzell Jun 16, 2025
738f475
move it inside the input group
edalzell Jun 16, 2025
f1c2c6b
Merge branch 'master' into feature/add-cf-to-video-fieldtype
edalzell Jul 4, 2025
0e357ef
wip
edalzell Jun 18, 2025
74f8f9d
wip
edalzell Jul 5, 2025
0f8fd0b
Merge branch 'master' into feature/add-cf-to-video-fieldtype
edalzell Jul 9, 2025
37d64b2
some sugar
edalzell Jul 9, 2025
51c3fa8
wip
edalzell Jul 9, 2025
8a831e7
Discard changes to src/Fields/Fieldtype.php
edalzell Jul 9, 2025
2f3c41b
Discard changes to resources/js/components/fieldtypes/Fieldtype.vue
edalzell Jul 9, 2025
76e7e45
actually set the id
edalzell Jul 9, 2025
a769861
don’t debounce, for now
edalzell Jul 9, 2025
e84848c
tidy
edalzell Jul 9, 2025
a9aa392
fix providers
edalzell Jul 9, 2025
78bbdf5
this gets rid of width/height
edalzell Jul 9, 2025
87141a1
fix CF sizing
edalzell Jul 9, 2025
e884b3a
Not supported
edalzell Jul 9, 2025
994e93b
Merge branch 'master' into pr/11871
duncanmcclean Jul 10, 2025
3924b52
Merge branch 'master' into pr/11871
duncanmcclean Jul 10, 2025
41056dc
null be null
edalzell Jul 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"league/glide": "^3.0",
"maennchen/zipstream-php": "^3.1",
"michelf/php-smartypants": "^1.8.1",
"mpratt/embera": "~2.0",
"nesbot/carbon": "^3.0",
"pixelfear/composer-dist-plugin": "^0.1.4",
"pragmarx/google2fa": "^8.0",
Expand Down
130 changes: 67 additions & 63 deletions resources/js/components/fieldtypes/VideoFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,89 +1,93 @@
<template>
<div class="flex flex-col space-y-3 p-1.5 bg-gray-100 border border-gray-300 dark:bg-gray-900 dark:border-gray-700 rounded-xl">
<ui-input-group>
<ui-input-group-prepend :text="__('URL')" />
<ui-input
:model-value="value"
:isReadOnly="isReadOnly"
:placeholder="__(config.placeholder) || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'"
@update:model-value="update"
@focus="$emit('focus')"
@blur="$emit('blur')"
/>
</ui-input-group>
<ui-description v-if="isInvalid" class="text-red-500">{{ __('statamic::validation.url') }}</ui-description>
<iframe
v-if="shouldShowPreview"
:src="embedUrl"
frameborder="0"
allow="fullscreen"
<div class="flex flex-col space-y-3 rounded-xl border border-gray-300 bg-gray-100 p-1.5 dark:border-gray-700 dark:bg-gray-900">
<Combobox
v-model="provider"
:options="providers"
option-label="provider"
option-value="provider"
:placeholder="__('Provider...')"
/>
<Input
v-if="provider != 'Cloudflare'"
:model-value="url"
:isReadOnly="isReadOnly"
:placeholder="__(config.placeholder) || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'"
:prepend="__('URL')"
@update:model-value="detailsFromUrl"
/>
<Input
v-else
:model-value="videoId"
:isReadOnly="isReadOnly"
:prepend="__('ID')"
@update:model-value="detailsFromCloudflare"
/>
<div
v-if="embedUrl"
class="aspect-video rounded-lg"
></iframe>
v-html="embedUrl"
></div>
</div>
</template>

<script>
import Fieldtype from './Fieldtype.vue';
import { Combobox, Input } from '@statamic/ui';

export default {
components: { Combobox, Input },

mixins: [Fieldtype],

data() {
return {
embedUrl: null,
provider: null,
savedValue: null,
url: null,
videoId: null,
};
},

computed: {
shouldShowPreview() {
return !this.isInvalid && (this.isEmbeddable || this.isVideo);
providers() {
return this.meta.providers;
},
},

embedUrl() {
let embed_url = this.value || '';

if (embed_url.includes('youtube')) {
embed_url = embed_url.includes('shorts/')
? embed_url.replace('shorts/', 'embed/')
: embed_url.replace('watch?v=', 'embed/');
}

if (embed_url.includes('youtu.be')) {
embed_url = embed_url.replace('youtu.be', 'www.youtube.com/embed');
}
methods: {
detailsFromCloudflare(id) {
if (id == null) return;

if (embed_url.includes('vimeo')) {
embed_url = embed_url.replace('/vimeo.com', '/player.vimeo.com/video');
this.savedValue = `cloudflare::${id}`;
this.videoId = id;
this.url = null;

if (!this.value.includes('progressive_redirect') && embed_url.split('/').length > 5) {
let hash = embed_url.substr(embed_url.lastIndexOf('/') + 1);
embed_url = embed_url.substr(0, embed_url.lastIndexOf('/')) + '?h=' + hash.replace('?', '&');
}
}
this.getVideoData({type: this.provider, id: this.videoId});
},

if (embed_url.includes('&') && !embed_url.includes('?')) {
embed_url = embed_url.replace('&', '?');
}
detailsFromUrl(url) {
if (url == null) return;

return embed_url;
},
this.savedValue = url;
this.videoId = null;
this.url = url;

isEmbeddable() {
const url = this.value || '';
const isYoutube = url.includes('youtube') || url.includes('youtu.be');
const isVimeo = url.includes('vimeo');
return isYoutube || isVimeo;
this.getVideoData({url: url});
},

isInvalid() {
let htmlRegex = new RegExp(/<([A-Z][A-Z0-9]*)\b[^>]*>.*?<\/\1>|<([A-Z][A-Z0-9]*)\b[^\/]*\/>/i);
return htmlRegex.test(this.value || '');
},
getVideoData(params) {
this.$axios
.get(this.meta.url, { params: params })
.then((response) => response.data)
.then((data) => {
this.embedUrl = data.embed_url;
this.provider = data.provider;
});

isUrl() {
const url = this.value || '';
return url.startsWith('http://') || url.startsWith('https://');
this.update(this.savedValue);
},

isVideo() {
const url = this.value || '';
const isVideo = url.includes('.mp4') || url.includes('.ogv') || url.includes('.mov') || url.includes('.webm');
return !this.isEmbeddable && isVideo;
},
},
}
};
</script>
2 changes: 2 additions & 0 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
use Statamic\Http\Controllers\CP\Fieldtypes\IconFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\RelationshipFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\VideoFieldtypeController;
use Statamic\Http\Controllers\CP\Forms\ActionController as FormActionController;
use Statamic\Http\Controllers\CP\Forms\FormBlueprintController;
use Statamic\Http\Controllers\CP\Forms\FormExportController;
Expand Down Expand Up @@ -343,6 +344,7 @@
Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload');
Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype');
Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype');
Route::get('video/details', [VideoFieldtypeController::class, 'details'])->name('video.details');
});

Route::group(['prefix' => 'field-action-modal'], function () {
Expand Down
42 changes: 42 additions & 0 deletions src/Fieldtypes/Video.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,38 @@

namespace Statamic\Fieldtypes;

use Embera\ProviderCollection\SlimProviderCollection;
use Statamic\Fields\Fieldtype;

class Video extends Fieldtype
{
protected $categories = ['media'];

public function augment($value)
{
if (is_null($value)) {
return null;
}

if (str($value)->isUrl()) {
return $value;
}

//otherwise assume it's a Cloudflare ID
return str($value)->afterLast('::')->value();
}

public function preload()
{
$providers = new Providers();

/** @todo Fetch these from some repository so folks can add their own */
return [
'providers' => $providers->get(),
'url' => cp_route('video.details'),
];
}

protected function configFieldItems(): array
{
return [
Expand All @@ -29,3 +55,19 @@ protected function configFieldItems(): array
];
}
}

class Providers extends SlimProviderCollection
{
public function get(): array
{
return collect($this->providers)
->unique()
->values()
->map(fn (string $class) => ['provider' => class_basename($class)])
->add(['provider' => 'Cloudflare'])
->sortBy('provider')
->add(['provider' => 'Not Supported'])
->values()
->all();
}
}
74 changes: 74 additions & 0 deletions src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Statamic\Http\Controllers\CP\Fieldtypes;

use Embera\Embera;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Fluent;
use Statamic\Http\Controllers\CP\CpController;

class VideoFieldtypeController extends CpController
{
public function details(Request $request)
{
if (! is_null($url = $request->query('url'))) {
return Video::fromUrl($url);
}

if ($this->isCloudflareStream($request)) {
$id = $request->query('id');
$embedUrl = "https://iframe.cloudflarestream.com/{$id}";
$iframe = "<iframe src='$embedUrl' frameborder='0' allow='fullscreen' style='height: 100%; width: 100%;'></iframe>";

return new Video(id: $id, provider: 'Cloudflare', embedUrl: $iframe);
}

return Video::notSupported();
}

private function isCloudflareStream(Request $request): bool
{
return $request->has('id') && $request->query('type') === 'Cloudflare';
}
}

class Video implements Arrayable
{
public static function fromUrl(string $url): self
{
if (empty($details = (new Embera(['responsive' => true]))->getUrlData($url))) {
return static::notSupported();
}

$data = new Fluent(Arr::first($details));

return new self(
id: $data->video_id,
provider: $data->embera_provider_name,
embedUrl: $data->html
);
}

public static function notSupported(): self
{
return new self(provider: 'Not Supported');
}

public function __construct(
public string $provider,
public ?string $id = null,
public ?string $embedUrl = null,
) {
}

public function toArray(): array
{
return [
'embed_url' => $this->embedUrl,
'id' => $this->id,
'provider' => $this->provider,
];
}
}