Изучение Livewire 3, Volt и Folio на примере создания плеера подкастов

Источник: «Learn Livewire 3, Volt, and Folio by building a podcast player»
В этом руководстве мы познакомимся с основами Livewire 3, Volt и Folio и создадим простое приложение, в котором будут отображаться эпизоды подкаста Laravel News.

Недавно команда Laravel выпустила Laravel Folio — мощный страничный маршрутизатор, предназначенный для упрощения маршрутизации в приложениях Laravel. Следом они выпустили Volt — элегантно выполненный функциональный API для Livewire, позволяющий PHP-логике компонента и шаблонам Blade сосуществовать в одном файле с минимальным количеством шаблонов.

Хотя их можно использовать по отдельности, я считаю, что их совместное применение — это новый, невероятно продуктивный способ создания приложений на Laravel.

В этой статье я расскажу как создать простое приложение, в котором перечисляются эпизоды подкаста Laravel News и пользователи могут их воспроизводить, причём плеер может плавно продолжать воспроизведение при загрузке страницы.

Установка Livewire, Volt и Folio

Для начала нам необходимо создать новое приложение Laravel и установить Livewire, Volt, Folio и Sushi (для создания фиктивных данных).

laravel new

composer require livewire/livewire:^3.0@beta livewire/volt:^1.0@beta laravel/folio:^1.0@beta calebporzio/sushi

Livewire v3, Volt и Folio все ещё находятся в бета-версии. Они должны быть достаточно стабильными, но использовать их следует на свой страх и риск.

После установки пакетов необходимо выполнить php artisan volt:install и php artisan folio:install. В результате будут созданы папки и провайдеры, необходимые Volt и Folio.

Модель Episode

Для фиктивных данных я создам модель Sushi. Sushi — это пакет, написанный Калебом Позио, который позволяет создавать модели Eloquent, запрашивающие данные из массива, записанного непосредственно в файле модели. Это отлично подходит для создания примеров приложений или данных, которые не должны часто меняться.

Создайте модель, затем удалите трейт HasFactory и замените его на трейт Sushi. В качестве данных для этого примера я добавил сведения о четырёх последних эпизодах подкаста Laravel News.

Не буду вдаваться в детали того, как все это работает, поскольку это не является целью статьи, а при создании собственного плеера подкастов вы, скорее всего, будете использовать реальную модель Eloquent.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Sushi\Sushi;

class Episode extends Model
{
use Sushi;

protected $casts = [
'released_at' => 'datetime',
];

protected $rows = [
[
'number' => 195,
'title' => 'Queries, GPT, and sinking downloads',
'notes' => '...',
'audio' => 'https://media.transistor.fm/c28ad926/93e5fe7d.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2579,
'released_at' => '2023-07-06 10:00:00',
],
[
'number' => 194,
'title' => 'Squeezing lemons, punching cards, and bellowing forges',
'notes' => '...',
'audio' => 'https://media.transistor.fm/6d2d53fe/f70d9278.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2219,
'released_at' => '2023-06-21 10:00:00',
],
[
'number' => 193,
'title' => 'Precognition, faking Stripe, and debugging Blade',
'notes' => '...',
'audio' => 'https://media.transistor.fm/d434305e/975fbb28.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2146,
'released_at' => '2023-06-06 10:00:00',
],
[
'number' => 192,
'title' => 'High octane, sleepy code, and Aaron Francis',
'notes' => '...',
'audio' => 'https://media.transistor.fm/b5f81577/c58c90c8.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 1865,
'released_at' => '2023-05-24 10:00:00',
],
// ...
];
}

Представление макета

Нам понадобится файл макета для загрузки Tailwind, добавления логотипа и основных стилей. Поскольку Livewire и Alpine теперь автоматически внедряют свои скрипты и стили, нам даже не нужно загружать их в макет! Мы создадим макет как анонимный компонент Blade по адресу resources/views/components/layout.blade.php.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laravel News Podcast Player</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
</head>
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>

<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>

<span>Laravel News Podcast</span>
</a>

<div class="py-10">{{ $slot }}</div>
</div>
</body>
</html>

Страница списка эпизодов

Во-первых, нам нужна страница, на которой будут отображаться все эпизоды подкаста.

Страница, на которой будут отображаются все эпизоды подкаста

Используя Folio, мы можем легко создать новую страницу в каталоге resources/views/pages, и Laravel автоматически создаст маршрут для этой страницы. Мы хотим, чтобы наш маршрут был /episodes, поэтому выполним php artisan make:folio episodes/index. Это создаст пустое представление по адресу resources/views/pages/episodes/index.blade.php.

На этой странице мы вставим компонент layout, а затем в цикле просмотрим все эпизоды подкаста. Volt предоставляет функции с пространством имён для большинства функций Livewire. Здесь мы откроем обычные теги <?php ?> для открытия и закрытия. Внутри них с помощью функции computed создадим переменную $episodes, выполняющую запрос для получения всех моделей эпизодов ($episodes = computed(fn () => Episode::get());). Мы можем получить доступ к свойству computed в шаблоне с помощью $this->episodes.

Я также создал переменную $formatDuration, которая представляет собой функцию для форматирования свойства duration_in_seconds каждого эпизода в удобочитаемый формат. Мы можем вызвать эту функцию в шаблоне с помощью $this->formatDuration($episode->duration_in_seconds).

Нам также необходимо обернуть динамическую функциональность на странице директивой @volt, чтобы зарегистрировать её как анонимный компонент Livewire на странице Folio.

<?php

use App\Models\Episode;
use Illuminate\Support\Stringable;
use function Livewire\Volt\computed;
use function Livewire\Volt\state;

$episodes = computed(fn () => Episode::get());

$formatDuration = function ($seconds) { ...

?>


<x-layout>
@volt
<div class="rounded-xl border border-gray-200 bg-white shadow">
<ul class="divide-y divide-gray-100">
@foreach ($this->episodes as $episode)
<li
wire:key="{{ $episode->number }}"
class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
>

<div>
<h2>
No. {{ $episode->number }} - {{ $episode->title }}
</h2>
<div
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500"
>

<p>
Released:
{{ $episode->released_at->format('M j, Y') }}
</p>
&middot;
<p>
Duration:
{{ $this->formatDuration($episode->duration_in_seconds) }}
</p>
</div>
</div>
<button
type="button"
class="flex shrink-0 items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
>

<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>

<span>Play</span>
</button>
</li>
@endforeach
</ul>
</div>
@endvolt
</x-layout>

Плеер эпизодов

Далее нам нужно добавить интерактивность. Я хочу добавить проигрыватель эпизодов, чтобы мы могли прослушивать эпизоды из списка эпизодов. Это может быть обычный компонент Blade, который мы отображаем в файле макета.

проигрыватель эпизодов
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laravel News Podcast Player</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
</head>
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>

<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>

<span>Laravel News Podcast</span>
</a>

<div class="py-10">{{ $slot }}</div>

<x-episode-player />
</div>
</body>
</html>

Мы можем создать этот компонент, добавив файл resources/views/components/episode-player.blade.php. Внутри компонента мы добавим элемент <audio> с некоторым кодом Alpine для хранения активного эпизода и функцию, которая обновляет активный эпизод и запускает аудио. Мы будем показывать плеер только в том случае, если активный эпизод установлен, и добавим в обёртку красивый затухающий переход.

<div
x-data="{
activeEpisode: null,
play(episode) {
this.activeEpisode = episode

this.$nextTick(() => {
this.$refs.audio.play()
})
},
}"

x-show="activeEpisode"
x-transition.opacity.duration.500ms
class="fixed inset-x-0 bottom-0 w-full border-t border-gray-200 bg-white"
style="display: none"
>

<div class="mx-auto max-w-xl p-6">
<h3
x-text="`Playing: No. ${activeEpisode?.number} - ${activeEpisode?.title}`"
class="text-center text-sm font-medium text-gray-600"
>
</h3>
<audio
x-ref="audio"
class="mx-auto mt-3"
:src="activeEpisode?.audio"
controls
>
</audio>
</div>
</div>

Если мы перезагрузим страницу, то не увидим никаких изменений. Это связано с тем, что мы не добавили способ воспроизведения эпизодов. Мы будем использовать события для передачи данных от наших компонентов Livewire к плееру. Сначала в плеере мы добавим x-on:play-episode.window="play($event.detail)", чтобы прослушать событие play-episode в окне и вызвать функцию play.

<div
x-data="{
activeEpisode: null,
play(episode) {
this.activeEpisode = episode

this.$nextTick(() => {
this.$refs.audio.play()
})
},
}"

x-on:play-episode.window="play($event.detail)"
...
>

<!-- ... -->
</div>

Далее на странице episodes/index мы добавим слушателя кликов на кнопки воспроизведения для каждого эпизода. Кнопки будут отправлять событие play-episode, которое будет получено и обработано плеером эпизода.

<button
x-data
x-on:click="$dispatch('play-episode', @js($episode))"
...
>

<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>

<span>Play</span>
</button>

Страница с подробной информацией об эпизоде

Далее я хочу добавить страницу с информацией об эпизоде, на которой будут отображаться заметки о каждом эпизоде и другие подробности.

Страница с информацией об эпизоде

В Folio существуют довольно удобные соглашения для привязки моделей маршрутов к именам файлов. Чтобы создать эквивалентный маршрут для /episodes/{episode:id}, создайте страницу по адресу resources/views/pages/episodes/[Episode].blade.php. Чтобы использовать параметр маршрута, отличный от первичного ключа, можно использовать синтаксис [Model:some_other_key].blade.php в имени файла. Я хочу использовать номер эпизода в URL, поэтому мы создадим файл по адресу resources/views/pages/episodes/[Episode:number].blade.php.

Folio автоматически запросит модели Episode для эпизода с номером, который мы передадим в URL, и сделает его доступным в виде переменной $episode в нашем коде <?php ?>. Затем мы можем преобразовать её в свойство Livewire с помощью функции состояния Volt.

Мы также разместим на этой странице кнопку воспроизведения, чтобы пользователи могли прослушать эпизод, просматривая его детали.

<?php
use Illuminate\Support\Stringable;
use function Livewire\Volt\state;

state(['episode' => fn () => $episode]);

$formatDuration = function ($seconds) { ...
?>


<x-layout>
@volt
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow">
<div class="p-6">
<div class="flex items-center justify-between gap-8">
<div>
<h2 class="text-xl font-medium">
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
<div
class="mt-1 flex items-center gap-3 text-sm text-gray-500"
>

<p>
Released:
{{ $episode->released_at->format('M j, Y') }}
</p>
&middot;
<p>
Duration:
{{ $this->formatDuration($episode->duration_in_seconds) }}
</p>
</div>
</div>

<button
x-on:click="$dispatch('play-episode', @js($episode))"
type="button"
class="flex items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
>

<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>

<span>Play</span>
</button>
</div>
<div class="prose prose-sm mt-4">
{!! $episode->notes !!}
</div>
</div>
<div class="bg-gray-50 px-6 py-4">
<a
href="/episodes"
class="text-sm font-medium text-gray-600"
>

&larr; Back to episodes
</a>
</div>
</div>
@endvolt
</x-layout>

Теперь нам нужно сделать ссылку на страницу подробностей с индексной страницы. Вернувшись на страницу episodes/index, обернём <h2> каждого эпизода в тег ссылки.

@foreach ($this->episodes as $episode)
<li
wire:key="{{ $episode->number }}"
class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
>

<div>
<a
href="/episodes/{{ $episode->number }}"
class="transition hover:text-[#FF2D20]"
>

<h2>
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
</a>
</div>
{{-- ... --}}
</li>
@endforeach

Режим SPA

Мы почти у цели. Приложение выглядит неплохо и работает хорошо, но есть одна проблема. Если пользователь прослушивает эпизод и переходит на другую страницу, то проигрыватель эпизодов теряет состояние активного эпизода и исчезает.

К счастью, в Livewire есть директива wire:navigate и @persist, которые теперь помогают решить эти проблемы!

В нашем файле макета давайте обернём логотип и проигрыватель эпизодов в блоки @persist. Livewire определит это и не будет перерисовывать эти блоки при смене страницы.

<!DOCTYPE html>
<html lang="en">
...
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
@persist('logo')
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>

<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>

<span>Laravel News Podcast</span>
</a>
@endpersist

<div class="py-10">{{ $slot }}</div>

@persist('player')
<x-episode-player />
@endpersist
</div>
</body>
</html>

Наконец, необходимо добавить атрибут wire:navigate ко всем ссылкам в приложении. Например:

<a
href="/episodes/{{ $episode->number }}"
class="transition hover:text-[#FF2D20]"
wire:navigate
>

<h2>
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
</a>

Когда используете атрибут wire:navigate, за кулисами Livewire получает содержимое новой страницы с помощью AJAX, а затем волшебным образом меняет его местами в браузере без полной перезагрузки страницы. Благодаря этому загрузка страницы становится невероятно быстрой, а такие функции, как persist, могут работать! Это позволяет реализовать функции, которые раньше можно было реализовать только с помощью SPA.

Заключение

Это было очень забавное демонстрационное приложение, которое мы создали в процессе изучения Volt и Folio. Я выложил демонстрационное приложение здесь, а @bosunski, если хотите увидеть полный исходный код или попробовать его самостоятельно!

Что думаете? Является ли Livewire v3 + Volt + Folio самым простым стеком для создания приложений на Laravel? Я думаю, что это действительно здорово и может показаться более привычным для тех, кто привык создавать приложения на JavaScript-фреймворках, таких, как Next.js и Nuxt.js. Также приятно, что весь код страницы расположен в одном месте — стилизация (через Tailwind), JS (через Alpine) и код бэкенда в одном файле. Присылайте свои соображения в Twitter!

Дополнительные материалы

Предыдущая Статья

Понимание flatMap() и других массивов TypeScript

Следующая Статья

Как изменить порт SSH по умолчанию в Linux