diff --git a/composer.json b/composer.json index 31009f2ce77..12e46b57873 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..a20504f19b4 --- /dev/null +++ b/resources/css/components/fieldtypes/video.css @@ -0,0 +1,19 @@ +.video-fieldtype-embed { + position: relative; + display: block; + width: 100%; + padding: 0; + padding-bottom: 56.25%; + overflow: hidden; + border-radius: var(--radius-md); +} + +.video-fieldtype-embed iframe { + 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..26a33465972 100644 --- a/resources/js/components/fieldtypes/VideoFieldtype.vue +++ b/resources/js/components/fieldtypes/VideoFieldtype.vue @@ -1,99 +1,154 @@ 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..2534f3aa69e --- /dev/null +++ b/resources/js/tests/components/fieldtypes/VideoFieldtype.test.js @@ -0,0 +1,206 @@ +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(() => { + vi.useFakeTimers(); + + window.IntersectionObserver = class { + constructor(callback) { + intersect = () => callback([{ isIntersecting: true, intersectionRatio: 1 }]); + } + observe() {} + disconnect() {} + }; + + axios = { get: vi.fn().mockResolvedValue({ data: {} }) }; + toast = { error: vi.fn() }; +}); + +afterEach(() => { + vi.useRealTimers(); + 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: [ + { value: 'cloudflare', label: 'Cloudflare Stream' }, + { value: 'Youtube', label: 'Youtube' }, + ], + }, + ...props, + }, + global: { + provide: { [publishContextKey]: {} }, + mocks: { $axios: axios, $toast: toast }, + stubs: { + 'ui-combobox': stub('select'), + 'ui-input': stub('input'), + 'ui-description': { template: '

' }, + }, + }, + }); +}; + +// Typing debounces, so the update is only emitted once the timers run. +const type = async (wrapper, value) => { + await wrapper.find('input').setValue(value); + vi.runAllTimers(); + await flushPromises(); +}; + +test('it renders the embed from preloaded meta once visible', async () => { + const wrapper = mountVideoField({ + value: 'https://www.youtube.com/watch?v=1234', + meta: { + url: '/cp/video/details', + providers: [], + video: { provider: 'Youtube', embed_url: 'https://www.youtube.com/embed/1234' }, + }, + }); + + expect(wrapper.find('iframe').exists()).toBe(false); + + intersect(); + await flushPromises(); + + expect(wrapper.find('iframe').attributes('src')).toBe('https://www.youtube.com/embed/1234'); +}); + +test('it debounces lookups while typing', async () => { + const wrapper = mountVideoField({ value: null }); + + await wrapper.find('input').setValue('https://www.youtube.com/watch?v=1'); + await wrapper.find('input').setValue('https://www.youtube.com/watch?v=12'); + await wrapper.find('input').setValue('https://www.youtube.com/watch?v=123'); + + expect(wrapper.emitted('update:value')).toBeUndefined(); + + vi.runAllTimers(); + + expect(wrapper.emitted('update:value')).toHaveLength(1); + expect(wrapper.emitted('update:value')[0]).toEqual(['https://www.youtube.com/watch?v=123']); +}); + +test('it sends the cloudflare prefix with the stored value', async () => { + const wrapper = mountVideoField({ value: 'cloudflare:oldid' }); + + await type(wrapper, '1234'); + + expect(wrapper.emitted('update:value')[0]).toEqual(['cloudflare:1234']); +}); + +test('it splits the stored value into the url and id inputs', () => { + expect(mountVideoField({ value: 'https://vimeo.com/1' }).find('input').element.value).toBe('https://vimeo.com/1'); + expect(mountVideoField({ value: 'cloudflare:1234' }).find('input').element.value).toBe('1234'); +}); + +test('it re-syncs the input when the value changes externally', async () => { + const wrapper = mountVideoField({ value: 'https://vimeo.com/1' }); + + await wrapper.setProps({ value: 'https://vimeo.com/2' }); + + expect(wrapper.find('input').element.value).toBe('https://vimeo.com/2'); +}); + +test('it clears the stored value when the provider changes', async () => { + const wrapper = mountVideoField({ value: 'https://www.youtube.com/watch?v=1234' }); + + await wrapper.find('select').setValue('cloudflare'); + + expect(wrapper.emitted('update:value')).toHaveLength(1); + expect(wrapper.emitted('update:value')[0]).toEqual([null]); + expect(wrapper.find('iframe').exists()).toBe(false); +}); + +test('it looks up details when the value changes', async () => { + axios.get.mockResolvedValue({ + data: { provider: 'Youtube', embed_url: 'https://www.youtube.com/embed/1234' }, + }); + + const wrapper = mountVideoField({ value: null }); + intersect(); + + await wrapper.setProps({ value: 'https://www.youtube.com/watch?v=1234' }); + await flushPromises(); + + expect(axios.get).toHaveBeenCalledWith('/cp/video/details', expect.objectContaining({ + params: { value: 'https://www.youtube.com/watch?v=1234' }, + })); + expect(wrapper.find('iframe').attributes('src')).toBe('https://www.youtube.com/embed/1234'); +}); + +test('it discards a response that resolves after the value moved on', async () => { + let resolveStale; + axios.get + .mockImplementationOnce(() => new Promise((resolve) => (resolveStale = resolve))) + .mockResolvedValueOnce({ data: { provider: 'Vimeo', embed_url: 'https://player.vimeo.com/video/2' } }); + + const wrapper = mountVideoField({ value: null }); + intersect(); + + await wrapper.setProps({ value: 'https://www.youtube.com/watch?v=1' }); + await wrapper.setProps({ value: 'https://vimeo.com/2' }); + await flushPromises(); + + // The first lookup lands last, but belongs to a value that is no longer current. + resolveStale({ data: { provider: 'Youtube', embed_url: 'https://www.youtube.com/embed/1' } }); + await flushPromises(); + + expect(wrapper.vm.provider).toBe('Vimeo'); + expect(wrapper.find('iframe').attributes('src')).toBe('https://player.vimeo.com/video/2'); +}); + +test('it renders a video element for a direct file', async () => { + axios.get.mockResolvedValue({ + data: { provider: 'file', embed_url: 'https://example.com/clip.mp4' }, + }); + + const wrapper = mountVideoField({ value: null }); + intersect(); + + await wrapper.setProps({ value: 'https://example.com/clip.mp4' }); + await flushPromises(); + + expect(wrapper.find('video').attributes('src')).toBe('https://example.com/clip.mp4'); + expect(wrapper.find('iframe').exists()).toBe(false); +}); + +test('a failed lookup clears the embed and shows an error', async () => { + axios.get.mockRejectedValue({ response: { data: { message: 'Nope' } } }); + + const wrapper = mountVideoField({ + value: 'https://www.youtube.com/watch?v=1234', + meta: { + url: '/cp/video/details', + providers: [], + video: { provider: 'Youtube', embed_url: 'https://www.youtube.com/embed/1234' }, + }, + }); + intersect(); + + await wrapper.setProps({ value: 'https://www.youtube.com/watch?v=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..8a2c1d3396e 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,29 @@ class Video extends Fieldtype { protected $categories = ['media']; + public function augment($value) + { + if (is_null($value)) { + return null; + } + + return VideoDetails::fromValue($value); + } + + public function preload() + { + $meta = [ + 'providers' => Providers::options(), + 'url' => cp_route('video.details'), + ]; + + if (! is_null($value = $this->field()->value())) { + $meta['video'] = VideoDetails::fromValue($value)->toArray(); + } + + return $meta; + } + protected function configFieldItems(): array { return [ diff --git a/src/Fieldtypes/Video/HttpClient.php b/src/Fieldtypes/Video/HttpClient.php new file mode 100644 index 00000000000..32f44e70027 --- /dev/null +++ b/src/Fieldtypes/Video/HttpClient.php @@ -0,0 +1,40 @@ +config['user_agent'] ?? 'Statamic') + ->connectTimeout(self::CONNECT_TIMEOUT) + ->timeout(self::TIMEOUT) + ->withOptions(['allow_redirects' => ['max' => 3, 'strict' => true]]) + ->get($url); + + if ($response->failed()) { + throw new RuntimeException(sprintf('Request to %s returned status %s', $url, $response->status())); + } + + return $response->body(); + } + + public function setConfig(array $config = []) + { + $this->config = $config; + } +} diff --git a/src/Fieldtypes/Video/Providers.php b/src/Fieldtypes/Video/Providers.php new file mode 100644 index 00000000000..bb2bcf57909 --- /dev/null +++ b/src/Fieldtypes/Video/Providers.php @@ -0,0 +1,46 @@ +registerProvider(static::$oembed); + } + + public static function options(): array + { + return collect(static::$oembed) + ->map(fn (string $provider) => ['value' => $provider, 'label' => $provider]) + ->push(['value' => self::CLOUDFLARE, 'label' => __('Cloudflare Stream')]) + ->push(['value' => self::FILE, 'label' => __('Video File')]) + ->sortBy('label') + ->values() + ->all(); + } +} diff --git a/src/Fieldtypes/Video/Video.php b/src/Fieldtypes/Video/Video.php new file mode 100644 index 00000000000..58f70a597df --- /dev/null +++ b/src/Fieldtypes/Video/Video.php @@ -0,0 +1,179 @@ +provider !== Providers::UNSUPPORTED; + } + + public function toArray(): array + { + return [ + 'embed_url' => $this->embedUrl, + 'id' => $this->id, + 'provider' => $this->provider, + 'url' => $this->url, + ]; + } + + public function toBool(): bool + { + return $this->isSupported(); + } + + public function __toString(): string + { + return (string) $this->url; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } + + #[\ReturnTypeWillChange] + public function offsetExists(mixed $offset) + { + return array_key_exists($offset, $this->toArray()); + } + + #[\ReturnTypeWillChange] + public function offsetGet(mixed $offset) + { + return $this->toArray()[$offset] ?? null; + } + + #[\ReturnTypeWillChange] + public function offsetSet(mixed $offset, mixed $value) + { + } + + #[\ReturnTypeWillChange] + public function offsetUnset(mixed $offset) + { + } + + protected static function embedUrlFrom(array $response): ?string + { + if (blank($html = Arr::get($response, 'html'))) { + return null; + } + + if (! preg_match('/]+src=["\']([^"\']+)["\']/i', $html, $matches)) { + return null; + } + + $url = html_entity_decode($matches[1], ENT_QUOTES); + + if (! filter_var($url, FILTER_VALIDATE_URL)) { + return null; + } + + return match (parse_url($url, PHP_URL_SCHEME)) { + 'https' => $url, + 'http' => Str::replaceStart('http://', 'https://', $url), + default => null, + }; + } + + protected static function fromOembed(string $url): self + { + if ($video = static::lookup($url, Embera::ONLY_FAKE_RESPONSES)) { + return $video; + } + + return Cache::remember( + 'statamic::video-fieldtype.'.md5($url), + self::CACHE_TTL, + fn () => static::lookup($url, Embera::DISABLE_FAKE_RESPONSES) ?? static::unsupported($url), + ); + } + + protected static function isVideoFile(string $url): bool + { + if (blank($path = parse_url($url, PHP_URL_PATH))) { + return false; + } + + return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), FileTypes::video()); + } + + protected static function lookup(string $url, int $fakeResponses): ?self + { + try { + $embera = new Embera( + ['fake_responses' => $fakeResponses], + new Providers, + new HttpClient, + ); + + $response = $embera->getUrlData($url); + } catch (Throwable) { + return null; + } + + if (empty($response) || blank($embedUrl = static::embedUrlFrom($first = Arr::first($response)))) { + return null; + } + + return new self( + Arr::get($first, 'embera_provider_name', Providers::UNSUPPORTED), + $url, + $embedUrl, + ); + } +} diff --git a/src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php b/src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php new file mode 100644 index 00000000000..e2c0d44d0d8 --- /dev/null +++ b/src/Http/Controllers/CP/Fieldtypes/VideoFieldtypeController.php @@ -0,0 +1,15 @@ +query('value'))->toArray(); + } +} diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index 42e3d800704..fdacd62a171 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -28,6 +28,7 @@ use Statamic\Fieldtypes\Bard; use Statamic\Fieldtypes\Bard\Augmentor; use Statamic\Fieldtypes\Link\ArrayableLink; +use Statamic\Fieldtypes\Video\Video as VideoValue; use Statamic\Statamic; use Statamic\Support\Arr; use Statamic\Support\Dumper; @@ -3199,6 +3200,10 @@ public function yearsAgo($value, $params) */ public function embedUrl($url) { + if ($url instanceof VideoValue) { + return $url->embedUrl; + } + if (Str::contains($url, 'vimeo')) { $url = str_replace('/vimeo.com', '/player.vimeo.com/video', $url); @@ -3264,6 +3269,10 @@ public function embedUrl($url) */ public function trackableEmbedUrl($url) { + if ($url instanceof VideoValue) { + return $url->embedUrl; + } + if (Str::contains($url, 'vimeo')) { return str_replace('/vimeo.com', '/player.vimeo.com/video', $url); } @@ -3296,6 +3305,10 @@ public function trackableEmbedUrl($url) */ public function isEmbeddable($url) { + if ($url instanceof VideoValue) { + return $url->isSupported(); + } + return Str::contains($url, ['youtu.be', 'youtube', 'vimeo']); } diff --git a/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php b/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php new file mode 100644 index 00000000000..e293f6ffb57 --- /dev/null +++ b/tests/CP/Controllers/Fieldtypes/VideoFieldtypeControllerTest.php @@ -0,0 +1,54 @@ +makeSuper())->save(); + + $this + ->actingAs($user) + ->get(cp_route('video.details', $queryParams)) + ->assertOK() + ->assertExactJson($video); + } + + public static function valuesProvider() + { + return [ + 'no value' => [[], ['embed_url' => null, 'id' => null, 'provider' => 'unsupported', 'url' => null]], + 'youtube' => [['value' => 'https://www.youtube.com/watch?v=FK3dav4bA4s'], [ + 'embed_url' => 'https://www.youtube.com/embed/FK3dav4bA4s?feature=oembed', + 'id' => null, + 'provider' => 'Youtube', + 'url' => 'https://www.youtube.com/watch?v=FK3dav4bA4s', + ]], + 'cloudflare' => [['value' => 'cloudflare:1234'], [ + 'embed_url' => 'https://iframe.cloudflarestream.com/1234', + 'id' => '1234', + 'provider' => 'cloudflare', + 'url' => 'cloudflare:1234', + ]], + ]; + } + + #[Test] + public function it_requires_authentication() + { + $this + ->get(cp_route('video.details', ['value' => 'https://www.youtube.com/watch?v=FK3dav4bA4s'])) + ->assertRedirect(cp_route('login')); + } +} diff --git a/tests/Fieldtypes/Video/ProvidersTest.php b/tests/Fieldtypes/Video/ProvidersTest.php new file mode 100644 index 00000000000..3b1eebd5b0c --- /dev/null +++ b/tests/Fieldtypes/Video/ProvidersTest.php @@ -0,0 +1,36 @@ +assertContains(['value' => 'Youtube', 'label' => 'Youtube'], $options); + $this->assertContains(['value' => 'cloudflare', 'label' => 'Cloudflare Stream'], $options); + $this->assertContains(['value' => 'file', 'label' => 'Video File'], $options); + } + + #[Test] + public function it_does_not_offer_unsupported_as_an_option() + { + $this->assertNotContains(Providers::UNSUPPORTED, collect(Providers::options())->pluck('value')->all()); + } + + #[Test] + public function it_only_registers_video_providers() + { + $providers = collect(Providers::options())->pluck('value'); + + $this->assertContains('Vimeo', $providers); + $this->assertNotContains('Figma', $providers); + $this->assertNotContains('Scribd', $providers); + } +} diff --git a/tests/Fieldtypes/Video/VideoTest.php b/tests/Fieldtypes/Video/VideoTest.php new file mode 100644 index 00000000000..5a1ae74c31d --- /dev/null +++ b/tests/Fieldtypes/Video/VideoTest.php @@ -0,0 +1,124 @@ +assertSame($provider, $video->provider); + $this->assertSame($id, $video->id); + $this->assertSame($embedUrl, $video->embedUrl); + } + + public static function valuesProvider() + { + return [ + 'youtube' => ['https://www.youtube.com/watch?v=FK3dav4bA4s', 'Youtube', null, 'https://www.youtube.com/embed/FK3dav4bA4s?feature=oembed'], + 'youtube shorts' => ['https://www.youtube.com/shorts/FK3dav4bA4s', 'Youtube', null, 'https://www.youtube.com/embed/FK3dav4bA4s?feature=oembed'], + 'vimeo' => ['https://vimeo.com/22439234', 'Vimeo', null, 'https://player.vimeo.com/video/22439234'], + 'cloudflare' => ['cloudflare:1234', 'cloudflare', '1234', 'https://iframe.cloudflarestream.com/1234'], + 'cloudflare without an id' => ['cloudflare:', 'unsupported', null, null], + 'cloudflare with a malformed id' => ['cloudflare:1234">', 'unsupported', null, null], + 'cloudflare with a path traversal id' => ['cloudflare:../../evil', 'unsupported', null, null], + 'mp4 file' => ['https://example.com/clip.mp4', 'file', null, 'https://example.com/clip.mp4'], + 'uppercase file extension' => ['https://example.com/clip.MOV', 'file', null, 'https://example.com/clip.MOV'], + 'file with a query string' => ['https://example.com/clip.webm?t=1', 'file', null, 'https://example.com/clip.webm?t=1'], + 'unsupported' => ['https://example.com/nope', 'unsupported', null, null], + 'empty' => ['', 'unsupported', null, null], + 'null' => [null, 'unsupported', null, null], + ]; + } + + #[Test] + public function it_does_not_make_http_requests_for_offline_providers() + { + Http::preventStrayRequests(); + + $this->assertSame('Youtube', Video::fromValue('https://www.youtube.com/watch?v=FK3dav4bA4s')->provider); + $this->assertSame('Vimeo', Video::fromValue('https://vimeo.com/22439234')->provider); + } + + #[Test] + public function it_returns_an_embed_url_rather_than_provider_supplied_markup() + { + Http::fake(['*' => Http::response([ + 'html' => '', + ])]); + + $video = Video::fromValue('https://wistia.com/medias/abc'); + + $this->assertSame('https://fast.wistia.net/embed/iframe/abc', $video->embedUrl); + $this->assertStringNotContainsString('onerror', $video->embedUrl); + } + + #[Test] + public function it_rejects_an_embed_that_is_not_a_valid_url() + { + Http::fake(['*' => Http::response(['html' => ''])]); + + $this->assertFalse(Video::fromValue('https://wistia.com/medias/abc')->isSupported()); + } + + #[Test] + public function it_upgrades_an_insecure_embed_url_to_https() + { + Http::fake(['*' => Http::response(['html' => ''])]); + + $this->assertSame( + 'https://fast.wistia.net/embed/iframe/abc', + Video::fromValue('https://wistia.com/medias/abc')->embedUrl, + ); + } + + #[Test] + public function it_is_not_supported_when_the_lookup_fails() + { + Http::fake(['*' => Http::response(status: 500)]); + + $this->assertFalse(Video::fromValue('https://wistia.com/medias/abc')->isSupported()); + } + + #[Test] + public function it_caches_lookups_that_require_a_request() + { + Http::fake(['*' => Http::response(['html' => ''])]); + + Video::fromValue('https://wistia.com/medias/abc'); + Video::fromValue('https://wistia.com/medias/abc'); + + Http::assertSentCount(1); + } + + #[Test] + public function it_casts_to_the_original_value() + { + $this->assertSame('https://vimeo.com/22439234', (string) Video::fromValue('https://vimeo.com/22439234')); + $this->assertSame('', (string) Video::fromValue(null)); + } + + #[Test] + public function it_is_arrayable_and_accessible_as_an_array() + { + $video = Video::fromValue('cloudflare:1234'); + + $this->assertSame([ + 'embed_url' => 'https://iframe.cloudflarestream.com/1234', + 'id' => '1234', + 'provider' => 'cloudflare', + 'url' => 'cloudflare:1234', + ], $video->toArray()); + + $this->assertSame('https://iframe.cloudflarestream.com/1234', $video['embed_url']); + } +} diff --git a/tests/Fieldtypes/VideoTest.php b/tests/Fieldtypes/VideoTest.php new file mode 100644 index 00000000000..afb4f73ff7b --- /dev/null +++ b/tests/Fieldtypes/VideoTest.php @@ -0,0 +1,86 @@ +fieldtype()->preload(); + + $this->assertArrayNotHasKey('video', $meta); + $this->assertContains(['value' => 'Youtube', 'label' => 'Youtube'], $meta['providers']); + $this->assertContains(['value' => 'cloudflare', 'label' => 'Cloudflare Stream'], $meta['providers']); + } + + #[Test] + #[DataProvider('preloadValuesProvider')] + public function it_preloads_with_value($provider, $embedUrl, $value) + { + $meta = $this->fieldtype($value)->preload(); + + $this->assertSame($provider, $meta['video']['provider']); + $this->assertSame($embedUrl, $meta['video']['embed_url']); + } + + public static function preloadValuesProvider() + { + return [ + 'youtube' => ['Youtube', 'https://www.youtube.com/embed/FK3dav4bA4s?feature=oembed', 'https://www.youtube.com/watch?v=FK3dav4bA4s'], + 'cloudflare' => ['cloudflare', 'https://iframe.cloudflarestream.com/1234', 'cloudflare:1234'], + 'file' => ['file', 'https://example.com/clip.mp4', 'https://example.com/clip.mp4'], + ]; + } + + #[Test] + public function it_augments_null_to_null() + { + $this->assertNull($this->fieldtype()->augment(null)); + } + + #[Test] + #[DataProvider('augmentProvider')] + public function it_augments_to_a_video($value, $provider, $id, $embedUrl) + { + $video = $this->fieldtype()->augment($value); + + $this->assertInstanceOf(VideoDetails::class, $video); + $this->assertSame($provider, $video->provider); + $this->assertSame($id, $video->id); + $this->assertSame($embedUrl, $video->embedUrl); + } + + public static function augmentProvider() + { + return [ + 'url' => ['https://vimeo.com/22439234', 'Vimeo', null, 'https://player.vimeo.com/video/22439234'], + 'cloudflare' => ['cloudflare:1234', 'cloudflare', '1234', 'https://iframe.cloudflarestream.com/1234'], + 'unsupported' => ['https://example.com/nope', 'unsupported', null, null], + ]; + } + + #[Test] + public function the_augmented_value_casts_to_the_original_url_for_backwards_compatibility() + { + $this->assertSame( + 'https://vimeo.com/22439234', + (string) $this->fieldtype()->augment('https://vimeo.com/22439234'), + ); + } + + private function fieldtype($value = null) + { + return tap(new Video, fn (Video $fieldtype) => $fieldtype + ->setField(new Field('test', ['type' => 'video'])) + ->field()->setValue($value) + ); + } +} diff --git a/tests/Modifiers/EmbedUrlTest.php b/tests/Modifiers/EmbedUrlTest.php index 6061c170f3f..fb8bacc2f73 100644 --- a/tests/Modifiers/EmbedUrlTest.php +++ b/tests/Modifiers/EmbedUrlTest.php @@ -3,6 +3,7 @@ namespace Tests\Modifiers; use PHPUnit\Framework\Attributes\Test; +use Statamic\Fieldtypes\Video\Video; use Statamic\Modifiers\Modify; use Tests\TestCase; @@ -113,6 +114,20 @@ public function it_ensures_url_with_query_parameters_are_valid() ); } + #[Test] + public function it_gets_the_embed_url_from_an_augmented_video_value() + { + $this->assertEquals( + 'https://iframe.cloudflarestream.com/1234', + $this->embed(Video::fromValue('cloudflare:1234')), + ); + + $this->assertEquals( + 'https://player.vimeo.com/video/22439234', + $this->embed(Video::fromValue('https://vimeo.com/22439234')), + ); + } + public function embed($url) { return Modify::value($url)->embedUrl()->fetch();