Add per-station branding support.

This commit is contained in:
Buster Neece 2023-01-03 16:55:49 -06:00
parent 71510ee4a4
commit a5bf63ed49
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
37 changed files with 831 additions and 86 deletions

View File

@ -5,6 +5,9 @@ release channel, you can take advantage of these new features and fixes.
## New Features/Changes
- **Per-Station Branding**: You can now provide custom album art, public page backgrounds, CSS and JavaScript on a
per-station basis, using a new Station Branding page that is very similar to the system-wide Branding page.
## Code Quality/Technical Changes
- Redis was removed in version 0.17.6 in order to yield fewer running tasks on servers by default; we have noticed that,

View File

@ -26,15 +26,33 @@ return static function (RouteCollectorProxy $group) {
->setName('api:stations:profile')
->add(new Middleware\Permissions(StationPermissions::View, true));
$group->get(
'/profile/edit',
Controller\Api\Stations\ProfileEditController::class . ':getProfileAction'
)->setName('api:stations:profile:edit')
->add(new Middleware\Permissions(StationPermissions::Profile, true));
$group->group(
'',
function (RouteCollectorProxy $group) {
$group->get(
'/profile/edit',
Controller\Api\Stations\ProfileEditController::class . ':getProfileAction'
)->setName('api:stations:profile:edit');
$group->put(
'/profile/edit',
Controller\Api\Stations\ProfileEditController::class . ':putProfileAction'
$group->put(
'/profile/edit',
Controller\Api\Stations\ProfileEditController::class . ':putProfileAction'
);
$group->get(
'/custom_assets/{type}',
Controller\Api\Stations\CustomAssets\GetCustomAssetAction::class
)->setName('api:stations:custom_assets');
$group->post(
'/custom_assets/{type}',
Controller\Api\Stations\CustomAssets\PostCustomAssetAction::class
);
$group->delete(
'/custom_assets/{type}',
Controller\Api\Stations\CustomAssets\DeleteCustomAssetAction::class
);
}
)->add(new Middleware\Permissions(StationPermissions::Profile, true));
$group->get('/schedule', Controller\Api\Stations\ScheduleAction::class)

View File

@ -21,6 +21,10 @@ return static function (RouteCollectorProxy $app) {
}
)->setName('stations:index:index');
$group->get('/branding', Controller\Stations\BrandingAction::class)
->setName('stations:branding')
->add(new Middleware\Permissions(StationPermissions::Profile, true));
$group->get('/bulk-media', Controller\Stations\BulkMediaAction::class)
->setName('stations:bulk-media')
->add(new Middleware\Permissions(StationPermissions::Media, true));

View File

@ -0,0 +1,51 @@
<template>
<section
class="card mb-3"
role="region"
>
<div class="card-header bg-primary-dark">
<h2 class="card-title">
{{ $gettext('Upload Custom Assets') }}
</h2>
</div>
<div class="card-body">
<ul class="list-unstyled">
<custom-asset-form
id="asset_background"
class="mb-3"
:api-url="backgroundApiUrl"
:caption="$gettext('Public Page Background')"
/>
<custom-asset-form
id="asset_album_art"
class="mb-3"
:api-url="albumArtApiUrl"
:caption="$gettext('Default Album Art')"
/>
</ul>
</div>
</section>
<branding-form :profile-edit-url="profileEditUrl" />
</template>
<script setup>
import BrandingForm from "~/components/Stations/Branding/BrandingForm.vue";
import CustomAssetForm from "~/components/Admin/Branding/CustomAssetForm.vue";
defineProps({
profileEditUrl: {
type: String,
required: true,
},
backgroundApiUrl: {
type: String,
required: true
},
albumArtApiUrl: {
type: String,
required: true
}
});
</script>

View File

@ -0,0 +1,176 @@
<template>
<form
class="form vue-form"
@submit.prevent="submit"
>
<section
class="card mb-3"
role="region"
>
<div class="card-header bg-primary-dark">
<h2 class="card-title">
{{ $gettext('Branding Settings') }}
</h2>
</div>
<b-alert
variant="danger"
:show="error != null"
>
{{ error }}
</b-alert>
<b-overlay
variant="card"
:show="loading"
>
<div class="card-body">
<b-form-group>
<div class="form-row">
<b-wrapped-form-group
id="form_edit_default_album_art_url"
class="col-md-6"
:field="v$.default_album_art_url"
>
<template #label>
{{ $gettext('Default Album Art URL') }}
</template>
<template #description>
{{
$gettext('If a song has no album art, this URL will be listed instead. Leave blank to use the standard placeholder art.')
}}
</template>
</b-wrapped-form-group>
<b-wrapped-form-group
id="edit_form_public_custom_css"
class="col-md-12"
:field="v$.public_custom_css"
>
<template #label>
{{ $gettext('Custom CSS for Public Pages') }}
</template>
<template #description>
{{
$gettext('This CSS will be applied to the station public pages.')
}}
</template>
<template #default="slotProps">
<codemirror-textarea
:id="slotProps.id"
v-model="slotProps.field.$model"
mode="css"
/>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group
id="edit_form_public_custom_js"
class="col-md-12"
:field="v$.public_custom_js"
>
<template #label>
{{ $gettext('Custom JS for Public Pages') }}
</template>
<template #description>
{{
$gettext('This javascript code will be applied to the station public pages.')
}}
</template>
<template #default="slotProps">
<codemirror-textarea
:id="slotProps.id"
v-model="slotProps.field.$model"
mode="javascript"
/>
</template>
</b-wrapped-form-group>
</div>
<b-button
size="lg"
type="submit"
class="mt-3"
variant="primary"
>
{{ $gettext('Save Changes') }}
</b-button>
</b-form-group>
</div>
</b-overlay>
</section>
</form>
</template>
<script setup>
import CodemirrorTextarea from "~/components/Common/CodemirrorTextarea.vue";
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup.vue";
import {onMounted, ref} from "vue";
import {useAxios} from "~/vendor/axios";
import mergeExisting from "~/functions/mergeExisting";
import {useNotify} from "~/vendor/bootstrapVue";
import {useTranslate} from "~/vendor/gettext";
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
const props = defineProps({
profileEditUrl: {
type: String,
required: true
},
});
const loading = ref(true);
const error = ref(null);
const {form, resetForm, v$, ifValid} = useVuelidateOnForm(
{
'default_album_art_url': {},
'public_custom_css': {},
'public_custom_js': {},
},
{
'default_album_art_url': '',
'public_custom_css': '',
'public_custom_js': ''
}
);
const {$gettext} = useTranslate();
const {axios} = useAxios();
const populateForm = (data) => {
form.value = mergeExisting(form.value, data);
};
const relist = () => {
resetForm();
loading.value = true;
axios.get(props.profileEditUrl).then((resp) => {
populateForm(resp.data.branding_config);
loading.value = false;
});
}
onMounted(relist);
const {wrapWithLoading, notifySuccess} = useNotify();
const submit = () => {
ifValid(() => {
wrapWithLoading(
axios({
method: 'PUT',
url: props.profileEditUrl,
data: {
branding_config: form.value
}
})
).then(() => {
notifySuccess();
relist();
});
});
}
</script>

View File

@ -50,13 +50,20 @@
</table>
<div class="card-actions">
<a
class="btn btn-outline-danger"
class="btn btn-outline-default"
@click.prevent="doOpenEmbed"
>
<icon icon="code" />
{{ $gettext('Embed Widgets') }}
</a>
<template v-if="userCanManageProfile">
<a
class="btn btn-outline-default"
:href="brandingUri"
>
<icon icon="design_services" />
{{ $gettext('Edit Branding') }}
</a>
<a
class="btn btn-outline-danger"
:data-confirm-title="$gettext('Disable public pages?')"

View File

@ -1,13 +1,46 @@
export default {
numSongs: Number,
numPlaylists: Number,
backendType: String,
hasStarted: Boolean,
userCanManageBroadcasting: Boolean,
userCanManageMedia: Boolean,
manageMediaUri: String,
managePlaylistsUri: String,
backendRestartUri: String,
backendStartUri: String,
backendStopUri: String
numSongs: {
type: Number,
required: true
},
numPlaylists: {
type: Number,
required: true
},
backendType: {
type: String,
required: true
},
hasStarted: {
type: Boolean,
required: true
},
userCanManageBroadcasting: {
type: Boolean,
required: true
},
userCanManageMedia: {
type: Boolean,
required: true
},
manageMediaUri: {
type: String,
required: true
},
managePlaylistsUri: {
type: String,
required: true
},
backendRestartUri: {
type: String,
required: true
},
backendStartUri: {
type: String,
required: true
},
backendStopUri: {
type: String,
required: true
}
};

View File

@ -1,13 +1,46 @@
export default {
stationSupportsStreamers: Boolean,
stationSupportsRequests: Boolean,
enablePublicPage: Boolean,
enableStreamers: Boolean,
enableOnDemand: Boolean,
enableRequests: Boolean,
publicPageEmbedUri: String,
publicOnDemandEmbedUri: String,
publicRequestEmbedUri: String,
publicHistoryEmbedUri: String,
publicScheduleEmbedUri: String
stationSupportsStreamers: {
type: Boolean,
required: true
},
stationSupportsRequests: {
type: Boolean,
required: true
},
enablePublicPage: {
type: Boolean,
required: true
},
enableStreamers: {
type: Boolean,
required: true
},
enableOnDemand: {
type: Boolean,
required: true
},
enableRequests: {
type: Boolean,
required: true
},
publicPageEmbedUri: {
type: String,
required: true
},
publicOnDemandEmbedUri: {
type: String,
required: true
},
publicRequestEmbedUri: {
type: String,
required: true
},
publicHistoryEmbedUri: {
type: String,
required: true
},
publicScheduleEmbedUri: {
type: String,
required: true
}
}

View File

@ -1,12 +1,42 @@
export default {
frontendType: String,
frontendAdminUri: String,
frontendAdminPassword: String,
frontendSourcePassword: String,
frontendRelayPassword: String,
frontendRestartUri: String,
frontendStartUri: String,
frontendStopUri: String,
hasStarted: Boolean,
userCanManageBroadcasting: Boolean
frontendType: {
type: String,
required: true
},
frontendAdminUri: {
type: String,
required: true
},
frontendAdminPassword: {
type: String,
required: true
},
frontendSourcePassword: {
type: String,
required: true
},
frontendRelayPassword: {
type: String,
required: true
},
frontendRestartUri: {
type: String,
required: true
},
frontendStartUri: {
type: String,
required: true
},
frontendStopUri: {
type: String,
required: true
},
hasStarted: {
type: Boolean,
required: true
},
userCanManageBroadcasting: {
type: Boolean,
required: true
}
}

View File

@ -1,6 +1,18 @@
export default {
stationName: String,
stationDescription: String,
userCanManageProfile: Boolean,
manageProfileUri: String
stationName: {
type: String,
required: true
},
stationDescription: {
type: String,
required: true
},
userCanManageProfile: {
type: Boolean,
required: true
},
manageProfileUri: {
type: String,
required: true
}
}

View File

@ -1,6 +1,18 @@
export default {
backendType: String,
userCanManageBroadcasting: Boolean,
backendSkipSongUri: String,
backendDisconnectStreamerUri: String
backendType: {
type: String,
required: true
},
userCanManageBroadcasting: {
type: Boolean,
required: true
},
backendSkipSongUri: {
type: String,
required: true
},
backendDisconnectStreamerUri: {
type: String,
required: true
}
}

View File

@ -1,15 +1,58 @@
export default {
stationSupportsStreamers: Boolean,
stationSupportsRequests: Boolean,
enablePublicPage: Boolean,
enableStreamers: Boolean,
enableOnDemand: Boolean,
enableRequests: Boolean,
userCanManageProfile: Boolean,
publicPageUri: String,
publicWebDjUri: String,
publicOnDemandUri: String,
publicPodcastsUri: String,
publicScheduleUri: String,
togglePublicPageUri: String
stationSupportsStreamers: {
type: Boolean,
required: true
},
stationSupportsRequests: {
type: Boolean,
required: true
},
enablePublicPage: {
type: Boolean,
required: true
},
enableStreamers: {
type: Boolean,
required: true
},
enableOnDemand: {
type: Boolean,
required: true
},
enableRequests: {
type: Boolean,
required: true
},
userCanManageProfile: {
type: Boolean,
required: true
},
publicPageUri: {
type: String,
required: true
},
publicWebDjUri: {
type: String,
required: true
},
publicOnDemandUri: {
type: String,
required: true
},
publicPodcastsUri: {
type: String,
required: true
},
publicScheduleUri: {
type: String,
required: true
},
togglePublicPageUri: {
type: String,
required: true
},
brandingUri: {
type: String,
required: true
},
}

View File

@ -1,7 +1,22 @@
export default {
enableRequests: Boolean,
userCanManageReports: Boolean,
userCanManageProfile: Boolean,
requestsViewUri: String,
requestsToggleUri: String
enableRequests: {
type: Boolean,
required: true
},
userCanManageReports: {
type: Boolean,
required: true
},
userCanManageProfile: {
type: Boolean,
required: true
},
requestsViewUri: {
type: String,
required: true
},
requestsToggleUri: {
type: String,
required: true
}
}

View File

@ -1,7 +1,22 @@
export default {
enableStreamers: Boolean,
userCanManageProfile: Boolean,
userCanManageStreamers: Boolean,
streamersViewUri: String,
streamersToggleUri: String
enableStreamers: {
type: Boolean,
required: true
},
userCanManageProfile: {
type: Boolean,
required: true
},
userCanManageStreamers: {
type: Boolean,
required: true
},
streamersViewUri: {
type: String,
required: true
},
streamersToggleUri: {
type: String,
required: true
}
}

View File

@ -0,0 +1,7 @@
import initBase from '~/base.js';
import '~/vendor/fancybox';
import StationsBranding from '~/components/Stations/Branding.vue';
export default initBase(StationsBranding);

View File

@ -35,6 +35,7 @@ module.exports = {
SetupRegister: '~/pages/Setup/Register.js',
SetupSettings: '~/pages/Setup/Settings.js',
SetupStation: '~/pages/Setup/Station.js',
StationsBranding: '~/pages/Stations/Branding.js',
StationsBulkMedia: '~/pages/Stations/BulkMedia.js',
StationsFallback: '~/pages/Stations/Fallback.js',
StationsHelp: '~/pages/Stations/Help.js',

View File

@ -4,14 +4,17 @@ declare(strict_types=1);
namespace App\Assets;
use App\Entity\Station;
use App\Environment;
use GuzzleHttp\Psr7\Uri;
use Psr\Http\Message\UriInterface;
use Symfony\Component\Filesystem\Filesystem;
abstract class AbstractCustomAsset implements CustomAssetInterface
{
public function __construct(
protected readonly Environment $environment
protected readonly Environment $environment,
protected readonly ?Station $station = null
) {
}
@ -22,7 +25,18 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
public function getPath(): string
{
$pattern = sprintf($this->getPattern(), '');
return $this->environment->getUploadsDirectory() . '/' . $pattern;
return $this->getBasePath() . '/' . $pattern;
}
protected function getBasePath(): string
{
$basePath = $this->environment->getUploadsDirectory();
if (null !== $this->station) {
$basePath .= '/' . $this->station->getShortName();
}
return $basePath;
}
public function getUrl(): string
@ -32,7 +46,7 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
$pattern = $this->getPattern();
$mtime = filemtime($path);
return $this->environment->getAssetUrl() . self::UPLOADS_URL_PREFIX . '/' . sprintf(
return $this->getBaseUrl() . '/' . sprintf(
$pattern,
'.' . $mtime
);
@ -41,6 +55,17 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
return $this->getDefaultUrl();
}
protected function getBaseUrl(): string
{
$baseUrl = $this->environment->getAssetUrl() . self::UPLOADS_URL_PREFIX;
if (null !== $this->station) {
$baseUrl .= '/' . $this->station->getShortName();
}
return $baseUrl;
}
public function getUri(): UriInterface
{
return new Uri($this->getUrl());
@ -55,4 +80,9 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
{
@unlink($this->getPath());
}
protected function ensureDirectoryExists(string $path): void
{
(new Filesystem())->mkdir(dirname($path));
}
}

View File

@ -16,7 +16,7 @@ abstract class AbstractMultiPatternCustomAsset extends AbstractCustomAsset
protected function getPathForPattern(string $pattern): string
{
$pattern = sprintf($pattern, '');
return $this->environment->getUploadsDirectory() . '/' . $pattern;
return $this->getBasePath() . '/' . $pattern;
}
public function getPath(): string
@ -47,7 +47,7 @@ abstract class AbstractMultiPatternCustomAsset extends AbstractCustomAsset
if (is_file($path)) {
$mtime = filemtime($path);
return $this->environment->getAssetUrl() . self::UPLOADS_URL_PREFIX . '/' . sprintf(
return $this->getBaseUrl() . '/' . sprintf(
$pattern,
'.' . $mtime
);

View File

@ -36,6 +36,10 @@ final class AlbumArtCustomAsset extends AbstractMultiPatternCustomAsset
$mimeType = $newImage->mime();
$pattern = $patterns[$mimeType] ?? $patterns['default'];
$newImage->save($this->getPathForPattern($pattern), 90);
$destPath = $this->getPathForPattern($pattern);
$this->ensureDirectoryExists($destPath);
$newImage->save($destPath, 90);
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Assets;
use App\Entity\Station;
use App\Environment;
enum AssetTypes: string
@ -12,12 +13,14 @@ enum AssetTypes: string
case Background = 'background';
case BrowserIcon = 'browser_icon';
public function createObject(Environment $environment): CustomAssetInterface
{
public function createObject(
Environment $environment,
?Station $station = null
): CustomAssetInterface {
return match ($this) {
self::AlbumArt => new AlbumArtCustomAsset($environment),
self::Background => new BackgroundCustomAsset($environment),
self::BrowserIcon => new BrowserIconCustomAsset($environment),
self::AlbumArt => new AlbumArtCustomAsset($environment, $station),
self::Background => new BackgroundCustomAsset($environment, $station),
self::BrowserIcon => new BrowserIconCustomAsset($environment, $station),
};
}
}

View File

@ -36,6 +36,10 @@ final class BackgroundCustomAsset extends AbstractMultiPatternCustomAsset
$mimeType = $newImage->mime();
$pattern = $patterns[$mimeType] ?? $patterns['default'];
$newImage->save($this->getPathForPattern($pattern), 90);
$destPath = $this->getPathForPattern($pattern);
$this->ensureDirectoryExists($destPath);
$newImage->save($destPath, 90);
}
}

View File

@ -41,7 +41,7 @@ final class BrowserIconCustomAsset extends AbstractCustomAsset
public function upload(Image $image): void
{
$uploadsDir = $this->environment->getUploadsDirectory() . '/browser_icon';
(new Filesystem())->mkdir($uploadsDir);
$this->ensureDirectoryExists($uploadsDir);
$newImage = clone $image;
$newImage->resize(256, 256);

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\CustomAssets;
use App\Assets\AssetTypes;
use App\Entity;
use App\Environment;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class DeleteCustomAssetAction
{
public function __construct(
private readonly Environment $environment
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id,
string $type
): ResponseInterface {
$customAsset = AssetTypes::from($type)->createObject(
$this->environment,
$request->getStation()
);
$customAsset->delete();
return $response->withJson(Entity\Api\Status::success());
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\CustomAssets;
use App\Assets\AssetTypes;
use App\Environment;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class GetCustomAssetAction
{
public function __construct(
private readonly Environment $environment
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id,
string $type
): ResponseInterface {
$customAsset = AssetTypes::from($type)->createObject(
$this->environment,
$request->getStation()
);
return $response->withJson(
[
'is_uploaded' => $customAsset->isUploaded(),
'url' => $customAsset->getUrl(),
]
);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\CustomAssets;
use App\Assets\AssetTypes;
use App\Entity;
use App\Environment;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Media\AlbumArt;
use App\Service\Flow;
use Psr\Http\Message\ResponseInterface;
final class PostCustomAssetAction
{
public function __construct(
private readonly Environment $environment
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id,
string $type
): ResponseInterface {
$customAsset = AssetTypes::from($type)->createObject(
$this->environment,
$request->getStation()
);
$flowResponse = Flow::process($request, $response);
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
$imageContents = $flowResponse->readAndDeleteUploadedFile();
$customAsset->upload(
AlbumArt::getImageManager()->make($imageContents)
);
return $response->withJson(Entity\Api\Status::success());
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Controller\Stations;
use App\Assets\AssetTypes;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class BrandingAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$router = $request->getRouter();
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_StationsBranding',
id: 'stations-branding',
title: __('Custom Branding'),
props: [
'profileEditUrl' => $router->fromHere(
'api:stations:profile:edit',
),
'backgroundApiUrl' => $router->fromHere('api:stations:custom_assets', [
'type' => AssetTypes::Background->value,
]),
'albumArtApiUrl' => $router->fromHere('api:stations:custom_assets', [
'type' => AssetTypes::AlbumArt->value,
]),
],
);
}
}

View File

@ -175,6 +175,9 @@ final class ProfileController
routeName: 'stations:profile:toggle',
routeParams: ['feature' => 'public', 'csrf' => $csrf]
),
'brandingUri' => $router->fromHere(
routeName: 'stations:branding',
),
// Frontend
'frontendAdminUri' => (string)$frontend?->getAdminUrl($station, $router->getBaseUrl()),

View File

@ -138,6 +138,24 @@ final class Customization
return $publicCss;
}
public function getStationCustomPublicCss(Entity\Station $station): string
{
$publicCss = $station->getBrandingConfig()->getPublicCustomCss() ?? '';
$background = new BackgroundCustomAsset($this->environment, $station);
if ($background->isUploaded()) {
$backgroundUrl = $background->getUrl();
$publicCss .= <<<CSS
[data-theme] body.page-minimal {
background-image: url('{$backgroundUrl}');
}
CSS;
}
return $publicCss;
}
/**
* Return the administrator-supplied custom JS for public (minimal layout) pages, if specified.
*/
@ -146,6 +164,11 @@ final class Customization
return $this->settings->getPublicCustomJs() ?? '';
}
public function getStationCustomPublicJs(Entity\Station $station): string
{
return $station->getBrandingConfig()->getPublicCustomJs() ?? '';
}
/**
* Return the administrator-supplied custom CSS for internal (full layout) pages, if specified.
*/

View File

@ -181,6 +181,11 @@ final class StationRepository extends Repository
public function getDefaultAlbumArtUrl(?Entity\Station $station = null): UriInterface
{
if (null !== $station) {
$stationAlbumArt = new AlbumArtCustomAsset($this->environment, $station);
if ($stationAlbumArt->isUploaded()) {
return $stationAlbumArt->getUri();
}
$stationCustomUri = $station->getBrandingConfig()->getDefaultAlbumArtUrlAsUri();
if (null !== $stationCustomUri) {
return $stationCustomUri;

View File

@ -29,4 +29,28 @@ class StationBrandingConfiguration extends AbstractStationConfiguration
{
$this->set(self::DEFAULT_ALBUM_ART_URL, $default_album_art_url);
}
public const PUBLIC_CUSTOM_CSS = 'public_custom_css';
public function getPublicCustomCss(): ?string
{
return $this->get(self::PUBLIC_CUSTOM_CSS);
}
public function setPublicCustomCss(?string $css): void
{
$this->set(self::PUBLIC_CUSTOM_CSS, $css);
}
public const PUBLIC_CUSTOM_JS = 'public_custom_js';
public function getPublicCustomJs(): ?string
{
return $this->get(self::PUBLIC_CUSTOM_JS);
}
public function setPublicCustomJs(?string $js): void
{
$this->set(self::PUBLIC_CUSTOM_JS, $js);
}
}

View File

@ -16,6 +16,8 @@ $this->layout(
]
);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
// Register PWA service worker
$swJsRoute = $router->named('public:sw');

View File

@ -0,0 +1,16 @@
<?php
/**
* @var \App\Customization $customization
* @var \App\Entity\Station $station
* @var App\View\GlobalSections $sections
*/
$sections->append(
'station_head',
'<style>' . $customization->getStationCustomPublicCss($station) . '</style>'
);
$sections->append(
'station_bodyjs',
'<script>' . $customization->getStationCustomPublicJs($station) . '</script>'
);

View File

@ -12,6 +12,8 @@ $this->layout('minimal', [
'hide_footer' => true,
]);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
$episodeAudioSrc = $router->named(
'api:stations:podcast:episode:download',
[

View File

@ -15,6 +15,8 @@ $this->layout(
]
);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
$sections->append(
'head',
<<<HTML

View File

@ -1,16 +1,19 @@
<?php
$this->layout('minimal', [
'page_class' => 'podcasts station-' . $station->getShortName(),
'title' => 'Podcasts - ' . $this->e($station->getName()),
'hide_footer' => true,
]);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
?>
<section id="content" role="main" class="d-flex align-items-stretch" style="height: 100vh;">
<div class="container pt-5 pb-5 h-100" style="flex: 1;">
<div id="station_podcasts">
<div class="row mb-4">
<h1 class="mx-auto"><?=$this->e($station->getName())?></h1>
<h1 class="mx-auto"><?= $this->e($station->getName()) ?></h1>
</div>
<div class="row justify-content-center">

View File

@ -15,6 +15,8 @@ $this->layout(
]
);
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
$sections->appendStart('bodyjs');
?>
<script src="<?= $this->assetUrl('dist/lib/webcaster/taglib.js') ?>"></script>

View File

@ -31,6 +31,8 @@ $hide_footer ??= false;
<style>
<?=$customization->getCustomPublicCss() ?>
</style>
<?= $sections->get('station_head') ?>
</head>
<body class="page-minimal <?= $page_class ?? '' ?>">
@ -43,6 +45,8 @@ $hide_footer ??= false;
<?=$customization->getCustomPublicJs() ?>
</script>
<?= $sections->get('station_bodyjs') ?>
<?= $this->section('content') ?>
<?php