feat: display chapters in episode's public page

closes #423
This commit is contained in:
Guy Martin 2024-02-17 12:02:38 +00:00 committed by Yassine Doghri
parent 98c6658840
commit 87cc437e1e
11 changed files with 194 additions and 2 deletions

View File

@ -125,6 +125,9 @@ $routes->group('@(:podcastHandle)', static function ($routes): void {
$routes->get('activity', 'EpisodeController::activity/$1/$2', [
'as' => 'episode-activity',
]);
$routes->get('chapters', 'EpisodeController::chapters/$1/$2', [
'as' => 'episode-chapters',
]);
$routes->options('comments', 'ActivityPubController::preflight');
$routes->get('comments', 'EpisodeController::comments/$1/$2', [
'as' => 'episode-comments',
@ -196,10 +199,12 @@ $routes->get('/audio/@(:podcastHandle)/(:slug).(:alphanum)', 'EpisodeAudioContro
$routes->get('/p/(:uuid)', 'EpisodePreviewController::index/$1', [
'as' => 'episode-preview',
]);
$routes->get('/p/(:uuid)/activity', 'EpisodePreviewController::activity/$1', [
'as' => 'episode-preview-activity',
]);
$routes->get('/p/(:uuid)/chapters', 'EpisodePreviewController::chapters/$1', [
'as' => 'episode-preview-chapters',
]);
// Other pages
$routes->get('/credits', 'CreditsController', [

View File

@ -27,6 +27,7 @@ use Config\Services;
use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Objects\OrderedCollectionObject;
use Modules\Fediverse\Objects\OrderedCollectionPage;
use Modules\Media\FileManagers\FileManagerInterface;
use SimpleXMLElement;
class EpisodeController extends BaseController
@ -166,6 +167,67 @@ class EpisodeController extends BaseController
return $cachedView;
}
public function chapters(): String
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->episode->podcast_id);
}
$cacheName = implode(
'_',
array_filter([
'page',
"podcast#{$this->podcast->id}",
"episode#{$this->episode->id}",
'chapters',
service('request')
->getLocale(),
is_unlocked($this->podcast->handle) ? 'unlocked' : null,
auth()
->loggedIn() ? 'authenticated' : null,
]),
);
if (! ($cachedView = cache($cacheName))) {
// get chapters from json file
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
];
if (isset($this->episode->chapters->file_key)) {
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
$episodeChaptersJsonString = (string) $fileManager->getFileContents($this->episode->chapters->file_key);
$chapters = json_decode($episodeChaptersJsonString, true);
$data['chapters'] = $chapters;
}
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
if (auth()->loggedIn()) {
helper('form');
return view('episode/chapters', $data);
}
// The page cache is set to a decade so it is deleted manually upon podcast update
return view('episode/chapters', $data, [
'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName,
]);
}
return $cachedView;
}
public function embed(string $theme = 'light-transparent'): string
{
header('Content-Security-Policy: frame-ancestors http://*:* https://*:*');

View File

@ -63,4 +63,12 @@ class EpisodePreviewController extends BaseController
'episode' => $this->episode,
]);
}
public function chapters(): RedirectResponse | string
{
return view('episode/preview-chapters', [
'podcast' => $this->episode->podcast,
'episode' => $this->episode,
]);
}
}

View File

@ -21,7 +21,6 @@ use App\Models\PostModel;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\URI;
use CodeIgniter\I18n\Time;
use Config\Images;
use Exception;

View File

@ -23,6 +23,7 @@ return [
'back_to_episodes' => 'Back to episodes of {podcast}',
'comments' => 'Comments',
'activity' => 'Activity',
'chapters' => 'Chapters',
'description' => 'Episode description',
'number_of_comments' => '{numberOfComments, plural,
one {# comment}
@ -42,4 +43,5 @@ return [
'publish' => 'Publish',
'publish_edit' => 'Edit publication',
],
'no_chapters' => 'No chapters are available for this episode.',
];

View File

@ -10,7 +10,49 @@ declare(strict_types=1);
namespace Modules\Media\Entities;
use CodeIgniter\Files\File;
use Exception;
class Chapters extends BaseMedia
{
protected string $type = 'chapters';
public function initFileProperties(): void
{
parent::initFileProperties();
if ($this->file_metadata !== null && array_key_exists('chapter_count', $this->file_metadata)) {
helper('media');
$this->chapter_count = $this->file_metadata['chapter_count'];
}
}
public function setFile(File $file): self
{
parent::setFile($file);
$metadata = lstat((string) $file) ?? [];
helper('filesystem');
$metadata['chapter_count'] = $this->countChaptersInJson($file);
$this->attributes['file_metadata'] = json_encode($metadata, JSON_INVALID_UTF8_IGNORE);
$this->file = $file;
return $this;
}
private function countChaptersInJson(File $file): Int
{
$chapterContent = file_get_contents($file->getRealPath());
if ($chapterContent === false) {
throw new Exception('Could not read chapter file at ' . $this->file->getRealPath());
}
return substr_count($chapterContent, 'startTime');
}
}

View File

@ -41,6 +41,7 @@ module.exports = {
backgroundColor: {
base: "hsl(var(--color-background-base) / <alpha-value>)",
elevated: "hsl(var(--color-background-elevated) / <alpha-value>)",
subtle: "hsl(var(--color-border-subtle) / <alpha-value>)",
navigation: "hsl(var(--color-background-navigation) / <alpha-value>)",
"navigation-active":
"hsl(var(--color-background-navigation-active) / <alpha-value>)",

View File

@ -0,0 +1,11 @@
<article class="flex p-2 gap-x-2">
<img src="<?= $chapterImgUrl ?>" class="w-20 h-20 rounded-lg aspect-square" loading="lazy" />
<div class="flex flex-col">
<div class="flex items-baseline gap-x-2">
<span class="px-1 text-sm font-semibold rounded bg-subtle"><?= $startTime ?></span><?= $title ?>
</div>
<?php if ($chapterUrl !== ''): ?>
<a class="inline-flex items-baseline mt-1 text-sm underline text-skin-muted hover:no-underline" href='<?= $chapterUrl ?>' target='_blank' rel="noopener noreferrer"><?= $chapterUrl ?><?= icon('external-link', 'sm:ml-1 sm:text-base sm:opacity-60') ?></a>
<?php endif; ?>
</div>
</article>

View File

@ -12,6 +12,11 @@ if ($episode->publication_status === 'published') {
'label' => lang('Episode.activity'),
'labelInfo' => $episode->posts_count,
],
[
'uri' => route_to('episode-chapters', esc($podcast->handle), esc($episode->slug)),
'label' => lang('Episode.chapters'),
'labelInfo' => $episode->chapters === null ? 0 : $episode->chapters->chapter_count,
],
];
} else {
$navigationItems = [
@ -25,8 +30,15 @@ if ($episode->publication_status === 'published') {
'label' => lang('Episode.activity'),
'labelInfo' => $episode->posts_count,
],
[
'uri' => route_to('episode-preview-chapters', $episode->preview_id),
'label' => lang('Episode.chapters'),
'labelInfo' => $episode->chapters === null ? 0 : $episode->chapters->chapter_count,
],
];
}
?>
<nav class="sticky z-40 flex col-start-2 pt-4 shadow bg-elevated md:px-8 gap-x-2 md:gap-x-4 -top-4 rounded-conditional-b-xl">
<?php foreach ($navigationItems as $item): ?>

View File

@ -0,0 +1,25 @@
<?= $this->extend('episode/_layout') ?>
<?= $this->section('content') ?>
<?php if (isset($chapters)): ?>
<div class="flex flex-col gap-2">
<?php foreach ($chapters['chapters'] as $chapter) {
if (isset($chapter['toc'])) {
if ($chapter['toc'] !== true) {
continue;
}
}
echo view('episode/_partials/chapter', [
'title' => array_key_exists('title', $chapter) ? $chapter['title'] : '',
'startTime' => format_duration($chapter['startTime']),
'chapterImgUrl' => array_key_exists('img', $chapter) ? $chapter['img'] : $episode->cover->thumbnail_url,
'chapterUrl' => array_key_exists('url', $chapter) ? $chapter['url'] : '',
]);
} ?>
</div>
<?php else: ?>
<div class="text-center"><?= lang('Episode.no_chapters') ?></div>
<?php endif; ?>
<?= $this->endSection() ?>

View File

@ -0,0 +1,25 @@
<?= $this->extend('episode/_layout-preview') ?>
<?= $this->section('content') ?>
<?php if (isset($chapters)): ?>
<div class="flex flex-col gap-2">
<?php foreach ($chapters['chapters'] as $chapter) {
if (isset($chapter['toc'])) {
if ($chapter['toc'] !== true) {
continue;
}
}
echo view('episode/_partials/chapter', [
'title' => array_key_exists('title', $chapter) ? $chapter['title'] : '',
'startTime' => format_duration($chapter['startTime']),
'chapterImgUrl' => array_key_exists('img', $chapter) ? $chapter['img'] : $episode->cover->thumbnail_url,
'chapterUrl' => array_key_exists('url', $chapter) ? $chapter['url'] : '',
]);
} ?>
</div>
<?php else: ?>
<div class="text-center"><?= lang('Episode.no_chapters') ?></div>
<?php endif; ?>
<?= $this->endSection() ?>