diff --git a/composer.json b/composer.json index 286eb085b84..37778c66f10 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,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 || ^9.0", 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..06eeb648738 100644 --- a/resources/js/components/fieldtypes/VideoFieldtype.vue +++ b/resources/js/components/fieldtypes/VideoFieldtype.vue @@ -1,28 +1,37 @@ @@ -34,70 +43,80 @@ 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.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/'); + watch: { + provider(newProvider, oldProvider) { + if (newProvider != oldProvider) { + this.embed = null; + this.url = null; } + } + }, - 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.embed = data.embed; + 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; - }, + 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/routes/cp.php b/routes/cp.php index 4c08207c7d3..9caa36fb1fe 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -61,6 +61,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; @@ -383,6 +384,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::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..922c60c4ecc --- /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..22dd0cc5aa2 --- /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..262edc8837b --- /dev/null +++ b/src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php @@ -0,0 +1,23 @@ +query('id'))) { + return Video::fromUrl($id); + } + + if (! is_null($url = $request->query('url'))) { + return Video::fromUrl($url); + } + + return Video::notSupported(); + } +} diff --git a/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php b/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php new file mode 100644 index 00000000000..6d661d31f5a --- /dev/null +++ b/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php @@ -0,0 +1,36 @@ +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']], + [['id' => 'cloudflare:1234'], ['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..80cb83693d9 --- /dev/null +++ b/tests/Fieldtypes/Video/VideoTest.php @@ -0,0 +1,29 @@ +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'], + ]; + } +} diff --git a/tests/Fieldtypes/VideoTest.php b/tests/Fieldtypes/VideoTest.php new file mode 100644 index 00000000000..4bf62e4a891 --- /dev/null +++ b/tests/Fieldtypes/VideoTest.php @@ -0,0 +1,93 @@ +setField(new Field('test', ['type' => 'video'])); + + $meta = $fieldtype->preload(); + + $this->assertSame('Cloudflare', $meta['providers'][0]['provider']); + $this->assertFalse(isset($meta['provider'])); + } + + #[Test] + #[DataProvider('preloadValuesProvider')] + public function it_preloads_with_value($provider, $value) + { + $fieldtype = tap(new Video, fn (Video $v) => $v + ->setField(new Field('test', ['type' => 'video'])) + ->field()->setValue($value) + ); + + $this->assertSame($provider, $fieldtype->preload()['provider']); + } + + public static function preloadValuesProvider() + { + return [ + ['Youtube', 'https://www.youtube.com/watch?v=FK3dav4bA4s'], + ['Cloudflare', 'cloudflare:1234'], + ]; + } + + #[Test] + #[DataProvider('processValuesProvider')] + public function it_processes_values($mode, $values) + { + $field = (new Text)->setField(new Field('test', [ + 'type' => 'text', + 'input_type' => $mode, + ])); + + $this->assertSame($values[0], $field->process('test')); + $this->assertSame($values[1], $field->process('3')); + $this->assertSame($values[2], $field->process('3test')); + $this->assertSame($values[3], $field->process('3.14')); + $this->assertSame($values[4], $field->process(null)); + } + + public static function processValuesProvider() + { + return [ + 'text' => ['text', ['test', '3', '3test', '3.14', null]], + 'number' => ['number', [0, 3, 3, 3.14, null]], + ]; + } + + #[Test] + #[DataProvider('preProcessIndexProvider')] + public function it_pre_processes_index_values($config, $value, $expected) + { + $field = (new Text)->setField(new Field('test', array_merge([ + 'type' => 'text', + ], $config))); + + $this->assertSame($expected, $field->preProcessIndex($value)); + } + + public static function preProcessIndexProvider() + { + return [ + 'string value' => [[], 'hello', 'hello'], + 'null value' => [[], null, null], + 'zero integer' => [[], 0, '0'], + 'zero string' => [[], '0', '0'], + 'zero with prepend' => [['prepend' => '$'], 0, '$0'], + 'zero with append' => [['append' => '%'], 0, '0%'], + 'zero with prepend and append' => [['prepend' => '$', 'append' => '%'], 0, '$0%'], + 'string with prepend' => [['prepend' => '$'], 'hello', '$hello'], + ]; + } +}