feat(rss): add soundbites according to the podcastindex specs

Closes #83
This commit is contained in:
Benjamin Bellamy 2020-12-07 20:13:46 +00:00
parent 0571a075da
commit 6b34617d07
24 changed files with 834 additions and 10 deletions

View File

@ -253,6 +253,29 @@ $routes->group(
'filter' => 'permission:podcast_episodes-edit',
]
);
$routes->get(
'soundbites',
'Episode::soundbitesEdit/$1/$2',
[
'as' => 'soundbites-edit',
'filter' => 'permission:podcast_episodes-edit',
]
);
$routes->post(
'soundbites',
'Episode::soundbitesAttemptEdit/$1/$2',
[
'filter' => 'permission:podcast_episodes-edit',
]
);
$routes->add(
'soundbites/(:num)/delete',
'Episode::soundbiteDelete/$1/$2/$3',
[
'as' => 'soundbite-delete',
'filter' => 'permission:podcast_episodes-edit',
]
);
});
});

View File

@ -10,6 +10,7 @@ namespace App\Controllers\Admin;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use App\Models\SoundbiteModel;
use CodeIgniter\I18n\Time;
class Episode extends BaseController
@ -24,6 +25,11 @@ class Episode extends BaseController
*/
protected $episode;
/**
* @var \App\Entities\Soundbite|null
*/
protected $soundbites;
public function _remap($method, ...$params)
{
$this->podcast = (new PodcastModel())->getPodcastById($params[0]);
@ -39,9 +45,12 @@ class Episode extends BaseController
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
unset($params[1]);
unset($params[0]);
}
return $this->$method();
return $this->$method(...$params);
}
public function list()
@ -316,4 +325,89 @@ class Episode extends BaseController
return redirect()->route('episode-list', [$this->podcast->id]);
}
public function soundbitesEdit()
{
helper(['form']);
$data = [
'podcast' => $this->podcast,
'episode' => $this->episode,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => $this->episode->title,
]);
return view('admin/episode/soundbites', $data);
}
public function soundbitesAttemptEdit()
{
$soundbites_array = $this->request->getPost('soundbites_array');
$rules = [
'soundbites_array.0.start_time' =>
'permit_empty|required_with[soundbites_array.0.duration]|decimal|greater_than_equal_to[0]',
'soundbites_array.0.duration' =>
'permit_empty|required_with[soundbites_array.0.start_time]|decimal|greater_than_equal_to[0]',
];
foreach ($soundbites_array as $soundbite_id => $soundbite) {
$rules += [
"soundbites_array.{$soundbite_id}.start_time" => 'required|decimal|greater_than_equal_to[0]',
"soundbites_array.{$soundbite_id}.duration" => 'required|decimal|greater_than_equal_to[0]',
];
}
if (!$this->validate($rules)) {
return redirect()
->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
foreach ($soundbites_array as $soundbite_id => $soundbite) {
if (
!empty($soundbite['start_time']) &&
!empty($soundbite['duration'])
) {
$data = [
'podcast_id' => $this->podcast->id,
'episode_id' => $this->episode->id,
'start_time' => $soundbite['start_time'],
'duration' => $soundbite['duration'],
'label' => $soundbite['label'],
'updated_by' => user()->id,
];
if ($soundbite_id == 0) {
$data += ['created_by' => user()->id];
} else {
$data += ['id' => $soundbite_id];
}
$soundbiteModel = new SoundbiteModel();
if (!$soundbiteModel->save($data)) {
return redirect()
->back()
->withInput()
->with('errors', $soundbiteModel->errors());
}
}
}
return redirect()->route('soundbites-edit', [
$this->podcast->id,
$this->episode->id,
]);
}
public function soundbiteDelete($soundbiteId)
{
(new SoundbiteModel())->deleteSoundbite(
$this->podcast->id,
$this->episode->id,
$soundbiteId
);
return redirect()->route('soundbites-edit', [
$this->podcast->id,
$this->episode->id,
]);
}
}

View File

@ -0,0 +1,77 @@
<?php
/**
* Class AddSoundbites
* Creates soundbites table in database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSoundbites extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'INT',
'unsigned' => true,
],
'episode_id' => [
'type' => 'INT',
'unsigned' => true,
],
'start_time' => [
'type' => 'FLOAT',
],
'duration' => [
'type' => 'FLOAT',
],
'label' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'created_by' => [
'type' => 'INT',
'unsigned' => true,
],
'updated_by' => [
'type' => 'INT',
'unsigned' => true,
],
'created_at' => [
'type' => 'DATETIME',
],
'updated_at' => [
'type' => 'DATETIME',
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['episode_id', 'start_time', 'duration']);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->addForeignKey('episode_id', 'episodes', 'id');
$this->forge->addForeignKey('created_by', 'users', 'id');
$this->forge->addForeignKey('updated_by', 'users', 'id');
$this->forge->createTable('soundbites');
}
public function down()
{
$this->forge->dropTable('soundbites');
}
}

View File

@ -19,6 +19,7 @@ class Category extends Entity
protected $parent;
protected $casts = [
'id' => 'integer',
'parent_id' => 'integer',
'code' => 'string',
'apple_category' => 'string',

View File

@ -9,6 +9,7 @@
namespace App\Entities;
use App\Models\PodcastModel;
use App\Models\SoundbiteModel;
use CodeIgniter\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
@ -75,6 +76,11 @@ class Episode extends Entity
*/
protected $chapters_url;
/**
* @var \App\Entities\Soundbite[]
*/
protected $soundbites;
/**
* Holds text only description, striped of any markdown or html special characters
*
@ -95,6 +101,7 @@ class Episode extends Entity
];
protected $casts = [
'id' => 'integer',
'guid' => 'string',
'slug' => 'string',
'title' => 'string',
@ -348,6 +355,29 @@ class Episode extends Entity
: null;
}
/**
* Returns the episodes soundbites
*
* @return \App\Entities\Episode[]
*/
public function getSoundbites()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Episode must be created before getting soundbites.'
);
}
if (empty($this->soundbites)) {
$this->soundbites = (new SoundbiteModel())->getEpisodeSoundbites(
$this->getPodcast()->id,
$this->id
);
}
return $this->soundbites;
}
public function getLink()
{
return base_url(

View File

@ -0,0 +1,39 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity;
class Soundbite extends Entity
{
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'episode_id' => 'integer',
'start_time' => 'float',
'duration' => 'float',
'label' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function setCreatedBy(\App\Entities\User $user)
{
$this->attributes['created_by'] = $user->id;
return $this;
}
public function setUpdatedBy(\App\Entities\User $user)
{
$this->attributes['updated_by'] = $user->id;
return $this;
}
}

View File

@ -29,6 +29,7 @@ class User extends \Myth\Auth\Entities\User
* when they are accessed.
*/
protected $casts = [
'id' => 'integer',
'active' => 'boolean',
'force_pass_reset' => 'boolean',
'podcast_role' => '?string',

View File

@ -255,6 +255,19 @@ function get_rss_feed($podcast, $serviceSlug = '')
$chaptersElement->addAttribute('type', 'application/json+chapters');
}
foreach ($episode->soundbites as $soundbite) {
$soundbiteElement = $item->addChild(
'soundbite',
empty($soundbite->label) ? null : $soundbite->label,
$podcast_namespace
);
$soundbiteElement->addAttribute(
'start_time',
$soundbite->start_time
);
$soundbiteElement->addAttribute('duration', $soundbite->duration);
}
$episode->is_blocked &&
$item->addChild('block', 'Yes', $itunes_namespace);
}

View File

@ -30,4 +30,5 @@ return [
'players' => 'players',
'listening-time' => 'listening time',
'time-periods' => 'time periods',
'soundbites' => 'soundbites',
];

View File

@ -82,4 +82,23 @@ return [
'submit_create' => 'Create episode',
'submit_edit' => 'Save episode',
],
'soundbites' => 'Soundbites',
'soundbites_form' => [
'title' => 'Edit soundbites',
'info_section_title' => 'Episode soundbites',
'info_section_subtitle' => 'Add, edit or delete soundbites',
'start_time' => 'Start',
'start_time_hint' =>
'The first second of the soundbite, it can be a decimal number.',
'duration' => 'Duration',
'duration_hint' =>
'The duration of the soundbite (in seconds), it can be a decimal number.',
'label' => 'Label',
'label_hint' => 'Text that will be displayed.',
'play' => 'Play soundbite',
'delete' => 'Delete soundbite',
'bookmark' =>
'Click while playing to get current position, click again to get duration.',
'submit_edit' => 'Save all soundbites',
],
];

View File

@ -30,4 +30,5 @@ return [
'players' => 'lecteurs',
'listening-time' => 'drée découte',
'time-periods' => 'périodes',
'soundbites' => 'extraits sonores',
];

View File

@ -83,4 +83,24 @@ return [
'submit_create' => 'Créer lépisode',
'submit_edit' => 'Enregistrer lépisode',
],
'soundbites' => 'Extraits sonores',
'soundbites_form' => [
'title' => 'Modifier les extraits sonores',
'info_section_title' => 'Extraits sonores de lépisode',
'info_section_subtitle' =>
'Ajouter, modifier ou supprimer des extraits sonores',
'start_time' => 'Début',
'start_time_hint' =>
'La première seconde de lextrait sonore, cela peut être un nombre décimal.',
'duration' => 'Durée',
'duration_hint' =>
'La durée de lextrait sonore (en secondes), cela peut être un nombre décimal.',
'label' => 'Libellé',
'label_hint' => 'Texte qui sera affiché.',
'play' => 'Écouter lextrait sonore',
'delete' => 'Supprimer lextrait sonore',
'bookmark' =>
'Cliquez pour récupérer la position actuelle, cliquez à nouveau pour récupérer la durée.',
'submit_edit' => 'Enregistrer tous les extraits sonores',
],
];

View File

@ -0,0 +1,97 @@
<?php
/**
* Class SoundbiteModel
* Model for podcasts_soundbites table in database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Models;
use CodeIgniter\Model;
class SoundbiteModel extends Model
{
protected $table = 'soundbites';
protected $primaryKey = 'id';
protected $allowedFields = [
'podcast_id',
'episode_id',
'label',
'start_time',
'duration',
'created_by',
'updated_by',
];
protected $returnType = \App\Entities\Soundbite::class;
protected $useSoftDeletes = false;
protected $useTimestamps = true;
protected $afterInsert = ['clearCache'];
protected $afterUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache'];
public function deleteSoundbite($podcastId, $episodeId, $soundbiteId)
{
return $this->delete([
'podcast_id' => $podcastId,
'episode_id' => $episodeId,
'id' => $soundbiteId,
]);
}
/**
* Gets all soundbites for an episode
*
* @param int $podcastId
* @param int $episodeId
*
* @return \App\Entities\Soundbite[]
*/
public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
{
if (!($found = cache("episode{$episodeId}_soundbites"))) {
$found = $this->where([
'episode_id' => $episodeId,
'podcast_id' => $podcastId,
])
->orderBy('start_time')
->findAll();
cache()->save("episode{$episodeId}_soundbites", $found, DECADE);
}
return $found;
}
public function clearCache(array $data)
{
$episode = (new EpisodeModel())->find(
isset($data['data'])
? $data['data']['episode_id']
: $data['id']['episode_id']
);
cache()->delete("episode{$episode->id}_soundbites");
// delete cache for rss feed
cache()->delete("podcast{$episode->id}_feed");
foreach (\Opawg\UserAgentsPhp\UserAgentsRSS::$db as $service) {
cache()->delete(
"podcast{$episode->podcast->id}_feed_{$service['slug']}"
);
}
$supportedLocales = config('App')->supportedLocales;
foreach ($supportedLocales as $locale) {
cache()->delete(
"page_podcast{$episode->podcast->id}_episode{$episode->id}_{$locale}"
);
}
return $data;
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M5 2h14a1 1 0 0 1 1 1v19.143a.5.5 0 0 1-.766.424L12 18.03l-7.234 4.536A.5.5 0 0 1 4 22.143V3a1 1 0 0 1 1-1z"/></svg>

After

Width:  |  Height:  |  Size: 245 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M7.752 5.439l10.508 6.13a.5.5 0 0 1 0 .863l-10.508 6.13A.5.5 0 0 1 7 18.128V5.871a.5.5 0 0 1 .752-.432z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M17.618 5.968l1.453-1.453 1.414 1.414-1.453 1.453a9 9 0 1 1-1.414-1.414zM11 8v6h2V8h-2zM8 1h8v2H8V1z"/></svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -3,7 +3,7 @@ import am4geodata_worldLow from "@amcharts/amcharts4-geodata/worldLow";
import * as am4charts from "@amcharts/amcharts4/charts";
import * as am4core from "@amcharts/amcharts4/core";
import * as am4maps from "@amcharts/amcharts4/maps";
import * as am4plugins_sliceGrouper from "@amcharts/amcharts4/plugins/sliceGrouper";
import * as am4plugins_sliceGrouper from "@amcharts/amcharts4/plugins/sliceGrouper";
import am4themes_material from "@amcharts/amcharts4/themes/material";
const drawPieChart = (chartDivId: string, dataUrl: string | null): void => {
@ -21,7 +21,9 @@ const drawPieChart = (chartDivId: string, dataUrl: string | null): void => {
chart.dataSource.parser.options.emptyAs = 0;
// Add and configure Series
const pieSeries = chart.series.push(new am4charts.PieSeries());
const grouper = pieSeries.plugins.push(new am4plugins_sliceGrouper.SliceGrouper());
const grouper = pieSeries.plugins.push(
new am4plugins_sliceGrouper.SliceGrouper()
);
grouper.limit = 9;
grouper.groupName = "- Other -";
grouper.clickBehavior = "break";
@ -95,13 +97,12 @@ const drawBarChart = (chartDivId: string, dataUrl: string | null): void => {
series.dataFields.categoryX = "labels";
series.name = "Hits";
series.columns.template.tooltipText = "{valueY} hits";
series.columns.template.fillOpacity = .8;
series.columns.template.fillOpacity = 0.8;
const columnTemplate = series.columns.template;
columnTemplate.strokeWidth = 2;
columnTemplate.strokeOpacity = 1;
};
const drawXYDurationChart = (
chartDivId: string,
dataUrl: string | null

View File

@ -0,0 +1,95 @@
let timeout: number | null = null;
const playSoundbite = (
audioPlayer: HTMLAudioElement,
startTime: number,
duration: number
): void => {
audioPlayer.currentTime = startTime;
if (duration > 0) {
audioPlayer.play();
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
timeout = window.setTimeout(() => {
audioPlayer.pause();
timeout = null;
}, duration * 1000);
}
};
const Soundbites = (): void => {
const audioPlayer: HTMLAudioElement | null = document.querySelector("audio");
if (audioPlayer) {
const soundbiteButton: HTMLButtonElement | null = document.querySelector(
"button[data-type='get-soundbite']"
);
if (soundbiteButton) {
const startTimeField: HTMLInputElement | null = document.querySelector(
`input[name="${soundbiteButton.dataset.startTimeFieldName}"]`
);
const durationField: HTMLInputElement | null = document.querySelector(
`input[name="${soundbiteButton.dataset.durationFieldName}"]`
);
if (startTimeField && durationField) {
soundbiteButton.addEventListener("click", () => {
if (startTimeField.value === "") {
startTimeField.value = (
Math.round(audioPlayer.currentTime * 100) / 100
).toString();
} else {
durationField.value = (
Math.round(
(audioPlayer.currentTime - Number(startTimeField.value)) * 100
) / 100
).toString();
}
});
}
}
const soundbitePlayButtons: NodeListOf<
HTMLButtonElement
> | null = document.querySelectorAll("button[data-type='play-soundbite']");
if (soundbitePlayButtons) {
for (let i = 0; i < soundbitePlayButtons.length; i++) {
const soundbitePlayButton: HTMLButtonElement = soundbitePlayButtons[i];
soundbitePlayButton.addEventListener("click", () => {
playSoundbite(
audioPlayer,
Number(soundbitePlayButton.dataset.soundbiteStartTime),
Number(soundbitePlayButton.dataset.soundbiteDuration)
);
});
}
}
const inputFields: NodeListOf<
HTMLInputElement
> | null = document.querySelectorAll("input[data-type='soundbite-field']");
if (inputFields) {
for (let i = 0; i < inputFields.length; i++) {
const inputField: HTMLInputElement = inputFields[i];
const soundbitePlayButton: HTMLButtonElement | null = document.querySelector(
`button[data-type="play-soundbite"][data-soundbite-id="${inputField.dataset.soundbiteId}"]`
);
if (soundbitePlayButton) {
if (inputField.dataset.fieldType == "start-time") {
inputField.addEventListener("input", () => {
soundbitePlayButton.dataset.soundbiteStartTime = inputField.value;
});
} else if (inputField.dataset.fieldType == "duration") {
inputField.addEventListener("input", () => {
soundbitePlayButton.dataset.soundbiteDuration = inputField.value;
});
}
}
}
}
}
};
export default Soundbites;

View File

@ -0,0 +1,3 @@
import Soundbites from "./modules/Soundbites";
Soundbites();

View File

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/assets/admin.css"/>
<link rel="stylesheet" href="/assets/index.css"/>
<script src="/assets/admin.js" type="module" defer></script>
<script src="/assets/soundbites.js" type="module" defer></script>
</head>
<body class="relative bg-gray-100 holy-grail-grid">

View File

@ -11,10 +11,12 @@
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(lang('Episode.create'), route_to('episode-create', $podcast->id), [
'variant' => 'primary',
'iconLeft' => 'add',
]) ?>
<?= button(
lang('Episode.create'),
route_to('episode-create', $podcast->id),
['variant' => 'primary', 'iconLeft' => 'add']
) ?>
<?= $this->endSection() ?>
@ -59,6 +61,13 @@
$podcast->id,
$episode->id
) ?>"><?= lang('Episode.edit') ?></a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'soundbites-edit',
$podcast->id,
$episode->id
) ?>"><?= lang(
'Episode.soundbites'
) ?></a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'episode',
$podcast->name,

View File

@ -0,0 +1,198 @@
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Episode.soundbites_form.title') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Episode.soundbites_form.title') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open_multipart(
route_to('episode-soundbites-edit', $podcast->id, $episode->id),
['method' => 'post', 'class' => 'flex flex-col']
) ?>
<?= csrf_field() ?>
<?= form_section(
lang('Episode.soundbites_form.info_section_title'),
lang('Episode.soundbites_form.info_section_subtitle')
) ?>
<table class="w-full table-fixed">
<thead>
<tr>
<th class="w-3/12 px-1 py-2">
<?= form_label(
lang('Episode.soundbites_form.start_time'),
'start_time',
[],
lang('Episode.soundbites_form.start_time_hint')
) ?></th>
<th class="w-3/12 px-1 py-2"><?= form_label(
lang('Episode.soundbites_form.duration'),
'duration',
[],
lang('Episode.soundbites_form.duration_hint')
) ?></th>
<th class="w-7/12 px-1 py-2"><?= form_label(
lang('Episode.soundbites_form.label'),
'label',
[],
lang('Episode.soundbites_form.label_hint'),
true
) ?></th>
<th class="w-1/12 px-1 py-2"></th>
</tr>
</thead>
<tbody>
<?php foreach ($episode->soundbites as $soundbite): ?>
<tr>
<td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
[
'id' => "soundbites_array[{$soundbite->id}][start_time]",
'name' => "soundbites_array[{$soundbite->id}][start_time]",
'class' => 'form-input w-full border-none text-center',
'value' => $soundbite->start_time,
'data-type' => 'soundbite-field',
'data-field-type' => 'start-time',
'data-soundbite-id' => $soundbite->id,
'required' => 'required',
'min' => '0',
]
) ?></td>
<td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
[
'id' => "soundbites_array[{$soundbite->id}][duration]",
'name' => "soundbites_array[{$soundbite->id}][duration]",
'class' => 'form-input w-full border-none text-center',
'value' => $soundbite->duration,
'data-type' => 'soundbite-field',
'data-field-type' => 'duration',
'data-soundbite-id' => $soundbite->id,
'required' => 'required',
'min' => '0',
]
) ?></td>
<td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
[
'id' => "soundbites_array[{$soundbite->id}][label]",
'name' => "soundbites_array[{$soundbite->id}][label]",
'class' => 'form-input w-full border-none',
'value' => $soundbite->label,
]
) ?></td>
<td class="px-4 py-2"><?= icon_button(
'play',
lang('Episode.soundbites_form.play'),
null,
['variant' => 'primary'],
[
'class' => 'mb-1 mr-1',
'data-type' => 'play-soundbite',
'data-soundbite-id' => $soundbite->id,
'data-soundbite-start-time' => $soundbite->start_time,
'data-soundbite-duration' => $soundbite->duration,
]
) ?>
<?= icon_button(
'delete-bin',
lang('Episode.soundbites_form.delete'),
route_to(
'soundbite-delete',
$podcast->id,
$episode->id,
$soundbite->id
),
['variant' => 'danger'],
[]
) ?>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
[
'id' => 'soundbites_array[0][start_time]',
'name' => 'soundbites_array[0][start_time]',
'class' => 'form-input w-full border-none text-center',
'value' => old('start_time'),
'data-soundbite-id' => '0',
'data-type' => 'soundbite-field',
'data-field-type' => 'start-time',
'min' => '0',
]
) ?></td>
<td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
[
'id' => 'soundbites_array[0][duration]',
'name' => 'soundbites_array[0][duration]',
'class' => 'form-input w-full border-none text-center',
'value' => old('duration'),
'data-soundbite-id' => '0',
'data-type' => 'soundbite-field',
'data-field-type' => 'duration',
'min' => '0',
]
) ?></td>
<td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
[
'id' => 'soundbites_array[0][label]',
'name' => 'soundbites_array[0][label]',
'class' => 'form-input w-full border-none',
'value' => old('label'),
]
) ?></td>
<td class="px-4 py-2"><?= icon_button(
'play',
lang('Episode.soundbites_form.play'),
null,
['variant' => 'primary'],
[
'data-type' => 'play-soundbite',
'data-soundbite-id' => 0,
'data-soundbite-start-time' => 0,
'data-soundbite-duration' => 0,
]
) ?>
</td>
</tr>
<tr><td colspan="3">
<audio controls preload="auto" class="w-full">
<source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
</td><td class="px-4 py-2"><?= icon_button(
'timer',
lang('Episode.soundbites_form.bookmark'),
null,
['variant' => 'info'],
[
'data-type' => 'get-soundbite',
'data-start-time-field-name' =>
'soundbites_array[0][start_time]',
'data-duration-field-name' => 'soundbites_array[0][duration]',
]
) ?></td></tr>
</tbody>
</table>
<?= form_section_close() ?>
<?= button(
lang('Episode.soundbites_form.submit_edit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>
<?= $this->endSection() ?>

View File

@ -22,7 +22,7 @@
alt="Episode cover"
class="object-cover w-full"
/>
<audio controls preload="none" class="w-full mb-6">
<audio controls preload="auto" class="w-full mb-6">
<source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
@ -51,6 +51,57 @@
</section>
</div>
<div class="mb-12">
<?= button(
lang('Episode.soundbites_form.title'),
route_to('soundbites-edit', $podcast->id, $episode->id),
['variant' => 'info', 'iconLeft' => 'edit'],
['class' => 'mb-4']
) ?>
<?php if (count($episode->soundbites) > 0): ?>
<?= data_table(
[
[
'header' => 'Play',
'cell' => function ($soundbite) {
return icon_button(
'play',
lang('Episode.soundbites_form.play'),
null,
['variant' => 'primary'],
[
'class' => 'mb-1 mr-1',
'data-type' => 'play-soundbite',
'data-soundbite-start-time' =>
$soundbite->start_time,
'data-soundbite-duration' => $soundbite->duration,
]
);
},
],
[
'header' => lang('Episode.soundbites_form.start_time'),
'cell' => function ($soundbite) {
return format_duration($soundbite->start_time);
},
],
[
'header' => lang('Episode.soundbites_form.duration'),
'cell' => function ($soundbite) {
return format_duration($soundbite->duration);
},
],
[
'header' => lang('Episode.soundbites_form.label'),
'cell' => function ($soundbite) {
return $soundbite->label;
},
],
],
$episode->soundbites
) ?>
<?php endif; ?>
</div>
<div class="mb-12 text-center">
<h2><?= lang('Charts.episode_by_day') ?></h2>

View File

@ -15,6 +15,7 @@
<link rel="stylesheet" href="/assets/index.css"/>
<link rel="canonical" href="<?= current_url() ?>" />
<script src="/assets/podcast.js" type="module" defer></script>
<script src="/assets/soundbites.js" type="module" defer></script>
<meta property="og:title" content="<?= $episode->title ?>" />
<meta property="og:locale" content="<?= $podcast->language_code ?>" />
<meta property="og:site_name" content="<?= $podcast->title ?>" />
@ -107,6 +108,52 @@
</div>
</header>
<?php if (count($episode->soundbites) > 0): ?>
<div class="w-full max-w-3xl px-2 py-6 mx-auto md:px-6">
<?= data_table(
[
[
'header' => lang('Episode.soundbites'),
'cell' => function ($soundbite) {
return icon_button(
'play',
lang('Episode.soundbites_form.play'),
null,
['variant' => 'primary'],
[
'class' => 'mb-1 mr-1',
'data-type' => 'play-soundbite',
'data-soundbite-start-time' =>
$soundbite->start_time,
'data-soundbite-duration' => $soundbite->duration,
]
);
},
],
[
'header' => lang('Episode.soundbites_form.start_time'),
'cell' => function ($soundbite) {
return format_duration($soundbite->start_time);
},
],
[
'header' => lang('Episode.soundbites_form.duration'),
'cell' => function ($soundbite) {
return format_duration($soundbite->duration);
},
],
[
'header' => lang('Episode.soundbites_form.label'),
'cell' => function ($soundbite) {
return $soundbite->label;
},
],
],
$episode->soundbites
) ?>
</div>
<?php endif; ?>
<section class="w-full max-w-3xl px-2 py-6 mx-auto prose md:px-6">
<?= $episode->description_html ?>
</section>