mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
feat(rss): add soundbites according to the podcastindex specs
Closes #83
This commit is contained in:
parent
0571a075da
commit
6b34617d07
@ -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',
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
77
app/Database/Migrations/2020-06-05-180000_add_soundbites.php
Normal file
77
app/Database/Migrations/2020-06-05-180000_add_soundbites.php
Normal 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');
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ class Category extends Entity
|
||||
protected $parent;
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'parent_id' => 'integer',
|
||||
'code' => 'string',
|
||||
'apple_category' => 'string',
|
||||
|
@ -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 episode’s 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(
|
||||
|
39
app/Entities/Soundbite.php
Normal file
39
app/Entities/Soundbite.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -30,4 +30,5 @@ return [
|
||||
'players' => 'players',
|
||||
'listening-time' => 'listening time',
|
||||
'time-periods' => 'time periods',
|
||||
'soundbites' => 'soundbites',
|
||||
];
|
||||
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
@ -30,4 +30,5 @@ return [
|
||||
'players' => 'lecteurs',
|
||||
'listening-time' => 'drée d’écoute',
|
||||
'time-periods' => 'périodes',
|
||||
'soundbites' => 'extraits sonores',
|
||||
];
|
||||
|
@ -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 l’extrait sonore, cela peut être un nombre décimal.',
|
||||
'duration' => 'Durée',
|
||||
'duration_hint' =>
|
||||
'La durée de l’extrait sonore (en secondes), cela peut être un nombre décimal.',
|
||||
'label' => 'Libellé',
|
||||
'label_hint' => 'Texte qui sera affiché.',
|
||||
'play' => 'Écouter l’extrait sonore',
|
||||
'delete' => 'Supprimer l’extrait 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',
|
||||
],
|
||||
];
|
||||
|
97
app/Models/SoundbiteModel.php
Normal file
97
app/Models/SoundbiteModel.php
Normal 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;
|
||||
}
|
||||
}
|
1
app/Views/_assets/icons/bookmark.svg
Normal file
1
app/Views/_assets/icons/bookmark.svg
Normal 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 |
1
app/Views/_assets/icons/play.svg
Normal file
1
app/Views/_assets/icons/play.svg
Normal 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 |
1
app/Views/_assets/icons/timer.svg
Normal file
1
app/Views/_assets/icons/timer.svg
Normal 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 |
@ -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
|
||||
|
95
app/Views/_assets/modules/Soundbites.ts
Normal file
95
app/Views/_assets/modules/Soundbites.ts
Normal 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;
|
3
app/Views/_assets/soundbites.ts
Normal file
3
app/Views/_assets/soundbites.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Soundbites from "./modules/Soundbites";
|
||||
|
||||
Soundbites();
|
@ -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">
|
||||
|
@ -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,
|
||||
|
198
app/Views/admin/episode/soundbites.php
Normal file
198
app/Views/admin/episode/soundbites.php
Normal 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() ?>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user