Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^2.0 resolves as low as 2.0.0, and the offline-resolution behaviour this feature is built on doesn't exist down there.

Embed::fromOembed() leans on Embera's getFakeResponse() rules via Embera::ONLY_FAKE_RESPONSES. I checked that method across the tags: Embera\Provider\Vimeo doesn't have it in 2.0.0, 2.0.5, 2.0.10, 2.0.15, 2.0.20, 2.0.25 or 2.0.30 — it first appears somewhere between 2.0.30 and 2.0.32. Youtube is the same. The constant itself exists all the way back to 2.0.0, so nothing throws; the fake-response path just quietly resolves nothing and falls through to a live request.

That's what P8.3 - L12.* - prefer-lowest - shard 3/4 is tripping over:

Failed asserting that two strings are identical.
-'Vimeo'
+'unsupported'
at tests/Fieldtypes/VideoTest.php:27

It's not only a test-environment problem, though. The PR description's core argument is that YouTube, Vimeo, DailyMotion, Loom, Streamable, Rumble, Coub and Ted cost zero HTTP requests — but that's only true on the newer end of what ^2.0 permits. A site whose lockfile lands on an older 2.0.x pays a network round-trip on every YouTube and Vimeo embed, which is exactly the cost the dependency was justified as avoiding.

Suggested change
"mpratt/embera": "^2.0",
"mpratt/embera": "^2.0.32",

Worth confirming the floor per-provider rather than taking 2.0.32 from me — I only bisected Vimeo and Youtube, and all eight of the "offline" providers need getFakeResponse() at whatever minimum you settle on.

"nesbot/carbon": "^3.0",
"pragmarx/google2fa": "^8.0 || ^9.0",
"rebing/graphql-laravel": "^9.15",
Expand Down
138 changes: 100 additions & 38 deletions resources/js/components/fieldtypes/VideoFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
<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-combobox
:model-value="mode"
:options="meta.providers"
option-label="label"
option-value="value"
:aria-label="__('Video Provider')"
@update:model-value="changeMode"
/>
<ui-input-group v-if="isCloudflare">
<ui-input-group-prepend :text="__('ID')" />
<ui-input
:model-value="videoId"
:isReadOnly="isReadOnly"
:aria-label="__('Video ID')"
@update:model-value="updateCloudflareId"
@focus="$emit('focus')"
@blur="$emit('blur')"
input-class="border-s-0"
/>
</ui-input-group>
<ui-input-group v-else>
<ui-input-group-prepend :text="__('URL')" />
<ui-input
:model-value="value"
Expand All @@ -14,8 +34,14 @@
/>
</ui-input-group>
<ui-description v-if="isInvalid" class="text-red-600">{{ __('statamic::validation.url') }}</ui-description>
<video
v-if="shouldShowPreview && isFile"
:src="isVisible ? embedUrl : null"
controls
class="aspect-video rounded-lg w-full"
></video>
<iframe
v-if="shouldShowPreview"
v-else-if="shouldShowPreview"
ref="iframe"
:src="isVisible ? embedUrl : null"
frameborder="0"
Expand All @@ -27,73 +53,105 @@
</template>

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

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

export default {
mixins: [Fieldtype],

data() {
return {
abortController: null,
isVisible: false,
lookedUp: this.meta.video,
observer: null,
// Only consulted when there's no value; otherwise the value itself says which input to show.
mode: this.meta.video?.provider === CLOUDFLARE ? CLOUDFLARE : URL_MODE,
};
},

computed: {
shouldShowPreview() {
return !this.isInvalid && (this.isEmbeddable || this.isVideo);
return !this.isInvalid && !!this.embedUrl;
},

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

if (embed_url.includes('youtube')) {
embed_url = embed_url.includes('shorts/')
? embed_url.replace('shorts/', 'embed/')
: embed_url.replace('watch?v=', 'embed/');
embedUrl() {
if (this.isCloudflare) {
return this.videoId ? `https://iframe.cloudflarestream.com/${this.videoId}` : null;
}

if (embed_url.includes('youtu.be')) {
embed_url = embed_url.replace('youtu.be', 'www.youtube.com/embed');
}
return this.lookedUp?.url === this.value ? this.lookedUp.embed_url : null;
},

if (embed_url.includes('vimeo')) {
embed_url = embed_url.replace('/vimeo.com', '/player.vimeo.com/video');
isCloudflare() {
return this.value?.startsWith(CLOUDFLARE_PREFIX) || (!this.value && this.mode === CLOUDFLARE);
},

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('?', '&');
}
}
isInvalid() {
if (this.isCloudflare) return !!this.videoId && !/^[a-zA-Z0-9]+$/.test(this.videoId);

if (embed_url.includes('&') && !embed_url.includes('?')) {
embed_url = embed_url.replace('&', '?');
}
return !!this.value && this.lookedUp?.url === this.value && this.lookedUp.provider === UNSUPPORTED;
},

return embed_url;
videoId() {
return this.value?.startsWith(CLOUDFLARE_PREFIX) ? this.value.slice(CLOUDFLARE_PREFIX.length) : null;
},
},

watch: {
value: {
handler(value) {
if (value?.startsWith(CLOUDFLARE_PREFIX) || !value) return;

isEmbeddable() {
const url = this.value || '';
const isYoutube = url.includes('youtube') || url.includes('youtu.be');
const isVimeo = url.includes('vimeo');
return isYoutube || isVimeo;
this.lookup(value);
},
immediate: true,
},
},

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

this.mode = mode;

if (this.value) this.update(null);
},

isUrl() {
const url = this.value || '';
return url.startsWith('http://') || url.startsWith('https://');
lookup(value) {
if (this.lookedUp?.url === value) return;

if (this.abortController) this.abortController.abort();

this.abortController = new AbortController();

this.$axios
.get(this.meta.detailsUrl, { params: { value }, signal: this.abortController.signal })
.then((response) => {
// Ignore a response that arrived after the value moved on.
if (value === this.value) this.lookedUp = response.data;
})
.catch((e) => {
if (axios.isCancel(e)) return;
if (value !== this.value) return;

this.lookedUp = null;
this.$toast.error(e.response ? e.response.data.message : __('Something went wrong'));
});
},

isVideo() {
const url = this.value || '';
const isVideo = url.includes('.mp4') || url.includes('.ogv') || url.includes('.mov') || url.includes('.webm');
return !this.isEmbeddable && isVideo;
updateCloudflareId(id) {
this.update(id ? `${CLOUDFLARE_PREFIX}${id}` : null);
},
},

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

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