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 @@
-
-
-
-
-
{{ __('statamic::validation.url') }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('This video URL is not supported.') }}
+
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('/', '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();