Podcast Pages Overhaul

- Move the Podcasts pages from individual PHP templates into a Vue mini-SPA
 - Podcasts and episodes are paginated, sortable and searchable
 - The full podcast page is embeddable in external pages
 - The podcast player is our standard seekable inline player and persists as you're navigating around the podcasts page
This commit is contained in:
Buster Neece 2024-01-20 17:05:54 -06:00
parent 8c4a4b8a76
commit 6deffe0ca2
No known key found for this signature in database
23 changed files with 649 additions and 596 deletions

View File

@ -49,6 +49,9 @@ return static function (RouteCollectorProxy $group) {
->add(new Middleware\RateLimit('ondemand', 1, 2));
// Podcast Public Pages
$group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction')
->setName('api:stations:podcasts');
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
@ -145,9 +148,6 @@ return static function (RouteCollectorProxy $group) {
$group->group(
'',
function (RouteCollectorProxy $group) {
$group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction')
->setName('api:stations:podcasts');
$group->post(
'/podcasts',
Controller\Api\Stations\PodcastsController::class . ':createAction'

View File

@ -57,20 +57,16 @@ return static function (RouteCollectorProxy $app) {
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
->setName('public:schedule');
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class)
->setName('public:podcasts');
$routes = [
'public:podcasts' => '/podcasts',
'public:podcast' => '/podcast/{podcast_id}',
'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}',
];
$group->get(
'/podcast/{podcast_id}/episodes',
Controller\Frontend\PublicPages\PodcastEpisodesAction::class
)
->setName('public:podcast:episodes');
$group->get(
'/podcast/{podcast_id}/episode/{episode_id}',
Controller\Frontend\PublicPages\PodcastEpisodeAction::class
)
->setName('public:podcast:episode');
foreach ($routes as $routeName => $routePath) {
$group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class)
->setName($routeName);
}
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
->setName('public:podcast:feed');

View File

@ -0,0 +1,180 @@
<template>
<loading
:loading="isLoading"
lazy
>
<div class="card-body">
<div class="d-flex">
<div class="flex-fill">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcasts'}">
{{ $gettext('Podcasts') }}
</router-link>
</li>
<li class="breadcrumb-item">
{{ podcast.title }}
</li>
</ol>
</nav>
<h4 class="card-title mb-1">
{{ podcast.title }}
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+podcast.email"
target="_blank"
>{{ podcast.author }}</a>
</small>
</h4>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ podcast.language_name }}
</span>
<span
v-for="category in podcast.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ podcast.description }}
</p>
<div class="buttons">
<a
class="btn btn-warning btn-sm"
:href="podcast.links.public_feed"
target="_blank"
>
<icon :icon="IconRss" />
{{ $gettext('RSS') }}
</a>
</div>
</div>
<div class="flex-shrink ps-3">
<album-art
:src="podcast.art"
:width="128"
/>
</div>
</div>
</div>
<data-table
id="podcast-episodes"
ref="$datatable"
paginated
:fields="fields"
:api-url="episodesUrl"
>
<template #cell(play_button)="{item}">
<play-button
icon-class="lg"
:url="item.links.download"
/>
</template>
<template #cell(art)="{item}">
<album-art
:src="item.art"
:width="64"
/>
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
<router-link
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
>
{{ item.title }}
</router-link>
</h5>
<div class="badges my-2">
<span
v-if="item.publish_at"
class="badge text-bg-secondary"
>
{{ formatTime(item.publish_at) }}
</span>
<span
v-else
class="badge text-bg-secondary"
>
{{ formatTime(item.created_at) }}
</span>
<span
v-if="item.explicit"
class="badge text-bg-danger"
>
{{ $gettext('Explicit') }}
</span>
</div>
<p class="card-text">
{{ item.description_short }}
</p>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<router-link
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
class="btn btn-primary"
>
{{ $gettext('Details') }}
</router-link>
</div>
</template>
</data-table>
</loading>
</template>
<script setup lang="ts">
import {getStationApiUrl} from "~/router.ts";
import {useRoute} from "vue-router";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
import {useAxios} from "~/vendor/axios.ts";
import Loading from "~/components/Common/Loading.vue";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import {useTranslate} from "~/vendor/gettext.ts";
import {IconRss} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
import PlayButton from "~/components/Common/PlayButton.vue";
import {useLuxon} from "~/vendor/luxon.ts";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts";
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/podcast/${params.podcast_id}`);
const {axios} = useAxios();
const {state: podcast, isLoading} = useRefreshableAsyncState(
() => axios.get(podcastUrl.value).then((r) => r.data),
{},
);
const episodesUrl = getStationApiUrl(`/podcast/${params.podcast_id}/episodes`);
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'play_button', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Episode'), sortable: true},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
const {DateTime} = useLuxon();
const {timezone} = useAzuraCastStation();
const {timeConfig} = useAzuraCast();
const formatTime = (value) => {
if (!value) {
return '';
}
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
};
</script>

View File

@ -0,0 +1,159 @@
<template>
<div class="card-body">
<loading
:loading="podcastLoading || episodeLoading"
lazy
>
<nav aria-label="breadcrumb">
<ol class="breadcrumb m-0">
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcasts'}">
{{ $gettext('Podcasts') }}
</router-link>
</li>
<li class="breadcrumb-item">
<router-link :to="{name: 'public:podcast', params: {podcast_id: podcast.id}}">
{{ podcast.title }}
</router-link>
</li>
<li class="breadcrumb-item">
{{ episode.title }}
</li>
</ol>
</nav>
</loading>
</div>
<div
class="card-body alert alert-secondary"
aria-live="polite"
>
<loading
:loading="podcastLoading"
lazy
>
<h4 class="card-title mb-1">
{{ podcast.title }}
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+podcast.email"
class="alert-link"
target="_blank"
>{{ podcast.author }}</a>
</small>
</h4>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ podcast.language_name }}
</span>
<span
v-for="category in podcast.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ podcast.description }}
</p>
</loading>
</div>
<div class="card-body">
<loading
:loading="episodeLoading"
lazy
>
<div class="d-flex">
<div class="flex-shrink-0 pe-3">
<play-button
icon-class="lg"
:url="episode.links.download"
/>
</div>
<div class="flex-fill">
<h4 class="card-title mb-1">
{{ episode.title }}
</h4>
<div class="badges my-2">
<span
v-if="episode.publish_at"
class="badge text-bg-secondary"
>
{{ formatTime(episode.publish_at) }}
</span>
<span
v-else
class="badge text-bg-secondary"
>
{{ formatTime(episode.created_at) }}
</span>
<span
v-if="episode.explicit"
class="badge text-bg-danger"
>
{{ $gettext('Explicit') }}
</span>
</div>
<p class="card-text">
{{ episode.description }}
</p>
</div>
<div class="flex-shrink-0 ps-3">
<album-art
:src="episode.art"
:width="96"
/>
</div>
</div>
</loading>
</div>
</template>
<script setup lang="ts">
import Loading from "~/components/Common/Loading.vue";
import {useRoute} from "vue-router";
import {getStationApiUrl} from "~/router.ts";
import {useAxios} from "~/vendor/axios.ts";
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
import AlbumArt from "~/components/Common/AlbumArt.vue";
import PlayButton from "~/components/Common/PlayButton.vue";
import {useLuxon} from "~/vendor/luxon.ts";
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts";
const {params} = useRoute();
const podcastUrl = getStationApiUrl(`/podcast/${params.podcast_id}`);
const episodeUrl = getStationApiUrl(`/podcast/${params.podcast_id}/episode/${params.episode_id}`);
const {axios} = useAxios();
const {state: podcast, isLoading: podcastLoading} = useRefreshableAsyncState(
() => axios.get(podcastUrl.value).then((r) => r.data),
{},
);
const {state: episode, isLoading: episodeLoading} = useRefreshableAsyncState(
() => axios.get(episodeUrl.value).then((r) => r.data),
{},
);
const {DateTime} = useLuxon();
const {timezone} = useAzuraCastStation();
const {timeConfig} = useAzuraCast();
const formatTime = (value) => {
if (!value) {
return '';
}
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
{...DateTime.DATETIME_MED, ...timeConfig}
);
};
</script>

View File

@ -0,0 +1,85 @@
<template>
<data-table
id="podcasts"
ref="$datatable"
paginated
:fields="fields"
:api-url="apiUrl"
>
<template #cell(art)="{item}">
<album-art
:src="item.art"
:width="96"
/>
</template>
<template #cell(title)="{item}">
<h5 class="m-0">
<router-link
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
>
{{ item.title }}
</router-link>
<br>
<small>
{{ $gettext('by') }} <a
:href="'mailto:'+item.email"
target="_blank"
>{{ item.author }}</a>
</small>
</h5>
<div class="badges my-2">
<span class="badge text-bg-info">
{{ item.language_name }}
</span>
<span
v-for="category in item.categories"
:key="category.category"
class="badge text-bg-secondary"
>
{{ category.text }}
</span>
</div>
<p class="card-text">
{{ item.description_short }}
</p>
</template>
<template #cell(actions)="{item}">
<div class="btn-group btn-group-sm">
<router-link
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
class="btn btn-primary"
>
{{ $gettext('Episodes') }}
</router-link>
<a
class="btn btn-warning"
:href="item.links.public_feed"
target="_blank"
>
<icon :icon="IconRss" />
{{ $gettext('RSS') }}
</a>
</div>
</template>
</data-table>
</template>
<script setup lang="ts">
import AlbumArt from "~/components/Common/AlbumArt.vue";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import {getStationApiUrl} from "~/router.ts";
import {useTranslate} from "~/vendor/gettext.ts";
import {IconRss} from "~/components/Common/icons.ts";
import Icon from "~/components/Common/Icon.vue";
const apiUrl = getStationApiUrl('/podcasts');
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
{key: 'title', label: $gettext('Podcast'), sortable: true},
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
];
</script>

View File

@ -0,0 +1,32 @@
<template>
<minimal-layout>
<full-height-card>
<template #header>
<div class="d-flex align-items-center">
<div class="flex-shrink">
<h2 class="card-title py-2">
<slot name="title">
{{ name }}
</slot>
</h2>
</div>
<div class="flex-fill text-end">
<inline-player ref="player" />
</div>
</div>
</template>
<template #default>
<router-view />
</template>
</full-height-card>
</minimal-layout>
</template>
<script setup lang="ts">
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
import InlinePlayer from "~/components/InlinePlayer.vue";
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
import MinimalLayout from "~/components/MinimalLayout.vue";
const {name} = useAzuraCastStation();
</script>

View File

@ -0,0 +1,21 @@
import {RouteRecordRaw} from "vue-router";
export default function usePodcastRoutes(): RouteRecordRaw[] {
return [
{
path: '/podcasts',
component: () => import('~/components/Public/Podcasts/PodcastList.vue'),
name: 'public:podcasts'
},
{
path: '/podcast/:podcast_id',
component: () => import('~/components/Public/Podcasts/Podcast.vue'),
name: 'public:podcast'
},
{
path: '/podcast/:podcast_id/episode/:episode_id',
component: () => import('~/components/Public/Podcasts/PodcastEpisode.vue'),
name: 'public:podcast:episode'
}
];
}

View File

@ -49,8 +49,8 @@
<h5 class="m-0">
{{ row.item.name }}
</h5>
<div>
<span class="badge text-bg-secondary me-1">
<div class="badges">
<span class="badge text-bg-secondary">
<template v-if="row.item.source === 'songs'">
{{ $gettext('Song-based') }}
</template>
@ -60,31 +60,31 @@
</span>
<span
v-if="row.item.is_jingle"
class="badge text-bg-primary me-1"
class="badge text-bg-primary"
>
{{ $gettext('Jingle Mode') }}
</span>
<span
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
class="badge text-bg-info me-1"
class="badge text-bg-info"
>
{{ $gettext('Sequential') }}
</span>
<span
v-if="row.item.include_in_on_demand"
class="badge text-bg-info me-1"
class="badge text-bg-info"
>
{{ $gettext('On-Demand') }}
</span>
<span
v-if="row.item.schedule_items.length > 0"
class="badge text-bg-info me-1"
class="badge text-bg-info"
>
{{ $gettext('Scheduled') }}
</span>
<span
v-if="!row.item.is_enabled"
class="badge text-bg-danger me-1"
class="badge text-bg-danger"
>
{{ $gettext('Disabled') }}
</span>

View File

@ -0,0 +1,24 @@
import initApp from "~/layout";
import {h} from "vue";
import {createRouter, createWebHistory} from "vue-router";
import {useAzuraCast} from "~/vendor/azuracast";
import {installRouter} from "~/vendor/router";
import PodcastsLayout from "~/components/Public/Podcasts/PodcastsLayout.vue";
import usePodcastRoutes from "~/components/Public/Podcasts/routes";
initApp({
render() {
return h(PodcastsLayout);
}
}, async (vueApp) => {
const routes = usePodcastRoutes();
const {componentProps} = useAzuraCast();
installRouter(
createRouter({
history: createWebHistory(componentProps.baseUrl),
routes
}),
vueApp
);
});

View File

@ -17,6 +17,7 @@ use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Service\Flow\UploadedFile;
use App\Utilities\Strings;
use App\Utilities\Types;
use InvalidArgumentException;
use OpenApi\Attributes as OA;
@ -222,6 +223,15 @@ final class PodcastEpisodesController extends AbstractApiCrudController
->orderBy('e.created_at', 'DESC')
->setParameter('podcast', $podcast);
$acl = $request->getAcl();
if (!$acl->isAllowed(StationPermissions::Podcasts, $station)) {
$queryBuilder = $queryBuilder
->andWhere('e.publish_at IS NULL OR e.publish_at <= :publishTime')
->setParameter('publishTime', time())
->andWhere('pm.id IS NOT NULL')
->orderBy('e.publish_at', 'DESC');
}
$queryBuilder = $this->searchQueryBuilder(
$request,
$queryBuilder,
@ -309,10 +319,14 @@ final class PodcastEpisodesController extends AbstractApiCrudController
$router = $request->getRouter();
$return = new ApiPodcastEpisode();
$return->id = $record->getId();
$return->id = $record->getIdRequired();
$return->title = $record->getTitle();
$return->description = $record->getDescription();
$return->description_short = Strings::truncateText($return->description, 100);
$return->explicit = $record->getExplicit();
$return->created_at = $record->getCreatedAt();
$return->publish_at = $record->getPublishAt();
$mediaRow = $record->getMedia();

View File

@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations;
use App\Controller\Api\AbstractApiCrudController;
use App\Controller\Api\Traits\CanSearchResults;
use App\Entity\Api\Podcast as ApiPodcast;
use App\Entity\Api\PodcastCategory as ApiPodcastCategory;
use App\Entity\Podcast;
use App\Entity\PodcastCategory;
use App\Entity\Repository\PodcastRepository;
@ -15,10 +16,13 @@ use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use App\Service\Flow\UploadedFile;
use App\Utilities\Strings;
use App\Utilities\Types;
use InvalidArgumentException;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Intl\Exception\MissingResourceException;
use Symfony\Component\Intl\Languages;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@ -239,18 +243,40 @@ final class PodcastsController extends AbstractApiCrudController
$station = $request->getStation();
$return = new ApiPodcast();
$return->id = $record->getId();
$return->storage_location_id = $record->getStorageLocation()->getId();
$return->id = $record->getIdRequired();
$return->storage_location_id = $record->getStorageLocation()->getIdRequired();
$return->title = $record->getTitle();
$return->link = $record->getLink();
$return->description = $record->getDescription();
$return->description_short = Strings::truncateText($return->description, 200);
$return->language = $record->getLanguage();
try {
$locale = $request->getCustomization()->getLocale();
$return->language_name = Languages::getName(
$return->language,
$locale->value
);
} catch (MissingResourceException) {
}
$return->author = $record->getAuthor();
$return->email = $record->getEmail();
$categories = [];
foreach ($record->getCategories() as $category) {
$categories[] = $category->getCategory();
$categoryRow = new ApiPodcastCategory();
$categoryRow->category = $category->getCategory();
$categoryRow->title = $category->getTitle();
$categoryRow->subtitle = $category->getSubTitle();
$categoryRow->text = (!empty($categoryRow->subtitle))
? $categoryRow->title . ' - ' . $categoryRow->subtitle
: $categoryRow->title;
$categories[] = $categoryRow;
}
$return->categories = $categories;
@ -287,7 +313,7 @@ final class PodcastsController extends AbstractApiCrudController
absolute: !$isInternal
),
'public_episodes' => $router->fromHere(
routeName: 'public:podcast:episodes',
routeName: 'public:podcast',
routeParams: ['podcast_id' => $record->getId()],
absolute: !$isInternal
),

View File

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Controller\SingleActionInterface;
use App\Entity\PodcastEpisode;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Entity\Repository\PodcastRepository;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class PodcastEpisodeAction implements SingleActionInterface
{
public function __construct(
private readonly PodcastRepository $podcastRepository,
private readonly PodcastEpisodeRepository $episodeRepository
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
array $params
): ResponseInterface {
/** @var string $podcastId */
$podcastId = $params['podcast_id'];
/** @var string $episodeId */
$episodeId = $params['episode_id'];
$router = $request->getRouter();
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw NotFoundException::station();
}
$podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId);
if ($podcast === null) {
throw NotFoundException::podcast();
}
$episode = $this->episodeRepository->fetchEpisodeForStation($station, $episodeId);
$podcastEpisodesLink = $router->named(
'public:podcast:episodes',
[
'station_id' => $station->getId(),
'podcast_id' => $podcastId,
]
);
if (!($episode instanceof PodcastEpisode) || !$episode->isPublished()) {
$request->getFlash()->error(__('Episode not found.'));
return $response->withRedirect($podcastEpisodesLink);
}
$feedLink = $router->named(
'public:podcast:feed',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
]
);
return $request->getView()->renderToResponse(
$response
->withHeader('X-Frame-Options', '*')
->withHeader('X-Robots-Tag', 'index, nofollow'),
'frontend/public/podcast-episode',
[
'episode' => $episode,
'feedLink' => $feedLink,
'podcast' => $podcast,
'podcastEpisodesLink' => $podcastEpisodesLink,
'station' => $station,
]
);
}
}

View File

@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Controller\SingleActionInterface;
use App\Entity\PodcastEpisode;
use App\Entity\Repository\PodcastEpisodeRepository;
use App\Entity\Repository\PodcastRepository;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class PodcastEpisodesAction implements SingleActionInterface
{
public function __construct(
private readonly PodcastRepository $podcastRepository,
private readonly PodcastEpisodeRepository $episodeRepository
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
array $params
): ResponseInterface {
/** @var string $podcastId */
$podcastId = $params['podcast_id'];
$router = $request->getRouter();
$station = $request->getStation();
if (!$station->getEnablePublicPage()) {
throw NotFoundException::station();
}
$podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId);
if ($podcast === null) {
throw NotFoundException::podcast();
}
$publishedEpisodes = $this->episodeRepository->fetchPublishedEpisodesForPodcast($podcast);
// Reverse sort order according to the calculated publishing timestamp
usort(
$publishedEpisodes,
static function ($prevEpisode, $nextEpisode) {
/** @var PodcastEpisode $prevEpisode */
/** @var PodcastEpisode $nextEpisode */
$prevPublishedAt = $prevEpisode->getPublishAt() ?? $prevEpisode->getCreatedAt();
$nextPublishedAt = $nextEpisode->getPublishAt() ?? $nextEpisode->getCreatedAt();
return ($nextPublishedAt <=> $prevPublishedAt);
}
);
$podcastsLink = $router->fromHere(
'public:podcasts',
[
'station_id' => $station->getId(),
]
);
if (count($publishedEpisodes) === 0) {
$request->getFlash()->error(__('No episodes found.'));
return $response->withRedirect($podcastsLink);
}
$feedLink = $router->named(
'public:podcast:feed',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
]
);
return $request->getView()->renderToResponse(
$response
->withHeader('X-Frame-Options', '*')
->withHeader('X-Robots-Tag', 'index, nofollow'),
'frontend/public/podcast-episodes',
[
'episodes' => $publishedEpisodes,
'feedLink' => $feedLink,
'podcast' => $podcast,
'podcastsLink' => $podcastsLink,
'station' => $station,
]
);
}
}

View File

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Controller\Frontend\PublicPages\Traits\IsEmbeddable;
use App\Controller\SingleActionInterface;
use App\Entity\Repository\PodcastRepository;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
@ -13,10 +13,7 @@ use Psr\Http\Message\ResponseInterface;
final class PodcastsAction implements SingleActionInterface
{
public function __construct(
private readonly PodcastRepository $podcastRepository
) {
}
use IsEmbeddable;
public function __invoke(
ServerRequest $request,
@ -29,17 +26,37 @@ final class PodcastsAction implements SingleActionInterface
throw NotFoundException::station();
}
$publishedPodcasts = $this->podcastRepository->fetchPublishedPodcastsForStation($station);
$pageClass = 'podcasts station-' . $station->getShortName();
if ($this->isEmbedded($request, $params)) {
$pageClass .= ' embed';
}
return $request->getView()->renderToResponse(
$response
$router = $request->getRouter();
$view = $request->getView();
// Add station public code.
$view->fetch(
'frontend/public/partials/station-custom',
['station' => $station]
);
return $view->renderVuePage(
response: $response
->withHeader('X-Frame-Options', '*')
->withHeader('X-Robots-Tag', 'index, nofollow'),
'frontend/public/podcasts',
[
'podcasts' => $publishedPodcasts,
'station' => $station,
]
component: 'Public/Podcasts',
id: 'podcast',
layout: 'minimal',
title: 'Podcasts - ' . $station->getName(),
layoutParams: [
'page_class' => $pageClass,
'hide_footer' => true,
],
props: [
'baseUrl' => $router->named('public:index', [
'station_id' => $station->getShortName(),
]),
],
);
}
}

View File

@ -16,41 +16,50 @@ final class Podcast
use HasLinks;
#[OA\Property]
public ?string $id = null;
public string $id;
#[OA\Property]
public ?int $storage_location_id = null;
public int $storage_location_id;
#[OA\Property]
public ?string $title = null;
public string $title;
#[OA\Property]
public ?string $link = null;
#[OA\Property]
public ?string $description = null;
public string $description;
#[OA\Property]
public ?string $language = null;
public string $description_short;
#[OA\Property]
public ?string $author = null;
public string $language;
#[OA\Property]
public ?string $email = null;
public string $language_name;
#[OA\Property]
public string $author;
#[OA\Property]
public string $email;
#[OA\Property]
public bool $has_custom_art = false;
#[OA\Property]
public ?string $art = null;
public string $art;
#[OA\Property]
public int $art_updated_at = 0;
/**
* @var PodcastCategory[]
*/
#[OA\Property(
type: 'array',
items: new OA\Items(type: 'string')
items: new OA\Items(type: PodcastCategory::class)
)]
public array $categories = [];

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Entity\Api;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Api_PodcastCategory',
type: 'object'
)]
final class PodcastCategory
{
#[OA\Property]
public string $category;
#[OA\Property]
public string $text;
#[OA\Property]
public string $title;
#[OA\Property]
public ?string $subtitle = null;
}

View File

@ -16,17 +16,23 @@ final class PodcastEpisode
use HasLinks;
#[OA\Property]
public ?string $id = null;
public string $id;
#[OA\Property]
public ?string $title = null;
public string $title;
#[OA\Property]
public ?string $description = null;
public string $description;
#[OA\Property]
public string $description_short;
#[OA\Property]
public bool $explicit = false;
#[OA\Property]
public int $created_at;
#[OA\Property]
public ?int $publish_at = null;

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity\Podcast;
use App\Entity\PodcastEpisode;
use App\Entity\PodcastMedia;
use App\Entity\Station;
@ -14,7 +13,7 @@ use App\Exception\StorageLocationFullException;
use App\Flysystem\ExtendedFilesystemInterface;
use App\Media\AlbumArt;
use App\Media\MetadataManager;
use http\Exception\InvalidArgumentException;
use InvalidArgumentException;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToRetrieveMetadata;
@ -56,28 +55,6 @@ final class PodcastEpisodeRepository extends Repository
->getOneOrNullResult();
}
/**
* @return PodcastEpisode[]
*/
public function fetchPublishedEpisodesForPodcast(Podcast $podcast): array
{
$episodes = $this->em->createQueryBuilder()
->select('pe')
->from(PodcastEpisode::class, 'pe')
->where('pe.podcast = :podcast')
->setParameter('podcast', $podcast)
->orderBy('pe.created_at', 'DESC')
->getQuery()
->getResult();
return array_filter(
$episodes,
static function (PodcastEpisode $episode) {
return $episode->isPublished();
}
);
}
public function writeEpisodeArt(
PodcastEpisode $episode,
string $rawArtworkString

View File

@ -44,35 +44,6 @@ final class PodcastRepository extends Repository
);
}
/**
* @return Podcast[]
*/
public function fetchPublishedPodcastsForStation(Station $station): array
{
$podcasts = $this->em->createQuery(
<<<'DQL'
SELECT p, pe
FROM App\Entity\Podcast p
LEFT JOIN p.episodes pe
WHERE p.storage_location = :storageLocation
DQL
)->setParameter('storageLocation', $station->getPodcastsStorageLocation())
->getResult();
return array_filter(
$podcasts,
static function (Podcast $podcast) {
foreach ($podcast->getEpisodes() as $episode) {
if ($episode->isPublished()) {
return true;
}
}
return false;
}
);
}
public function writePodcastArt(
Podcast $podcast,
string $rawArtworkString,

View File

@ -1,95 +0,0 @@
<?php
/**
* @var \App\Http\RouterInterface $router
* @var \App\View\GlobalSections $sections
*/
use Carbon\CarbonImmutable;
$this->layout('minimal', [
'page_class' => 'podcasts station-' . $station->getShortName(),
'title' => 'Podcasts - ' . $station->getName(),
'hide_footer' => true,
]);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
$episodeAudioSrc = $router->named(
'api:stations:podcast:episode:download',
[
'station_id' => $station->getId(),
'podcast_id' => $episode->getPodcast()->getId(),
'episode_id' => $episode->getId(),
],
[],
true
);
$publishedAt = CarbonImmutable::createFromTimestamp($episode->getCreatedAt());
if ($episode->getPublishAt() !== null) {
$publishedAt = CarbonImmutable::createFromTimestamp($episode->getPublishAt());
}
$sections->append(
'head',
<<<HTML
<link rel="alternate" type="application/rss+xml" title="{$this->e($podcast->getTitle())}" href="{$feedLink}">
HTML
);
?>
<div class="public-page">
<section class="card" role="region">
<div class="card-body">
<div class="d-flex mb-3">
<div class="flex-fill">
<h1 class="card-title mb-1">
<?= $this->e($podcast->getTitle()) ?>
</h1>
<h2 class="card-subtitle mb-3">
<?= $this->e($episode->getTitle()) ?>
</h2>
<div class="mb-3">
<span class="badge text-bg-dark">
<?= $publishedAt->format('d. M. Y') ?>
</span>
<?php
if ($episode->getExplicit()) : ?>
<span class="badge text-bg-danger"><?= __('Explicit') ?></span>
<?php
endif; ?>
</div>
<p class="card-text"><?= $this->e($episode->getDescription()) ?></p>
</div>
<div class="flex-shrink-0 ps-2" style="max-width: 128px;">
<img src="<?= $router->named(
'api:stations:podcast:episode:art',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
'episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt(),
]
); ?>" class="card-img img-fluid"
alt="<?= $this->e($podcast->getTitle()) ?>">
</div>
</div>
<div class="mb-3">
<audio src="<?= $episodeAudioSrc ?>" controls style="width: 100%;"></audio>
</div>
<div class="buttons">
<a href="<?= $podcastEpisodesLink ?>" class="btn btn-sm btn-primary">
<span><?= __('Back') ?></span>
</a>
<a href="<?= $feedLink ?>" class="btn btn-sm btn-warning" target="_blank">
<?= $this->fetch('icons/rss') ?>
<span><?= __('RSS Feed') ?></span>
</a>
</div>
</div>
</section>
</div>

View File

@ -1,116 +0,0 @@
<?php
/**
* @var \App\Http\RouterInterface $router
* @var \App\View\GlobalSections $sections
*/
use Carbon\CarbonImmutable;
$this->layout(
'minimal',
[
'page_class' => 'podcasts station-' . $station->getShortName(),
'title' => 'Podcasts - ' . $station->getName(),
'hide_footer' => true,
]
);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
$sections->append(
'head',
<<<HTML
<link rel="alternate" type="application/rss+xml" title="{$this->e($podcast->getTitle())}" href="{$feedLink}">
HTML
);
?>
<div class="public-page">
<section class="card" role="region">
<div class="card-body">
<h1 class="card-title mb-1">
<?= $this->e($podcast->getTitle()) ?>
</h1>
<h2 class="card-subtitle mb-3">
<?= __('Episodes') ?>
</h2>
<div class="buttons mb-3">
<a href="<?= $podcastsLink ?>" class="btn btn-sm btn-primary">
<span><?= __('Back') ?></span>
</a>
<a href="<?= $feedLink ?>" class="btn btn-sm btn-warning" target="_blank">
<?= $this->fetch('icons/rss') ?>
<span><?= __('RSS Feed') ?></span>
</a>
</div>
<?php
/** @var App\Entity\PodcastEpisode $episode */
foreach ($episodes as $episode) : ?>
<?php
$episodePageLink = $router->named(
'public:podcast:episode',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
'episode_id' => $episode->getId(),
]
) ?>
<div class="d-flex align-items-center mb-3">
<div class="flex-shrink-0 pe-2" style="max-width: 128px">
<a href="<?= $this->e($episodePageLink) ?>" title="<?= __('View Details') ?>">
<img src="<?= $router->named(
'api:stations:podcast:episode:art',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
'episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt(),
]
); ?>" class="card-img img-fluid" alt="<?= $this->e($podcast->getTitle()) ?>">
</a>
</div>
<div class="flex-fill">
<h5 class="card-title"><?= $this->e($episode->getTitle()) ?></h5>
<p class="card-text"><?= $this->e($episode->getDescription()) ?></p>
<?php
if ($episode->getExplicit()) : ?>
<p class="card-text">
<small class="text-warning-emphasis"><?= __('Contains explicit content') ?></small>
</p>
<?php
endif; ?>
<p class="card-text">
<?php
$publishedAt = CarbonImmutable::createFromTimestamp(
$episode->getCreatedAt()
); ?>
<?php
if ($episode->getPublishAt() !== null) : ?>
<?php
$publishedAt = CarbonImmutable::createFromTimestamp(
$episode->getPublishAt()
); ?>
<?php
endif; ?>
<span class="badge badge-pill badge-dark" data-toggle="tooltip"
data-placement="right" data-html="true"
title="<?= $publishedAt->format(
'H:i'
) ?>"><?= $publishedAt->format('d. M. Y') ?></span>
</p>
<div class="block-buttons">
<a href="<?= $this->e($episodePageLink) ?>" class="btn btn-primary">
<?= __('View Details') ?>
</a>
</div>
</div>
</div>
<?php
endforeach; ?>
</div>
</section>
</div>

View File

@ -1,92 +0,0 @@
<?php
$this->layout('minimal', [
'page_class' => 'podcasts station-' . $station->getShortName(),
'title' => 'Podcasts - ' . $station->getName(),
'hide_footer' => true,
]);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
?>
<div class="public-page">
<section class="card" role="region">
<div class="card-body">
<h1 class="card-title mb-3">
<?= $this->e($station->getName()) ?>
</h1>
<?php
/** @var App\Entity\Podcast $podcast */
foreach ($podcasts as $podcast) : ?>
<?php
$episodesPageLink = (string)$router->named(
'public:podcast:episodes',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
]
) ?>
<?php
$feedLink = (string)$router->named(
'public:podcast:feed',
['station_id' => $station->getId(), 'podcast_id' => $podcast->getId()]
) ?>
<div class="d-flex align-items-center mb-3">
<div class="flex-shrink-0 pe-2" style="max-width: 128px">
<a href="<?= $this->e($episodesPageLink) ?>" title="<?= __('Episodes') ?>">
<img src="<?= $router->named(
'api:stations:podcast:art',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
]
); ?>" class="card-img img-fluid" alt="<?= $this->e($podcast->getTitle()) ?>">
</a>
</div>
<div class="d-fill">
<h5 class="card-subtitle"><?= $this->e($podcast->getTitle()) ?></h5>
<p class="card-text"><?= $this->e($podcast->getDescription()) ?></p>
<p class="card-text">
<small class="text-muted"><?= __('Language') ?>: <?= strtoupper(
$podcast->getLanguage()
) ?></small>
<br/>
<small class="text-muted"><?= __('Categories') ?>: <?= implode(
$podcast->getCategories()->map(
function ($category) {
$title = $category->getTitle();
$subtitle = $category->getSubTitle();
return (!empty($subtitle))
? $title . ' - ' . $subtitle
: $title;
}
)->getValues()
); ?></small>
</p>
<div class="buttons">
<a href="<?= $episodesPageLink ?>" class="btn btn-sm btn-primary">
<?= __('Episodes') ?>
</a>
<a href="<?= $feedLink ?>" class="btn btn-sm btn-warning"
target="_blank">
<?= $this->fetch('icons/rss') ?>
<span><?= __('RSS Feed') ?></span>
</a>
</div>
</div>
</div>
<?php
endforeach; ?>
<?php
if (count($podcasts) === 0) : ?>
<p class="card-text"><?= __('No entries found.') ?></p>
<?php
endif; ?>
</div>
</section>
</div>

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"
class="icon <?= $class ?? null ?>"
fill="currentColor"
focusable="false"
aria-hidden="true">
<path d="M188-109q-40 0-69-28.5T90-207q0-40 29-69t69-29q40 0 69 29t29 69q0 41-29 69.5T188-109Zm532 0q0-133-48.5-248t-133-200Q454-642 339.091-690.5 224.182-739 90-739v-110q156 0 291 57.5T615.5-634q99.5 100 157 235T830-109H720Zm-278 0q0-164-93.5-262T90-469v-110q104 0 189 34.5t145.5 97q60.5 62.5 94 149T552-109H442Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 529 B