Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
d6edd27
Merge branch 'ui' into feature/add-cf-to-video-fieldtype
edalzell Jun 3, 2025
095fe97
Merge branch 'ui' into feature/add-cf-to-video-fieldtype
edalzell Jun 13, 2025
f35b6a1
video providers
edalzell Jun 13, 2025
e373489
move the provider/embed logic to the fieldtype
edalzell Jun 14, 2025
caa2ded
move the provider/embed logic to the fieldtype
edalzell Jun 14, 2025
63ed860
wip
edalzell Jun 16, 2025
8548d67
don’t know how to identify a CF stream id
edalzell Jun 16, 2025
738f475
move it inside the input group
edalzell Jun 16, 2025
f1c2c6b
Merge branch 'master' into feature/add-cf-to-video-fieldtype
edalzell Jul 4, 2025
0e357ef
wip
edalzell Jun 18, 2025
74f8f9d
wip
edalzell Jul 5, 2025
0f8fd0b
Merge branch 'master' into feature/add-cf-to-video-fieldtype
edalzell Jul 9, 2025
37d64b2
some sugar
edalzell Jul 9, 2025
51c3fa8
wip
edalzell Jul 9, 2025
8a831e7
Discard changes to src/Fields/Fieldtype.php
edalzell Jul 9, 2025
2f3c41b
Discard changes to resources/js/components/fieldtypes/Fieldtype.vue
edalzell Jul 9, 2025
76e7e45
actually set the id
edalzell Jul 9, 2025
a769861
don’t debounce, for now
edalzell Jul 9, 2025
e84848c
tidy
edalzell Jul 9, 2025
a9aa392
fix providers
edalzell Jul 9, 2025
78bbdf5
this gets rid of width/height
edalzell Jul 9, 2025
87141a1
fix CF sizing
edalzell Jul 9, 2025
e884b3a
Not supported
edalzell Jul 9, 2025
994e93b
Merge branch 'master' into pr/11871
duncanmcclean Jul 10, 2025
3924b52
Merge branch 'master' into pr/11871
duncanmcclean Jul 10, 2025
41056dc
null be null
edalzell Jul 10, 2025
6d0ae6f
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell Mar 18, 2026
5548b80
fix bad merge
edalzell Mar 18, 2026
18650f0
wip
edalzell Mar 18, 2026
b5f0be3
tidy
edalzell Mar 18, 2026
2caaf6d
better display of embed
edalzell Mar 18, 2026
3fcbbf7
tidy
edalzell Mar 19, 2026
560c3fb
tidy
edalzell Mar 19, 2026
5f5d215
tidy
edalzell Mar 19, 2026
6ee9392
set/show the value properly
edalzell Mar 19, 2026
6370ad2
Tests
edalzell Mar 19, 2026
f10c70b
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell May 6, 2026
8007863
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell Jun 9, 2026
1567689
Merge remote-tracking branch 'upstream/6.x' into feature/add-cf-to-vi…
edalzell Sep 2, 2026
4943837
Escape Cloudflare ID in embed HTML
edalzell Sep 2, 2026
b3f32c6
Send the stored value to the video details endpoint
edalzell Sep 2, 2026
471ebe0
Restore lazy-loading, handle failed lookups, and stop the provider wa…
edalzell Sep 2, 2026
25c29dd
Remove copy-pasted Text fieldtype tests
edalzell Sep 2, 2026
6845c55
Fix unsafe new static usage
edalzell Sep 2, 2026
932accb
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell Sep 8, 2026
32e1a7e
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell Sep 14, 2026
d4eefc3
Constrain Embera to video providers and our own HTTP client
edalzell Sep 14, 2026
86c9822
Return a validated embed URL instead of provider-supplied markup
edalzell Sep 14, 2026
7b04a55
Augment video fields to a value object carrying the provider
edalzell Sep 14, 2026
c78a401
Build the video preview in Vue and debounce provider lookups
edalzell Sep 14, 2026
4326020
Validate the Cloudflare Stream ID format
edalzell Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions resources/css/components/fieldtypes/video.css
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions resources/css/cp.css
Original file line number Diff line number Diff line change
Expand Up @@ -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';
183 changes: 121 additions & 62 deletions resources/js/components/fieldtypes/VideoFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,99 +1,154 @@
<template>
<div class="flex flex-col space-y-3 p-1.5 bg-gray-100 border border-gray-300 dark:bg-gray-900 dark:border-gray-700 rounded-xl">
<ui-input-group>
<ui-input-group-prepend :text="__('URL')" />
<ui-input
:model-value="value"
:isReadOnly="isReadOnly"
:placeholder="__(config.placeholder) || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'"
:aria-label="__('Video URL')"
@update:model-value="update"
@focus="$emit('focus')"
@blur="$emit('blur')"
input-class="border-s-0"
/>
</ui-input-group>
<ui-description v-if="isInvalid" class="text-red-600">{{ __('statamic::validation.url') }}</ui-description>
<iframe
v-if="shouldShowPreview"
ref="iframe"
:src="isVisible ? embedUrl : null"
frameborder="0"
allow="fullscreen"
class="aspect-video rounded-lg"
loading="lazy"
></iframe>
<ui-combobox
:model-value="provider"
:options="providers"
option-label="label"
option-value="value"
:placeholder="__('Provider...')"
@update:model-value="changeProvider"
/>

<ui-input
v-if="isCloudflare"
:aria-label="__('Video ID')"
input-class="border-s-0"
:isReadOnly="isReadOnly"
:model-value="videoId"
:prepend="__('ID')"
@update:model-value="updateCloudflareId"
@focus="$emit('focus')"
@blur="$emit('blur')"
/>

<ui-input
v-else
:aria-label="__('Video URL')"
input-class="border-s-0"
:isReadOnly="isReadOnly"
:model-value="url"
:placeholder="__(config.placeholder) || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'"
:prepend="__('URL')"
@update:model-value="updateUrl"
@focus="$emit('focus')"
@blur="$emit('blur')"
/>

<video v-if="shouldShowPreview && isFile" :src="embedUrl" controls class="w-full rounded-md"></video>

<div v-else-if="shouldShowPreview" class="video-fieldtype-embed">
<iframe
:src="embedUrl"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>

<ui-description v-else-if="isUnsupported" class="text-red-500">
{{ __('This video URL is not supported.') }}
</ui-description>
</div>
</template>

<script>
import axios from 'axios';
import Fieldtype from './Fieldtype.vue';

const CLOUDFLARE = 'cloudflare';
const FILE = 'file';
const UNSUPPORTED = 'unsupported';
const CLOUDFLARE_PREFIX = 'cloudflare:';

export default {
mixins: [Fieldtype],

data() {
return {
abortController: null,
embedUrl: this.meta.video?.embed_url ?? null,
isVisible: false,
observer: null,
provider: this.meta.video?.provider ?? null,
};
},

computed: {
shouldShowPreview() {
return !this.isInvalid && (this.isEmbeddable || this.isVideo);
isCloudflare() {
return this.provider === CLOUDFLARE || !!this.value?.startsWith(CLOUDFLARE_PREFIX);
},

embedUrl() {
let embed_url = this.value || '';
isFile() {
return this.provider === FILE;
},

if (embed_url.includes('youtube')) {
embed_url = embed_url.includes('shorts/')
? embed_url.replace('shorts/', 'embed/')
: embed_url.replace('watch?v=', 'embed/');
}
isUnsupported() {
return this.provider === UNSUPPORTED && !!this.value;
},

if (embed_url.includes('youtu.be')) {
embed_url = embed_url.replace('youtu.be', 'www.youtube.com/embed');
}
providers() {
return this.meta.providers;
},

if (embed_url.includes('vimeo')) {
embed_url = embed_url.replace('/vimeo.com', '/player.vimeo.com/video');
shouldShowPreview() {
return this.isVisible && !!this.embedUrl;
},

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('?', '&');
}
}
url() {
return this.value?.startsWith(CLOUDFLARE_PREFIX) ? null : this.value;
},

if (embed_url.includes('&') && !embed_url.includes('?')) {
embed_url = embed_url.replace('&', '?');
}
videoId() {
return this.value?.startsWith(CLOUDFLARE_PREFIX) ? this.value.slice(CLOUDFLARE_PREFIX.length) : null;
},
},

return embed_url;
watch: {
value(value) {
this.lookup(value);
},
},

isEmbeddable() {
const url = this.value || '';
const isYoutube = url.includes('youtube') || url.includes('youtu.be');
const isVimeo = url.includes('vimeo');
return isYoutube || isVimeo;
methods: {
changeProvider(provider) {
this.provider = provider;
this.embedUrl = null;
this.update(null);
},

isInvalid() {
let htmlRegex = new RegExp(/<([A-Z][A-Z0-9]*)\b[^>]*>.*?<\/\1>|<([A-Z][A-Z0-9]*)\b[^\/]*\/>/i);
return htmlRegex.test(this.value || '');
lookup(value) {
if (this.abortController) this.abortController.abort();

if (!value) {
this.embedUrl = null;
return;
}

this.abortController = new AbortController();

this.$axios
.get(this.meta.url, { params: { value }, signal: this.abortController.signal })
.then((response) => {
if (value !== this.value) return;

this.embedUrl = response.data.embed_url;
this.provider = response.data.provider;
})
.catch((e) => {
if (axios.isCancel(e)) return;
if (value !== this.value) return;

this.embedUrl = 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://');
updateCloudflareId(id) {
this.updateDebounced(id ? `${CLOUDFLARE_PREFIX}${id}` : null);
},

isVideo() {
const url = this.value || '';
const isVideo = url.includes('.mp4') || url.includes('.ogv') || url.includes('.mov') || url.includes('.webm');
return !this.isEmbeddable && isVideo;
updateUrl(url) {
this.updateDebounced(url || null);
},
},

Expand All @@ -107,7 +162,7 @@ export default {
}
});
},
{ threshold: 0.01 }
{ threshold: 0.01 },
);

if (this.$el) {
Expand All @@ -119,6 +174,10 @@ export default {
if (this.observer) {
this.observer.disconnect();
}

if (this.abortController) {
this.abortController.abort();
}
},
};
</script>
Loading
Loading