diff --git a/composer.json b/composer.json
index 31009f2ce77..23196f99ecf 100644
--- a/composer.json
+++ b/composer.json
@@ -24,6 +24,7 @@
"league/glide": "^3.0 || ^4.0",
"maennchen/zipstream-php": "^3.1",
"michelf/php-smartypants": "^1.8.1",
+ "mpratt/embera": "~2.0",
"nesbot/carbon": "^3.0",
"pragmarx/google2fa": "^8.0 || ^9.0",
"rebing/graphql-laravel": "^9.15",
diff --git a/resources/css/components/fieldtypes/video.css b/resources/css/components/fieldtypes/video.css
new file mode 100644
index 00000000000..639147f7f00
--- /dev/null
+++ b/resources/css/components/fieldtypes/video.css
@@ -0,0 +1,18 @@
+.embera-embed-responsive {
+ position: relative;
+ display: block;
+ width: 100%;
+ padding: 0;
+ overflow: hidden;
+ padding-bottom: 50%;
+}
+
+.embera-embed-responsive-item {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+}
diff --git a/resources/css/cp.css b/resources/css/cp.css
index dd8ab0f1172..a3686b28127 100644
--- a/resources/css/cp.css
+++ b/resources/css/cp.css
@@ -37,4 +37,5 @@
@import './components/fieldtypes/relationship.css';
@import './components/fieldtypes/section.css';
@import './components/fieldtypes/table.css';
+@import './components/fieldtypes/video.css';
@import './components/fieldtypes/width.css';
diff --git a/resources/js/components/fieldtypes/VideoFieldtype.vue b/resources/js/components/fieldtypes/VideoFieldtype.vue
index 848a08d4fbf..51a857e617e 100644
--- a/resources/js/components/fieldtypes/VideoFieldtype.vue
+++ b/resources/js/components/fieldtypes/VideoFieldtype.vue
@@ -1,28 +1,40 @@
-
-
-
-
-
{{ __('statamic::validation.url') }}
-
+
+
+
+
+
+
+
@@ -34,70 +46,81 @@ export default {
data() {
return {
+ embed: this.meta.embed,
isVisible: false,
observer: null,
+ provider: this.meta.provider,
+ savedValue: null,
+ url: null,
+ videoId: null,
};
},
computed: {
shouldShowPreview() {
- return !this.isInvalid && (this.isEmbeddable || this.isVideo);
+ return this.isVisible && !!this.embed;
},
- embedUrl() {
- let embed_url = this.value || '';
+ providers() {
+ return this.meta.providers;
+ }
+ },
- if (embed_url.includes('youtube')) {
- embed_url = embed_url.includes('shorts/')
- ? embed_url.replace('shorts/', 'embed/')
- : embed_url.replace('watch?v=', 'embed/');
- }
+ methods: {
+ changeProvider(provider) {
+ this.provider = provider;
+ this.embed = null;
+ this.url = null;
+ },
- if (embed_url.includes('youtu.be')) {
- embed_url = embed_url.replace('youtu.be', 'www.youtube.com/embed');
- }
+ 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();
+ },
- 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();
},
- 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() {
+ this.$axios
+ .get(this.meta.url, { params: { url: this.savedValue } })
+ .then((response) => response.data)
+ .then((data) => {
+ this.embed = data.embed;
+ this.provider = data.provider;
+ })
+ .catch((e) => {
+ this.embed = null;
+ this.$toast.error(e.response ? e.response.data.message : __('Something went wrong'));
+ });
- 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;
- },
+ setUrlOrId() {
+ if (this.value?.startsWith('cloudflare:')) {
+ this.videoId = this.value.replace('cloudflare:','');
+ return;
+ }
+
+ this.url = this.value;
+ }
},
mounted() {
+ this.setUrlOrId();
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
diff --git a/resources/js/tests/components/fieldtypes/VideoFieldtype.test.js b/resources/js/tests/components/fieldtypes/VideoFieldtype.test.js
new file mode 100644
index 00000000000..7b7b9108528
--- /dev/null
+++ b/resources/js/tests/components/fieldtypes/VideoFieldtype.test.js
@@ -0,0 +1,126 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import VideoFieldtype from '@/components/fieldtypes/VideoFieldtype.vue';
+import { publishContextKey } from '@/components/ui';
+
+window.__ = (key) => key;
+
+let intersect;
+let axios;
+let toast;
+
+beforeEach(() => {
+ window.IntersectionObserver = class {
+ constructor(callback) {
+ intersect = () => callback([{ isIntersecting: true, intersectionRatio: 1 }]);
+ }
+ observe() {}
+ disconnect() {}
+ };
+
+ axios = { get: vi.fn() };
+ toast = { error: vi.fn() };
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+const stub = (tag) => ({
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template: `<${tag} :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
+});
+
+const mountVideoField = (props = {}) => {
+ return mount(VideoFieldtype, {
+ props: {
+ handle: 'video',
+ config: {},
+ meta: {
+ url: '/cp/video/details',
+ providers: [{ provider: 'Cloudflare' }, { provider: 'Youtube' }],
+ },
+ ...props,
+ },
+ global: {
+ provide: { [publishContextKey]: {} },
+ mocks: { $axios: axios, $toast: toast },
+ stubs: {
+ 'ui-combobox': stub('select'),
+ 'ui-input': stub('input'),
+ },
+ },
+ });
+};
+
+test('cloudflare id is sent with the cloudflare prefix', async () => {
+ axios.get.mockResolvedValue({ data: { embed: '', provider: 'Cloudflare' } });
+
+ const wrapper = mountVideoField({ meta: { url: '/cp/video/details', providers: [], provider: 'Cloudflare' } });
+
+ await wrapper.find('input').setValue('1234');
+ await flushPromises();
+
+ expect(axios.get).toHaveBeenCalledWith('/cp/video/details', { params: { url: 'cloudflare:1234' } });
+ expect(wrapper.emitted('update:value')[0]).toEqual(['cloudflare:1234']);
+});
+
+test('embed survives the provider being corrected by the lookup', async () => {
+ axios.get.mockResolvedValue({ data: { embed: '', provider: 'Youtube' } });
+
+ const wrapper = mountVideoField();
+ intersect();
+
+ await wrapper.find('input').setValue('https://www.youtube.com/watch?v=FK3dav4bA4s');
+ await flushPromises();
+
+ expect(wrapper.vm.provider).toBe('Youtube');
+ expect(wrapper.find('input').element.value).toBe('https://www.youtube.com/watch?v=FK3dav4bA4s');
+ expect(wrapper.find('iframe').exists()).toBe(true);
+});
+
+test('changing the provider clears the embed and url', async () => {
+ const wrapper = mountVideoField({
+ value: 'https://www.youtube.com/watch?v=FK3dav4bA4s',
+ meta: { url: '/cp/video/details', providers: [], provider: 'Youtube', embed: '' },
+ });
+ intersect();
+ await flushPromises();
+ expect(wrapper.find('iframe').exists()).toBe(true);
+
+ await wrapper.find('select').setValue('Cloudflare');
+
+ expect(wrapper.find('iframe').exists()).toBe(false);
+ expect(wrapper.vm.url).toBeNull();
+});
+
+test('embed is not rendered until the field is visible', async () => {
+ const wrapper = mountVideoField({
+ value: 'cloudflare:1234',
+ meta: { url: '/cp/video/details', providers: [], provider: 'Cloudflare', embed: '' },
+ });
+
+ expect(wrapper.find('iframe').exists()).toBe(false);
+
+ intersect();
+ await flushPromises();
+
+ expect(wrapper.find('iframe').exists()).toBe(true);
+});
+
+test('failed lookup clears the embed and shows an error', async () => {
+ axios.get.mockRejectedValue({ response: { data: { message: 'Nope' } } });
+
+ const wrapper = mountVideoField({
+ value: 'cloudflare:1234',
+ meta: { url: '/cp/video/details', providers: [], provider: 'Cloudflare', embed: '' },
+ });
+ intersect();
+
+ await wrapper.find('input').setValue('5678');
+ await flushPromises();
+
+ expect(wrapper.find('iframe').exists()).toBe(false);
+ expect(toast.error).toHaveBeenCalledWith('Nope');
+});
diff --git a/routes/cp.php b/routes/cp.php
index c0e022faaba..1041f8af3e8 100644
--- a/routes/cp.php
+++ b/routes/cp.php
@@ -63,6 +63,7 @@
use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\RelationshipFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\ReplicatorSetController;
+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;
@@ -388,6 +389,7 @@
Route::post('markdown', [MarkdownFieldtypeController::class, 'preview'])->name('markdown.preview');
Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload');
Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype');
+ Route::get('video/details', [VideoFieldtypeController::class, 'details'])->name('video.details');
Route::post('replicator/set', ReplicatorSetController::class)->name('replicator-fieldtype.set');
});
diff --git a/src/Fieldtypes/Video.php b/src/Fieldtypes/Video.php
index 5e12dbf48bf..250b6fab18f 100644
--- a/src/Fieldtypes/Video.php
+++ b/src/Fieldtypes/Video.php
@@ -3,6 +3,8 @@
namespace Statamic\Fieldtypes;
use Statamic\Fields\Fieldtype;
+use Statamic\Fieldtypes\Video\Providers;
+use Statamic\Fieldtypes\Video\Video as VideoDetails;
use function Statamic\trans as __;
@@ -10,6 +12,38 @@ 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()
+ {
+ $meta = [
+ 'providers' => Providers::get(),
+ 'url' => cp_route('video.details'),
+ ];
+
+ if (! is_null($url = $this->field()->value())) {
+ $video = VideoDetails::fromUrl($url);
+
+ /** @todo Fetch these from some repository so folks can add their own */
+ $meta['embed'] = $video->embed;
+ $meta['provider'] = $video->provider;
+ }
+
+ return $meta;
+ }
+
protected function configFieldItems(): array
{
return [
diff --git a/src/Fieldtypes/Video/Providers.php b/src/Fieldtypes/Video/Providers.php
new file mode 100644
index 00000000000..7e2626b9fd7
--- /dev/null
+++ b/src/Fieldtypes/Video/Providers.php
@@ -0,0 +1,21 @@
+providers)
+ ->unique()
+ ->values()
+ ->map(fn (string $class) => ['provider' => class_basename($class)])
+ ->add(['provider' => 'Cloudflare'])
+ ->sortBy('provider')
+ ->add(['provider' => 'Not Supported'])
+ ->values()
+ ->all();
+ }
+}
diff --git a/src/Fieldtypes/Video/Video.php b/src/Fieldtypes/Video/Video.php
new file mode 100644
index 00000000000..6148be514b3
--- /dev/null
+++ b/src/Fieldtypes/Video/Video.php
@@ -0,0 +1,56 @@
+";
+
+ return new self(id: $id, provider: 'Cloudflare', embed: $iframe);
+ }
+
+ 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,
+ embed: $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 $embed = null,
+ ) {
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'embed' => $this->embed,
+ 'id' => $this->id,
+ 'provider' => $this->provider,
+ ];
+ }
+}
diff --git a/src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php b/src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php
new file mode 100644
index 00000000000..1247cf6e34c
--- /dev/null
+++ b/src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php
@@ -0,0 +1,19 @@
+query('url'))) {
+ return Video::notSupported();
+ }
+
+ return Video::fromUrl($url);
+ }
+}
diff --git a/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php b/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php
new file mode 100644
index 00000000000..aff2f231adf
--- /dev/null
+++ b/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php
@@ -0,0 +1,40 @@
+makeSuper())->save();
+
+ $this
+ ->actingAs($user)
+ ->get(cp_route('video.details', $queryParams))
+ ->assertOK()
+ ->assertJson($video, $strict = true);
+ }
+
+ public static function valuesProvider()
+ {
+ return [
+ [[], ['embed' => null, 'id' => null, 'provider' => 'Not Supported']],
+ [['url' => 'https://www.youtube.com/watch?v=FK3dav4bA4s'], ['id' => null, 'provider' => 'Youtube']],
+ [['url' => 'cloudflare:1234'], [
+ 'embed' => '',
+ 'id' => '1234',
+ 'provider' => 'Cloudflare',
+ ]],
+ ];
+ }
+}
diff --git a/tests/Fieldtypes/Video/ProvidersTest.php b/tests/Fieldtypes/Video/ProvidersTest.php
new file mode 100644
index 00000000000..fefd20709de
--- /dev/null
+++ b/tests/Fieldtypes/Video/ProvidersTest.php
@@ -0,0 +1,18 @@
+assertSame('Cloudflare', $providers[0]['provider']);
+ }
+}
diff --git a/tests/Fieldtypes/Video/VideoTest.php b/tests/Fieldtypes/Video/VideoTest.php
new file mode 100644
index 00000000000..b392c5514cb
--- /dev/null
+++ b/tests/Fieldtypes/Video/VideoTest.php
@@ -0,0 +1,41 @@
+assertSame($provider, $video->provider);
+ $this->assertSame($id, $video->id);
+ }
+
+ public static function valuesProvider()
+ {
+ return [
+ ['Youtube', null, 'https://www.youtube.com/watch?v=FK3dav4bA4s'],
+ ['Cloudflare', '1234', 'cloudflare:1234'],
+ ];
+ }
+
+ #[Test]
+ public function it_escapes_the_cloudflare_id_in_the_embed()
+ {
+ $video = Video::fromUrl('cloudflare:1234">');
+
+ $this->assertStringNotContainsString('