Commit fee2c1c0 authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat(pwa): add service-worker + webmanifest for each podcasts to have them install on devices

- configure service-worker using vite-plugin-pwa
- refactor Image entity to generate images of
different types based on size config
- add requirement for webp library for php gd to generate webp
images for instance
- add action to regenerate all instance images for eventual Images config
changes
- enhance google lighthouse metrics for pwa
parent 902f959b
......@@ -37,6 +37,7 @@ RUN apt-get update \
# https://github.com/mlocati/docker-php-extension-installer (included in php's docker image)
libicu-dev \
libpng-dev \
libwebp-dev \
libjpeg-dev \
zlib1g-dev \
libzip-dev \
......@@ -44,7 +45,7 @@ RUN apt-get update \
&& docker-php-ext-install intl \
&& docker-php-ext-install zip \
# gd for image processing
&& docker-php-ext-configure gd --with-jpeg \
&& docker-php-ext-configure gd --with-webp --with-jpeg \
&& docker-php-ext-install gd \
# redis extension for cache
&& pecl install -o -f redis \
......
......@@ -69,7 +69,8 @@ PHP version 8.0 or higher is required, with the following extensions installed:
- [intl](https://php.net/manual/en/intl.requirements.php)
- [libcurl](https://php.net/manual/en/curl.requirements.php)
- [mbstring](https://php.net/manual/en/mbstring.installation.php)
- [gd](https://www.php.net/manual/en/image.installation.php)
- [gd](https://www.php.net/manual/en/image.installation.php) with **JPEG**,
**PNG** and **WEBP** libraries.
Additionally, make sure that the following extensions are enabled in your PHP:
......
......@@ -47,15 +47,57 @@ class Images extends BaseConfig
*
* Array values are as follows: 'name' => [width, height]
*
* @var array<string, int[]>
* @var array<string, array<string, int|string>>
*/
public array $podcastCoverSizes = [
'tiny' => [40, 40],
'thumbnail' => [150, 150],
'medium' => [320, 320],
'large' => [1024, 1024],
'feed' => [1400, 1400],
'id3' => [500, 500],
'tiny' => [
'width' => 40,
'height' => 40,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'thumbnail' => [
'width' => 150,
'height' => 150,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'medium' => [
'width' => 320,
'height' => 320,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'large' => [
'width' => 1024,
'height' => 1024,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'feed' => [
'width' => 1400,
'height' => 1400,
],
'id3' => [
'width' => 500,
'height' => 500,
],
'federation' => [
'width' => 400,
'height' => 400,
],
'webmanifest192' => [
'width' => 192,
'height' => 192,
'mimetype' => 'image/png',
'extension' => 'png',
],
'webmanifest512' => [
'width' => 512,
'height' => 512,
'mimetype' => 'image/png',
'extension' => 'png',
],
];
/**
......@@ -63,14 +105,25 @@ class Images extends BaseConfig
*
* Uploaded podcast header covers are of 3:1 ratio
*
* Array values are as follows: 'name' => [width, height]
*
* @var array<string, int[]>
* @var array<string, array<string, int|string>>
*/
public array $podcastBannerSizes = [
'small' => [320, 128],
'medium' => [960, 320],
'large' => [1500, 500],
'small' => [
'width' => 320,
'height' => 128,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'medium' => [
'width' => 960,
'height' => 320,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'federation' => [
'width' => 1500,
'height' => 500,
],
];
public string $podcastBannerDefaultPath = 'castopod-banner-default.jpg';
......@@ -84,11 +137,27 @@ class Images extends BaseConfig
*
* Array values are as follows: 'name' => [width, height]
*
* @var array<string, int[]>
* @var array<string, array<string, int|string>>
*/
public array $personAvatarSizes = [
'tiny' => [40, 40],
'thumbnail' => [150, 150],
'medium' => [320, 320],
'tiny' => [
'width' => 40,
'height' => 40,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'thumbnail' => [
'width' => 150,
'height' => 150,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'medium' => [
'width' => 320,
'height' => 320,
'mimetype' =>
'image/webp',
'extension' => 'webp',
],
];
}
......@@ -65,6 +65,10 @@ $routes->group('@(:podcastHandle)', function ($routes): void {
$routes->get('/', 'PodcastController::activity/$1', [
'as' => 'podcast-activity',
]);
$routes->get('manifest.webmanifest', 'WebmanifestController::podcastManifest/$1', [
'as' => 'podcast-webmanifest',
]);
// override default Fediverse Library's actor route
$routes->options('/', 'ActivityPubController::preflight');
$routes->get('/', 'PodcastController::activity/$1', [
......
......@@ -204,9 +204,9 @@ class EpisodeController extends BaseController
'height' => 144,
'thumbnail_url' => $this->episode->cover->large_url,
'thumbnail_width' => config('Images')
->podcastCoverSizes['large'][0],
->podcastCoverSizes['large']['width'],
'thumbnail_height' => config('Images')
->podcastCoverSizes['large'][1],
->podcastCoverSizes['large']['height'],
]);
}
......@@ -222,8 +222,8 @@ class EpisodeController extends BaseController
$oembed->addChild('author_name', $this->podcast->title);
$oembed->addChild('author_url', $this->podcast->link);
$oembed->addChild('thumbnail', $this->episode->cover->large_url);
$oembed->addChild('thumbnail_width', (string) config('Images')->podcastCoverSizes['large'][0]);
$oembed->addChild('thumbnail_height', (string) config('Images')->podcastCoverSizes['large'][1]);
$oembed->addChild('thumbnail_width', (string) config('Images')->podcastCoverSizes['large']['width']);
$oembed->addChild('thumbnail_height', (string) config('Images')->podcastCoverSizes['large']['height']);
$oembed->addChild(
'html',
htmlentities(
......
......@@ -10,11 +10,44 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Models\PodcastModel;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\ResponseInterface;
class WebmanifestController extends Controller
{
/**
* @var array<string, string>
*/
public const THEME_COLORS = [
'pine' => [
'theme' => '#009486',
'background' => '#F0F9F8',
],
'lake' => [
'theme' => '#00ACE0',
'background' => '#F0F7F9',
],
'jacaranda' => [
'theme' => '#562CDD',
'background' => '#F2F0F9',
],
'crimson' => [
'theme' => '#F24562',
'background' => '#F9F0F2',
],
'amber' => [
'theme' => '#FF6224',
'background' => '#F9F3F0',
],
'onyx' => [
'theme' =>
'#040406',
'background' => '#F3F3F7',
],
];
public function index(): ResponseInterface
{
$webmanifest = [
......@@ -22,8 +55,13 @@ class WebmanifestController extends Controller
->get('App.siteName'),
'description' => service('settings')
->get('App.siteDescription'),
'lang' => service('request')
->getLocale(),
'start_url' => base_url(),
'display' => 'minimal-ui',
'theme_color' => '#009486',
'orientation' => 'portrait',
'theme_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['theme'],
'background_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['background'],
'icons' => [
[
'src' => service('settings')
......@@ -42,4 +80,39 @@ class WebmanifestController extends Controller
return $this->response->setJSON($webmanifest);
}
public function podcastManifest(string $podcastHandle): ResponseInterface
{
if (
($podcast = (new PodcastModel())->getPodcastByHandle($podcastHandle)) === null
) {
throw PageNotFoundException::forPageNotFound();
}
$webmanifest = [
'name' => $podcast->title,
'short_name' => '@' . $podcast->handle,
'description' => $podcast->description,
'lang' => $podcast->language_code,
'start_url' => $podcast->link,
'display' => 'minimal-ui',
'orientation' => 'portrait',
'theme_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['theme'],
'background_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['background'],
'icons' => [
[
'src' => $podcast->cover->webmanifest192_url,
'type' => $podcast->cover->webmanifest192_mimetype,
'sizes' => '192x192',
],
[
'src' => $podcast->cover->webmanifest512_url,
'type' => $podcast->cover->webmanifest512_mimetype,
'sizes' => '512x512',
],
],
];
return $this->response->setJSON($webmanifest);
}
}
......@@ -41,4 +41,22 @@ class Actor extends FediverseActor
return $this->podcast;
}
public function getAvatarImageUrl(): string
{
if ($this->podcast !== null) {
return $this->podcast->cover->thumbnail_url;
}
return $this->attributes['avatar_image_url'];
}
public function getAvatarImageMimetype(): string
{
if ($this->podcast !== null) {
return $this->podcast->cover->thumbnail_mimetype;
}
return $this->attributes['avatar_image_mimetype'];
}
}
......@@ -200,7 +200,9 @@ class Episode extends Entity
public function getCover(): Image
{
if ($coverPath = $this->attributes['cover_path']) {
return new Image(null, $coverPath, $this->attributes['cover_mimetype']);
return new Image(null, $coverPath, $this->attributes['cover_mimetype'], config(
'Images'
)->podcastCoverSizes);
}
return $this->getPodcast()
......
......@@ -28,7 +28,7 @@ class Image extends Entity
{
protected Images $config;
protected ?File $file = null;
protected File $file;
protected string $dirname;
......@@ -38,7 +38,16 @@ class Image extends Entity
protected string $mimetype;
public function __construct(?File $file, string $path = '', string $mimetype = '')
/**
* @var array<string, array<string, int|string>>
*/
protected array $sizes = [];
/**
* @param array<string, array<string, int|string>> $sizes
* @param File $file
*/
public function __construct(?File $file, string $path = '', string $mimetype = '', array $sizes = [])
{
if ($file === null && $path === '') {
throw new RuntimeException('File or path must be set to create an Image.');
......@@ -63,11 +72,17 @@ class Image extends Entity
] = pathinfo($path);
}
if ($file === null) {
helper('media');
$file = new File(media_path($path));
}
$this->file = $file;
$this->dirname = $dirname;
$this->filename = $filename;
$this->extension = $extension;
$this->mimetype = $mimetype;
$this->sizes = $sizes;
}
public function __get($property)
......@@ -91,7 +106,24 @@ class Image extends Entity
if ($this->dirname !== '.') {
$path .= $this->dirname . '/';
}
$path .= $this->filename . $fileSuffix . '.' . $this->extension;
$path .= $this->filename . $fileSuffix;
$extension = '.' . $this->extension;
$mimetype = $this->mimetype;
if ($fileSuffix !== '') {
$sizeName = substr($fileSuffix, 1);
if (array_key_exists('extension', $this->sizes[$sizeName])) {
$extension = '.' . $this->sizes[$sizeName]['extension'];
}
if (array_key_exists('mimetype', $this->sizes[$sizeName])) {
$mimetype = $this->sizes[$sizeName]['mimetype'];
}
}
$path .= $extension;
if (str_ends_with($property, 'mimetype')) {
return $mimetype;
}
if (str_ends_with($property, 'url')) {
helper('media');
......@@ -111,15 +143,11 @@ class Image extends Entity
public function getFile(): File
{
if ($this->file === null) {
$this->file = new File($this->path);
}
return $this->file;
}
/**
* @param array<string, int[]> $sizes
* @param array<string, array<string, int|string>> $sizes
*/
public function saveImage(array $sizes, string $dirname, string $filename): void
{
......@@ -127,6 +155,7 @@ class Image extends Entity
$this->dirname = $dirname;
$this->filename = $filename;
$this->sizes = $sizes;
save_media($this->file, $this->dirname, $this->filename);
......@@ -136,8 +165,8 @@ class Image extends Entity
$pathProperty = $name . '_path';
$imageService
->withFile(media_path($this->path))
->resize($size[0], $size[1])
->save(media_path($this->{$pathProperty}));
->resize($size['width'], $size['height']);
$imageService->save(media_path($this->{$pathProperty}));
}
}
......
......@@ -77,10 +77,12 @@ class Person extends Entity
public function getAvatar(): Image
{
if ($this->attributes['avatar_path'] === null) {
return new Image(null, '/castopod-avatar-default.jpg', 'image/jpeg');
return new Image(null, '/castopod-avatar-default.jpg', 'image/jpeg', config('Images')->personAvatarSizes);
}
return new Image(null, $this->attributes['avatar_path'], $this->attributes['avatar_mimetype']);
return new Image(null, $this->attributes['avatar_path'], $this->attributes['avatar_mimetype'], config(
'Images'
)->personAvatarSizes);
}
/**
......
......@@ -211,7 +211,7 @@ class Podcast extends Entity
public function getCover(): Image
{
return new Image(null, $this->cover_path, $this->cover_mimetype);
return new Image(null, $this->cover_path, $this->cover_mimetype, config('Images')->podcastCoverSizes);
}
/**
......@@ -248,11 +248,13 @@ class Podcast extends Entity
config('Images')
->podcastBannerDefaultPath,
config('Images')
->podcastBannerDefaultMimeType
->podcastBannerDefaultMimeType,
config('Images')
->podcastBannerSizes
);
}
return new Image(null, $this->banner_path, $this->banner_mimetype);
return new Image(null, $this->banner_path, $this->banner_mimetype, config('Images') ->podcastBannerSizes);
}
public function getLink(): string
......
......@@ -24,7 +24,7 @@ if (! function_exists('get_podcast_metatags')) {
$schema = new Schema(
new Thing('PodcastSeries', [
'name' => $podcast->title,
'url' => url_to('podcast-activity', $podcast->handle),
'url' => $podcast->link,
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
......@@ -41,8 +41,8 @@ if (! function_exists('get_podcast_metatags')) {
->description(htmlspecialchars($podcast->description))
->image((string) $podcast->cover->large_url)
->canonical((string) current_url())
->og('image:width', (string) config('Images')->podcastCoverSizes['large'][0])
->og('image:height', (string) config('Images')->podcastCoverSizes['large'][1])
->og('image:width', (string) config('Images')->podcastCoverSizes['large']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['large']['height'])
->og('locale', $podcast->language_code)
->og('site_name', service('settings')->get('App.siteName'));
......@@ -70,7 +70,7 @@ if (! function_exists('get_episode_metatags')) {
]),
'partOfSeries' => new Thing('PodcastSeries', [
'name' => $episode->podcast->title,
'url' => url_to('podcast-activity', $episode->podcast->handle),
'url' => $episode->podcast->link,
]),
])
);
......@@ -83,8 +83,8 @@ if (! function_exists('get_episode_metatags')) {
->image((string) $episode->cover->large_url, 'player')
->canonical($episode->link)
->og('site_name', service('settings')->get('App.siteName'))
->og('image:width', (string) config('Images')->podcastCoverSizes['large'][0])
->og('image:height', (string) config('Images')->podcastCoverSizes['large'][1])
->og('image:width', (string) config('Images')->podcastCoverSizes['large']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['large']['height'])
->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_file_opengraph_url)
->og('audio:type', $episode->audio_file_mimetype)
......
......@@ -485,9 +485,9 @@ class PodcastModel extends Model
// update values
$actor->display_name = $podcast->title;
$actor->summary = $podcast->description_html;
$actor->avatar_image_url = $podcast->cover->thumbnail_url;
$actor->avatar_image_url = $podcast->cover->federation_url;
$actor->avatar_image_mimetype = $podcast->cover->mimetype;
$actor->cover_image_url = $podcast->banner->large_url;
$actor->cover_image_url = $podcast->banner->federation_url;
$actor->cover_image_mimetype = $podcast->banner->mimetype;
if ($actor->hasChanged()) {
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm4.82-4.924A7 7 0 0 0 9.032 5.658l.975 1.755A5 5 0 0 1 17 12h-3l2.82 5.076zm-1.852 1.266l-.975-1.755A5 5 0 0 1 7 12h3L7.18 6.924a7 7 0 0 0 7.788 11.418z"/>
</g>
</svg>
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c0a25c3d11c806b4bc62eafb22902bc8",
"content-hash": "afb6585b90ed08cc8a257f346ab1c416",
"packages": [
{
"name": "brick/math",
......@@ -603,8 +603,8 @@
"php": ">=5.4.0"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.0",
"phpunit/phpunit": "^4.8 || ^5.0 || ^6.1 || ^7.5 || ^8.5"
"jakub-onderka/php-parallel-lint": "^0.9 || ^1.0",
"phpunit/phpunit": "^4.8|^5.0"
},
"suggest": {
"ext-SimpleXML": "SimpleXML extension is required to analyze RIFF/WAV/BWF audio files (also requires `ext-libxml`).",
......
......@@ -153,8 +153,8 @@ You do not wish to use the VSCode devcontainer? No problem!
> The `docker-compose up -d` command will boot 4 containers in the
> background:
>
> - `castopod-host_app`: a php based container with CodeIgniter4 requirements
> installed
> - `castopod-host_app`: a php based container with Castopod Host
> requirements installed
> - `castopod-host_redis`: a [redis](https://redis.io/) database to handle
> queries and pages caching
> - `castopod-host_mariadb`: a [mariadb](https://mariadb.org/) server for
......
......@@ -31,6 +31,10 @@ $routes->group(
'as' => 'settings-instance-delete-icon',
'filter' => 'permission:settings-manage',
]);
$routes->post('instance-images-regenerate', 'SettingsController::regenerateImages', [
'as' => 'settings-images-regenerate',
'filter' => 'permission:settings-manage',
]);
$routes->get('theme', 'SettingsController::theme', [
'as' => 'settings-theme',
'filter' => 'permission:settings-manage',
......
......@@ -10,6 +10,8 @@ declare(strict_types=1);
namespace Modules\Admin\Controllers;
use App\Models\PersonModel;
use App\Models\PodcastModel;
use CodeIgniter\HTTP\RedirectResponse;
use PHP_ICO;
......@@ -75,20 +77,20 @@ class SettingsController extends BaseController
service('image')
->withFile(ROOTPATH . 'public/media/site/icon.png')
->resize($size, $size)
->save(ROOTPATH . "public/media/site/icon-{$size}.{$randomHash}.png");
->save(media_path("/site/icon-{$size}.{$randomHash}.png"));
}
service('settings')
->set('App.siteIcon', [
'ico' => "/media/site/favicon.{$randomHash}.ico",
'64' => "/media/site/icon-64.{$randomHash}.png",
'180' => "/media/site/icon-180.{$randomHash}.png",
'192' => "/media/site/icon-192.{$randomHash}.png",
'512' => "/media/site/icon-512.{$randomHash}.png",
'ico' => media_path("/site/favicon.{$randomHash}.ico"),
'64' => media_path("/site/icon-64.{$randomHash}.png"),
'180' => media_path("/site/icon-180.{$randomHash}.png"),
'192' => media_path("/site/icon-192.{$randomHash}.png"),
'512' => media_path("/site/icon-512.{$randomHash}.png"),
]);
}
return redirect('settings-general')->with('message'