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 @@ @@ -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('