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
10 changes: 10 additions & 0 deletions src/Fieldtypes/Video.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
namespace Statamic\Fieldtypes;

use Statamic\Fields\Fieldtype;
use Statamic\Fieldtypes\Video\Embed;

use function Statamic\trans as __;

class Video extends Fieldtype
{
protected $categories = ['media'];

public function augment($value)
{
if (is_null($value)) {
return null;
}

return Embed::fromValue($value);
}

protected function configFieldItems(): array
{
return [
Expand Down
222 changes: 222 additions & 0 deletions src/Fieldtypes/Video/Embed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

namespace Statamic\Fieldtypes\Video;

use ArrayAccess;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Str;
use JsonSerializable;
use Statamic\Contracts\Support\Boolable;
use Statamic\Support\FileTypes;

class Embed implements Arrayable, ArrayAccess, Boolable, JsonSerializable
{
const CLOUDFLARE = 'cloudflare';
const CLOUDFLARE_EMBED_URL = 'https://iframe.cloudflarestream.com/';
const CLOUDFLARE_ID_PATTERN = '/^[a-zA-Z0-9]+$/';
const CLOUDFLARE_PREFIX = 'cloudflare:';
const FILE = 'file';
const UNSUPPORTED = 'unsupported';
const VIMEO = 'vimeo';
const YOUTUBE = 'youtube';

public static function fromValue(?string $value): self
{
if (blank($value)) {
return static::unsupported($value);
}

if (Str::startsWith($value, self::CLOUDFLARE_PREFIX)) {
$id = Str::after($value, self::CLOUDFLARE_PREFIX);

return preg_match(self::CLOUDFLARE_ID_PATTERN, $id)
? new self(self::CLOUDFLARE, $value, self::CLOUDFLARE_EMBED_URL.$id, $id)
: static::unsupported($value);
}

if ($provider = static::oembedProvider($value)) {
return new self($provider, $value, static::embedUrl($value));
}

if (static::isVideoFile($value)) {
return new self(self::FILE, $value, $value);
}

return static::unsupported($value);
}

public static function unsupported(?string $value = null): self
{
return new self(self::UNSUPPORTED, $value);
}

public function __construct(
public readonly string $provider,
public readonly ?string $url = null,
public readonly ?string $embedUrl = null,
public readonly ?string $id = null,
) {
}

public function isEmbeddable(): bool
{
return ! in_array($this->provider, [self::FILE, self::UNSUPPORTED]);
}

public function isSupported(): bool
{
return $this->provider !== self::UNSUPPORTED;
}

public function toArray(): array
{
return [
'embed_url' => $this->embedUrl,
'id' => $this->id,
'provider' => $this->provider,
'url' => $this->url,
];
}

public function toBool(): bool
{
return (bool) $this->url;
}

public function __toString(): string
{
return (string) $this->url;
}

#[\ReturnTypeWillChange]
public function jsonSerialize()
Comment thread
edalzell marked this conversation as resolved.
{
return (string) $this;
}

#[\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)
{
}

/**
* Turn a link that's direct to a video's page into its embeddable equivalent.
*/
public static function embedUrl(?string $url): ?string
{
if (blank($url)) {
return $url;
}

if (Str::contains($url, self::VIMEO)) {
return static::vimeoEmbedUrl($url);
}

if (Str::contains($url, 'youtu.be')) {
$url = str_replace('youtu.be', 'www.youtube.com/embed', $url);

// Check for start at point and replace it with correct parameter.
if (Str::contains($url, '?t=')) {
$url = str_replace('?t=', '?start=', $url);
}
}

if (Str::contains($url, 'youtube.com/watch?v=')) {
$url = str_replace('watch?v=', 'embed/', $url);

if (Str::contains($url, '&t=')) {
$url = str_replace('&t=', '?start=', $url);
}
}

if (Str::contains($url, 'youtube.com/shorts/')) {
$url = str_replace('shorts/', 'embed/', $url);
}

if (Str::contains($url, 'youtube.com')) {
$url = str_replace('youtube.com', 'youtube-nocookie.com', $url);
}

// This avoids SSL issues when using the non-www version
if (Str::contains($url, '//youtube-nocookie.com')) {
$url = str_replace('//youtube-nocookie.com', '//www.youtube-nocookie.com', $url);
}

if (Str::contains($url, '&') && ! Str::contains($url, '?')) {
$url = Str::replaceFirst('&', '?', $url);
}

return $url;
}

public static function isEmbeddableUrl(?string $url): bool
{
return filled($url) && Str::contains($url, ['youtu.be', 'youtube', self::VIMEO]);
}

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 oembedProvider(string $url): ?string
{
if (Str::contains($url, self::VIMEO)) {
return self::VIMEO;
}

if (Str::contains($url, ['youtu.be', 'youtube'])) {
return self::YOUTUBE;
}

return null;
}

// Unlisted vimeo urls are in the form vimeo.com/id/hash, but embeds pass the hash as a get param.
protected static function vimeoEmbedUrl(string $url): string
{
$url = str_replace('/vimeo.com', '/player.vimeo.com/video', $url);
$hash = '';

if (! Str::contains($url, 'progressive_redirect') && Str::substrCount($url, '/') > 4) {
$hash = Str::afterLast($url, '/');
$url = Str::beforeLast($url, '/');

if (Str::contains($hash, '?')) {
$url .= '?'.Str::after($hash, '?');
$hash = Str::before($hash, '?');
}
}

$paramsToAdd = '?dnt=1';

if ($hash) {
$paramsToAdd .= '&h='.$hash;
}

return Str::contains($url, '?')
? str_replace('?', $paramsToAdd.'&', $url)
: $url.$paramsToAdd;
}
}
93 changes: 21 additions & 72 deletions src/Modifiers/CoreModifiers.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
use Statamic\Fieldtypes\Bard;
use Statamic\Fieldtypes\Bard\Augmentor;
use Statamic\Fieldtypes\Link\ArrayableLink;
use Statamic\Fieldtypes\Video\Embed;
use Statamic\Statamic;
use Statamic\Support\Arr;
use Statamic\Support\Dumper;
use Statamic\Support\Html;
use Statamic\Support\Str;
use Statamic\Support\Traits\ChecksDumpability;
use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState;
use Stringable;
use Stringy\StaticStringy as Stringy;

use function Statamic\trans;
Expand Down Expand Up @@ -1560,7 +1562,9 @@ public function length($value)
return $value->count();
}

if ($value instanceof Arrayable) {
// Value objects like ArrayableString are both Arrayable and Stringable.
// They stand in for a string, so measure the string, not the array.
if ($value instanceof Arrayable && ! $value instanceof Stringable) {
Comment on lines +1565 to +1567

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.

The comment scopes this to value objects, but Stringable is auto-implemented by PHP 8 on anything declaring __toString() — so the guard catches a good deal more than the value objects it's aimed at.

In src/, the non-Countable classes that are both Arrayable and Stringable are ArrayableString, LabeledValue, ArrayableLink and Embed — plus Asset, Entries\Collection, Taxonomy, AssetContainer and Blueprint.

Measured on the last of those:

{{ … | length }} 6.x this PR
Asset 21 (field count) 17 (strlen of url())
Collection 2 4 (strlen of handle)
AssetContainer 7 4 (strlen of handle)

For the value objects the change is right — {{ code_field \| length }} measuring the code beats counting ['value', …]. For assets and collections it's neither intended nor an improvement: the character count of an asset URL is no more meaningful than the field count was, and it changes silently. LengthTest only covers the ArrayableString case, so nothing catches it.

Narrowing the guard to the value-object family fixes it:

Suggested change
// Value objects like ArrayableString are both Arrayable and Stringable.
// They stand in for a string, so measure the string, not the array.
if ($value instanceof Arrayable && ! $value instanceof Stringable) {
// Value objects like ArrayableString are both Arrayable and Stringable.
// They stand in for a string, so measure the string, not the array.
if ($value instanceof Arrayable && ! $value instanceof ArrayableString && ! $value instanceof Embed) {

Though that second instanceof hints at the tidier fix: if Embed extended ArrayableString — the convention the description cites anyway — the guard would just be ! $value instanceof ArrayableString, and you'd get value(), the ArrayAccess implementation and Statamic\View\Blade\value() unwrapping for free instead of hand-rolling them. Up to you whether that's in scope here, but it's what this guard is working around.

$value = $value->toArray();
}

Expand Down Expand Up @@ -3199,60 +3203,11 @@ public function yearsAgo($value, $params)
*/
public function embedUrl($url)
{
if (Str::contains($url, 'vimeo')) {
$url = str_replace('/vimeo.com', '/player.vimeo.com/video', $url);

[$url, $hash] = $this->handleUnlistedVimeoUrls($url);

$paramsToAdd = '?dnt=1';
if ($hash) {
$paramsToAdd .= '&h='.$hash;
}

if (Str::contains($url, '?')) {
$url = str_replace('?', $paramsToAdd.'&', $url);
} else {
$url .= $paramsToAdd;
}

return $url;
}

if (Str::contains($url, 'youtu.be')) {
$url = str_replace('youtu.be', 'www.youtube.com/embed', $url);

// Check for start at point and replace it with correct parameter.
if (Str::contains($url, '?t=')) {
$url = str_replace('?t=', '?start=', $url);
}
}

if (Str::contains($url, 'youtube.com/watch?v=')) {
$url = str_replace('watch?v=', 'embed/', $url);

if (Str::contains($url, '&t=')) {
$url = str_replace('&t=', '?start=', $url);
}
}

if (Str::contains($url, 'youtube.com/shorts/')) {
$url = str_replace('shorts/', 'embed/', $url);
if ($url instanceof Embed) {
return $url->embedUrl ?? $url->url;
}

if (Str::contains($url, 'youtube.com')) {
$url = str_replace('youtube.com', 'youtube-nocookie.com', $url);
}

// This avoids SSL issues when using the non-www version
if (Str::contains($url, '//youtube-nocookie.com')) {
$url = str_replace('//youtube-nocookie.com', '//www.youtube-nocookie.com', $url);
}

if (Str::contains($url, '&') && ! Str::contains($url, '?')) {
$url = Str::replaceFirst('&', '?', $url);
}

return $url;
return Embed::embedUrl($url);
}

/**
Expand All @@ -3264,6 +3219,14 @@ public function embedUrl($url)
*/
public function trackableEmbedUrl($url)
{
if ($url instanceof Embed) {
$url = $url->url;
}

if (blank($url)) {
return $url;
}
Comment thread
edalzell marked this conversation as resolved.

if (Str::contains($url, 'vimeo')) {
return str_replace('/vimeo.com', '/player.vimeo.com/video', $url);
}
Expand Down Expand Up @@ -3296,7 +3259,11 @@ public function trackableEmbedUrl($url)
*/
public function isEmbeddable($url)
{
return Str::contains($url, ['youtu.be', 'youtube', 'vimeo']);
if ($url instanceof Embed) {
return $url->isEmbeddable();
}
Comment thread
edalzell marked this conversation as resolved.

return Embed::isEmbeddableUrl($url);
}

/**
Expand Down Expand Up @@ -3381,24 +3348,6 @@ private function getFromContext($context, $params, $key = 0)
Arr::get($context, $params[$key], $params[$key]);
}

// unlisted vimeo urls are in the form vimeo.com/id/hash, but embeds pass the hash as a get param
private function handleUnlistedVimeoUrls($url)
{
$hash = '';

if (! Str::contains($url, 'progressive_redirect') && Str::substrCount($url, '/') > 4) {
$hash = Str::afterLast($url, '/');
$url = Str::beforeLast($url, '/');

if (Str::contains($hash, '?')) {
$url .= '?'.Str::after($hash, '?');
$hash = Str::before($hash, '?');
}
}

return [$url, $hash];
}

private function dumpingAllowed(array $params): bool
{
return $this->traitDumpingAllowed() || (Arr::get($params, 0) === 'force');
Expand Down
Loading
Loading