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:
parent
8c4a4b8a76
commit
6deffe0ca2
|
@ -49,6 +49,9 @@ return static function (RouteCollectorProxy $group) {
|
||||||
->add(new Middleware\RateLimit('ondemand', 1, 2));
|
->add(new Middleware\RateLimit('ondemand', 1, 2));
|
||||||
|
|
||||||
// Podcast Public Pages
|
// Podcast Public Pages
|
||||||
|
$group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction')
|
||||||
|
->setName('api:stations:podcasts');
|
||||||
|
|
||||||
$group->group(
|
$group->group(
|
||||||
'/podcast/{podcast_id}',
|
'/podcast/{podcast_id}',
|
||||||
function (RouteCollectorProxy $group) {
|
function (RouteCollectorProxy $group) {
|
||||||
|
@ -145,9 +148,6 @@ return static function (RouteCollectorProxy $group) {
|
||||||
$group->group(
|
$group->group(
|
||||||
'',
|
'',
|
||||||
function (RouteCollectorProxy $group) {
|
function (RouteCollectorProxy $group) {
|
||||||
$group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction')
|
|
||||||
->setName('api:stations:podcasts');
|
|
||||||
|
|
||||||
$group->post(
|
$group->post(
|
||||||
'/podcasts',
|
'/podcasts',
|
||||||
Controller\Api\Stations\PodcastsController::class . ':createAction'
|
Controller\Api\Stations\PodcastsController::class . ':createAction'
|
||||||
|
|
|
@ -57,20 +57,16 @@ return static function (RouteCollectorProxy $app) {
|
||||||
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
|
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
|
||||||
->setName('public:schedule');
|
->setName('public:schedule');
|
||||||
|
|
||||||
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class)
|
$routes = [
|
||||||
->setName('public:podcasts');
|
'public:podcasts' => '/podcasts',
|
||||||
|
'public:podcast' => '/podcast/{podcast_id}',
|
||||||
|
'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}',
|
||||||
|
];
|
||||||
|
|
||||||
$group->get(
|
foreach ($routes as $routeName => $routePath) {
|
||||||
'/podcast/{podcast_id}/episodes',
|
$group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class)
|
||||||
Controller\Frontend\PublicPages\PodcastEpisodesAction::class
|
->setName($routeName);
|
||||||
)
|
}
|
||||||
->setName('public:podcast:episodes');
|
|
||||||
|
|
||||||
$group->get(
|
|
||||||
'/podcast/{podcast_id}/episode/{episode_id}',
|
|
||||||
Controller\Frontend\PublicPages\PodcastEpisodeAction::class
|
|
||||||
)
|
|
||||||
->setName('public:podcast:episode');
|
|
||||||
|
|
||||||
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
|
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
|
||||||
->setName('public:podcast:feed');
|
->setName('public:podcast:feed');
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
|
@ -49,8 +49,8 @@
|
||||||
<h5 class="m-0">
|
<h5 class="m-0">
|
||||||
{{ row.item.name }}
|
{{ row.item.name }}
|
||||||
</h5>
|
</h5>
|
||||||
<div>
|
<div class="badges">
|
||||||
<span class="badge text-bg-secondary me-1">
|
<span class="badge text-bg-secondary">
|
||||||
<template v-if="row.item.source === 'songs'">
|
<template v-if="row.item.source === 'songs'">
|
||||||
{{ $gettext('Song-based') }}
|
{{ $gettext('Song-based') }}
|
||||||
</template>
|
</template>
|
||||||
|
@ -60,31 +60,31 @@
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.is_jingle"
|
v-if="row.item.is_jingle"
|
||||||
class="badge text-bg-primary me-1"
|
class="badge text-bg-primary"
|
||||||
>
|
>
|
||||||
{{ $gettext('Jingle Mode') }}
|
{{ $gettext('Jingle Mode') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
|
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
|
||||||
class="badge text-bg-info me-1"
|
class="badge text-bg-info"
|
||||||
>
|
>
|
||||||
{{ $gettext('Sequential') }}
|
{{ $gettext('Sequential') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.include_in_on_demand"
|
v-if="row.item.include_in_on_demand"
|
||||||
class="badge text-bg-info me-1"
|
class="badge text-bg-info"
|
||||||
>
|
>
|
||||||
{{ $gettext('On-Demand') }}
|
{{ $gettext('On-Demand') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.schedule_items.length > 0"
|
v-if="row.item.schedule_items.length > 0"
|
||||||
class="badge text-bg-info me-1"
|
class="badge text-bg-info"
|
||||||
>
|
>
|
||||||
{{ $gettext('Scheduled') }}
|
{{ $gettext('Scheduled') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="!row.item.is_enabled"
|
v-if="!row.item.is_enabled"
|
||||||
class="badge text-bg-danger me-1"
|
class="badge text-bg-danger"
|
||||||
>
|
>
|
||||||
{{ $gettext('Disabled') }}
|
{{ $gettext('Disabled') }}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
||||||
|
});
|
|
@ -17,6 +17,7 @@ use App\Http\Response;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
use App\OpenApi;
|
use App\OpenApi;
|
||||||
use App\Service\Flow\UploadedFile;
|
use App\Service\Flow\UploadedFile;
|
||||||
|
use App\Utilities\Strings;
|
||||||
use App\Utilities\Types;
|
use App\Utilities\Types;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
@ -222,6 +223,15 @@ final class PodcastEpisodesController extends AbstractApiCrudController
|
||||||
->orderBy('e.created_at', 'DESC')
|
->orderBy('e.created_at', 'DESC')
|
||||||
->setParameter('podcast', $podcast);
|
->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(
|
$queryBuilder = $this->searchQueryBuilder(
|
||||||
$request,
|
$request,
|
||||||
$queryBuilder,
|
$queryBuilder,
|
||||||
|
@ -309,10 +319,14 @@ final class PodcastEpisodesController extends AbstractApiCrudController
|
||||||
$router = $request->getRouter();
|
$router = $request->getRouter();
|
||||||
|
|
||||||
$return = new ApiPodcastEpisode();
|
$return = new ApiPodcastEpisode();
|
||||||
$return->id = $record->getId();
|
$return->id = $record->getIdRequired();
|
||||||
$return->title = $record->getTitle();
|
$return->title = $record->getTitle();
|
||||||
|
|
||||||
$return->description = $record->getDescription();
|
$return->description = $record->getDescription();
|
||||||
|
$return->description_short = Strings::truncateText($return->description, 100);
|
||||||
|
|
||||||
$return->explicit = $record->getExplicit();
|
$return->explicit = $record->getExplicit();
|
||||||
|
$return->created_at = $record->getCreatedAt();
|
||||||
$return->publish_at = $record->getPublishAt();
|
$return->publish_at = $record->getPublishAt();
|
||||||
|
|
||||||
$mediaRow = $record->getMedia();
|
$mediaRow = $record->getMedia();
|
||||||
|
|
|
@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations;
|
||||||
use App\Controller\Api\AbstractApiCrudController;
|
use App\Controller\Api\AbstractApiCrudController;
|
||||||
use App\Controller\Api\Traits\CanSearchResults;
|
use App\Controller\Api\Traits\CanSearchResults;
|
||||||
use App\Entity\Api\Podcast as ApiPodcast;
|
use App\Entity\Api\Podcast as ApiPodcast;
|
||||||
|
use App\Entity\Api\PodcastCategory as ApiPodcastCategory;
|
||||||
use App\Entity\Podcast;
|
use App\Entity\Podcast;
|
||||||
use App\Entity\PodcastCategory;
|
use App\Entity\PodcastCategory;
|
||||||
use App\Entity\Repository\PodcastRepository;
|
use App\Entity\Repository\PodcastRepository;
|
||||||
|
@ -15,10 +16,13 @@ use App\Http\Response;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
use App\OpenApi;
|
use App\OpenApi;
|
||||||
use App\Service\Flow\UploadedFile;
|
use App\Service\Flow\UploadedFile;
|
||||||
|
use App\Utilities\Strings;
|
||||||
use App\Utilities\Types;
|
use App\Utilities\Types;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Symfony\Component\Intl\Exception\MissingResourceException;
|
||||||
|
use Symfony\Component\Intl\Languages;
|
||||||
use Symfony\Component\Serializer\Serializer;
|
use Symfony\Component\Serializer\Serializer;
|
||||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
@ -239,18 +243,40 @@ final class PodcastsController extends AbstractApiCrudController
|
||||||
$station = $request->getStation();
|
$station = $request->getStation();
|
||||||
|
|
||||||
$return = new ApiPodcast();
|
$return = new ApiPodcast();
|
||||||
$return->id = $record->getId();
|
$return->id = $record->getIdRequired();
|
||||||
$return->storage_location_id = $record->getStorageLocation()->getId();
|
$return->storage_location_id = $record->getStorageLocation()->getIdRequired();
|
||||||
|
|
||||||
$return->title = $record->getTitle();
|
$return->title = $record->getTitle();
|
||||||
$return->link = $record->getLink();
|
$return->link = $record->getLink();
|
||||||
|
|
||||||
$return->description = $record->getDescription();
|
$return->description = $record->getDescription();
|
||||||
|
$return->description_short = Strings::truncateText($return->description, 200);
|
||||||
|
|
||||||
$return->language = $record->getLanguage();
|
$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->author = $record->getAuthor();
|
||||||
$return->email = $record->getEmail();
|
$return->email = $record->getEmail();
|
||||||
|
|
||||||
$categories = [];
|
$categories = [];
|
||||||
foreach ($record->getCategories() as $category) {
|
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;
|
$return->categories = $categories;
|
||||||
|
|
||||||
|
@ -287,7 +313,7 @@ final class PodcastsController extends AbstractApiCrudController
|
||||||
absolute: !$isInternal
|
absolute: !$isInternal
|
||||||
),
|
),
|
||||||
'public_episodes' => $router->fromHere(
|
'public_episodes' => $router->fromHere(
|
||||||
routeName: 'public:podcast:episodes',
|
routeName: 'public:podcast',
|
||||||
routeParams: ['podcast_id' => $record->getId()],
|
routeParams: ['podcast_id' => $record->getId()],
|
||||||
absolute: !$isInternal
|
absolute: !$isInternal
|
||||||
),
|
),
|
||||||
|
|
|
@ -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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller\Frontend\PublicPages;
|
namespace App\Controller\Frontend\PublicPages;
|
||||||
|
|
||||||
|
use App\Controller\Frontend\PublicPages\Traits\IsEmbeddable;
|
||||||
use App\Controller\SingleActionInterface;
|
use App\Controller\SingleActionInterface;
|
||||||
use App\Entity\Repository\PodcastRepository;
|
|
||||||
use App\Exception\NotFoundException;
|
use App\Exception\NotFoundException;
|
||||||
use App\Http\Response;
|
use App\Http\Response;
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
|
@ -13,10 +13,7 @@ use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
final class PodcastsAction implements SingleActionInterface
|
final class PodcastsAction implements SingleActionInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
use IsEmbeddable;
|
||||||
private readonly PodcastRepository $podcastRepository
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __invoke(
|
public function __invoke(
|
||||||
ServerRequest $request,
|
ServerRequest $request,
|
||||||
|
@ -29,17 +26,37 @@ final class PodcastsAction implements SingleActionInterface
|
||||||
throw NotFoundException::station();
|
throw NotFoundException::station();
|
||||||
}
|
}
|
||||||
|
|
||||||
$publishedPodcasts = $this->podcastRepository->fetchPublishedPodcastsForStation($station);
|
$pageClass = 'podcasts station-' . $station->getShortName();
|
||||||
|
if ($this->isEmbedded($request, $params)) {
|
||||||
|
$pageClass .= ' embed';
|
||||||
|
}
|
||||||
|
|
||||||
return $request->getView()->renderToResponse(
|
$router = $request->getRouter();
|
||||||
$response
|
$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-Frame-Options', '*')
|
||||||
->withHeader('X-Robots-Tag', 'index, nofollow'),
|
->withHeader('X-Robots-Tag', 'index, nofollow'),
|
||||||
'frontend/public/podcasts',
|
component: 'Public/Podcasts',
|
||||||
[
|
id: 'podcast',
|
||||||
'podcasts' => $publishedPodcasts,
|
layout: 'minimal',
|
||||||
'station' => $station,
|
title: 'Podcasts - ' . $station->getName(),
|
||||||
]
|
layoutParams: [
|
||||||
|
'page_class' => $pageClass,
|
||||||
|
'hide_footer' => true,
|
||||||
|
],
|
||||||
|
props: [
|
||||||
|
'baseUrl' => $router->named('public:index', [
|
||||||
|
'station_id' => $station->getShortName(),
|
||||||
|
]),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,41 +16,50 @@ final class Podcast
|
||||||
use HasLinks;
|
use HasLinks;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $id = null;
|
public string $id;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?int $storage_location_id = null;
|
public int $storage_location_id;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $title = null;
|
public string $title;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $link = null;
|
public ?string $link = null;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $description = null;
|
public string $description;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $language = null;
|
public string $description_short;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $author = null;
|
public string $language;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $email = null;
|
public string $language_name;
|
||||||
|
|
||||||
|
#[OA\Property]
|
||||||
|
public string $author;
|
||||||
|
|
||||||
|
#[OA\Property]
|
||||||
|
public string $email;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public bool $has_custom_art = false;
|
public bool $has_custom_art = false;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $art = null;
|
public string $art;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public int $art_updated_at = 0;
|
public int $art_updated_at = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var PodcastCategory[]
|
||||||
|
*/
|
||||||
#[OA\Property(
|
#[OA\Property(
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: new OA\Items(type: 'string')
|
items: new OA\Items(type: PodcastCategory::class)
|
||||||
)]
|
)]
|
||||||
public array $categories = [];
|
public array $categories = [];
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -16,17 +16,23 @@ final class PodcastEpisode
|
||||||
use HasLinks;
|
use HasLinks;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $id = null;
|
public string $id;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $title = null;
|
public string $title;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?string $description = null;
|
public string $description;
|
||||||
|
|
||||||
|
#[OA\Property]
|
||||||
|
public string $description_short;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public bool $explicit = false;
|
public bool $explicit = false;
|
||||||
|
|
||||||
|
#[OA\Property]
|
||||||
|
public int $created_at;
|
||||||
|
|
||||||
#[OA\Property]
|
#[OA\Property]
|
||||||
public ?int $publish_at = null;
|
public ?int $publish_at = null;
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||||
namespace App\Entity\Repository;
|
namespace App\Entity\Repository;
|
||||||
|
|
||||||
use App\Doctrine\Repository;
|
use App\Doctrine\Repository;
|
||||||
use App\Entity\Podcast;
|
|
||||||
use App\Entity\PodcastEpisode;
|
use App\Entity\PodcastEpisode;
|
||||||
use App\Entity\PodcastMedia;
|
use App\Entity\PodcastMedia;
|
||||||
use App\Entity\Station;
|
use App\Entity\Station;
|
||||||
|
@ -14,7 +13,7 @@ use App\Exception\StorageLocationFullException;
|
||||||
use App\Flysystem\ExtendedFilesystemInterface;
|
use App\Flysystem\ExtendedFilesystemInterface;
|
||||||
use App\Media\AlbumArt;
|
use App\Media\AlbumArt;
|
||||||
use App\Media\MetadataManager;
|
use App\Media\MetadataManager;
|
||||||
use http\Exception\InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use League\Flysystem\UnableToDeleteFile;
|
use League\Flysystem\UnableToDeleteFile;
|
||||||
use League\Flysystem\UnableToRetrieveMetadata;
|
use League\Flysystem\UnableToRetrieveMetadata;
|
||||||
|
|
||||||
|
@ -56,28 +55,6 @@ final class PodcastEpisodeRepository extends Repository
|
||||||
->getOneOrNullResult();
|
->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(
|
public function writeEpisodeArt(
|
||||||
PodcastEpisode $episode,
|
PodcastEpisode $episode,
|
||||||
string $rawArtworkString
|
string $rawArtworkString
|
||||||
|
|
|
@ -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(
|
public function writePodcastArt(
|
||||||
Podcast $podcast,
|
Podcast $podcast,
|
||||||
string $rawArtworkString,
|
string $rawArtworkString,
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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 |
Loading…
Reference in New Issue