diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 23c5e95152c5ce5e87ace05e444b0df2fc0d4cd4..cfbc02c9b88be296bc3cabd726a0300e7ced386a 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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', + ] + ); }); }); diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php index 4f2531c8cdb02fb00602fd86e4502ffc53869449..f88c49b277802b6e7ab2ca4de3a4c9bbe307686a 100644 --- a/app/Controllers/Admin/Episode.php +++ b/app/Controllers/Admin/Episode.php @@ -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, + ]); + } } diff --git a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php b/app/Database/Migrations/2020-06-05-180000_add_soundbites.php new file mode 100644 index 0000000000000000000000000000000000000000..57af8f31769ebf0511dd34a48c9b964780848182 --- /dev/null +++ b/app/Database/Migrations/2020-06-05-180000_add_soundbites.php @@ -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'); + } +} diff --git a/app/Entities/Category.php b/app/Entities/Category.php index aa1e32d9a0561d197f1be69aca073ef3c993621b..e233f5a5e07687fc5da742a8921c388f8b3df08c 100644 --- a/app/Entities/Category.php +++ b/app/Entities/Category.php @@ -19,6 +19,7 @@ class Category extends Entity protected $parent; protected $casts = [ + 'id' => 'integer', 'parent_id' => 'integer', 'code' => 'string', 'apple_category' => 'string', diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index b316e97e292d75f09fbe075e7cb1e3ea96c1a0a6..5d240f9c2d08a52ad19b172c22fb12ddb69dc447 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -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( diff --git a/app/Entities/Soundbite.php b/app/Entities/Soundbite.php new file mode 100644 index 0000000000000000000000000000000000000000..33373c8bde834c34f0af390d4590ff948207ece8 --- /dev/null +++ b/app/Entities/Soundbite.php @@ -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; + } +} diff --git a/app/Entities/User.php b/app/Entities/User.php index 6a3e7a1fcfcb48f5b731b5705609aefe0f761f9b..2c94df540a20d4c467f03749bcc34ef022243941 100644 --- a/app/Entities/User.php +++ b/app/Entities/User.php @@ -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', diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index 88baff7196466fab4a31c290677d93f5054279e8..a00a2b5e2f5561978e3cb01d1d9a36c22ad65ec1 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -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); } diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php index 8fcfcbebd616232a42417eb52c6b6d2783c4a4c9..27301ab73a22642f900756c65809c9290700520b 100644 --- a/app/Language/en/Breadcrumb.php +++ b/app/Language/en/Breadcrumb.php @@ -30,4 +30,5 @@ return [ 'players' => 'players', 'listening-time' => 'listening time', 'time-periods' => 'time periods', + 'soundbites' => 'soundbites', ]; diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php index ea045e49d44b773e38932b8a1c2e169be1afc212..94d175c9ab255208dc4f9fb12c3275bc3ede86c0 100644 --- a/app/Language/en/Episode.php +++ b/app/Language/en/Episode.php @@ -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', + ], ]; diff --git a/app/Language/fr/Breadcrumb.php b/app/Language/fr/Breadcrumb.php index de8d5f8084b7ca29f050d5eb0b29eb509b97df47..71d8c331d9303395334e10d84c1d0a94f89ba487 100644 --- a/app/Language/fr/Breadcrumb.php +++ b/app/Language/fr/Breadcrumb.php @@ -30,4 +30,5 @@ return [ 'players' => 'lecteurs', 'listening-time' => 'drée d’écoute', 'time-periods' => 'périodes', + 'soundbites' => 'extraits sonores', ]; diff --git a/app/Language/fr/Episode.php b/app/Language/fr/Episode.php index 25911e2a5d87f793689ba66ae272f9fa86d49183..36d1daac64962d5756b243ead42d58a7ddfbb762 100644 --- a/app/Language/fr/Episode.php +++ b/app/Language/fr/Episode.php @@ -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', + ], ]; diff --git a/app/Models/SoundbiteModel.php b/app/Models/SoundbiteModel.php new file mode 100644 index 0000000000000000000000000000000000000000..fa55d5927c967fc85bae34bc92ab32a965e50637 --- /dev/null +++ b/app/Models/SoundbiteModel.php @@ -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; + } +} diff --git a/app/Views/_assets/icons/bookmark.svg b/app/Views/_assets/icons/bookmark.svg new file mode 100644 index 0000000000000000000000000000000000000000..f340d6ed246d92df66f1d1be3101a0d0c489624c --- /dev/null +++ b/app/Views/_assets/icons/bookmark.svg @@ -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> \ No newline at end of file diff --git a/app/Views/_assets/icons/play.svg b/app/Views/_assets/icons/play.svg new file mode 100644 index 0000000000000000000000000000000000000000..4978d3d5b5d56da9ac0cd84dd565d21aeb1ec076 --- /dev/null +++ b/app/Views/_assets/icons/play.svg @@ -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> \ No newline at end of file diff --git a/app/Views/_assets/icons/timer.svg b/app/Views/_assets/icons/timer.svg new file mode 100644 index 0000000000000000000000000000000000000000..4f2136e6e9ef1654f4f3e4d151ef7db7921785ec --- /dev/null +++ b/app/Views/_assets/icons/timer.svg @@ -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> \ No newline at end of file diff --git a/app/Views/_assets/modules/Charts.ts b/app/Views/_assets/modules/Charts.ts index 777a0d8e1a63e8b2208e6e2cf90917e84d2b2f9e..08014db121679caa4b6a9ab8896112af61d4748f 100644 --- a/app/Views/_assets/modules/Charts.ts +++ b/app/Views/_assets/modules/Charts.ts @@ -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 diff --git a/app/Views/_assets/modules/Soundbites.ts b/app/Views/_assets/modules/Soundbites.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8f7ffbf0c33bd6efd27d370f4008d88f0080875 --- /dev/null +++ b/app/Views/_assets/modules/Soundbites.ts @@ -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; diff --git a/app/Views/_assets/soundbites.ts b/app/Views/_assets/soundbites.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa8b6be11633e94f747419f15274cdbfd2371e21 --- /dev/null +++ b/app/Views/_assets/soundbites.ts @@ -0,0 +1,3 @@ +import Soundbites from "./modules/Soundbites"; + +Soundbites(); diff --git a/app/Views/admin/_layout.php b/app/Views/admin/_layout.php index 6a8d1706679c353851225363792b033cecdfd09b..3f1289cc29c55cdbee539e237bb3d42e460e1224 100644 --- a/app/Views/admin/_layout.php +++ b/app/Views/admin/_layout.php @@ -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"> diff --git a/app/Views/admin/episode/list.php b/app/Views/admin/episode/list.php index dff7ceffb1e889f612f4786975f64b8631ab3e59..da3170e9f07998d46a931156ee3a3ecaf957482a 100644 --- a/app/Views/admin/episode/list.php +++ b/app/Views/admin/episode/list.php @@ -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, diff --git a/app/Views/admin/episode/soundbites.php b/app/Views/admin/episode/soundbites.php new file mode 100644 index 0000000000000000000000000000000000000000..1a5ff48404b8e9cad1eb95caaa83e3916120492f --- /dev/null +++ b/app/Views/admin/episode/soundbites.php @@ -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() ?> diff --git a/app/Views/admin/episode/view.php b/app/Views/admin/episode/view.php index d36151f19e277e6dd523a46344c210ad5cc4a0e3..74744e47366bdfba9474d720d673343d58111f8b 100644 --- a/app/Views/admin/episode/view.php +++ b/app/Views/admin/episode/view.php @@ -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> diff --git a/app/Views/episode.php b/app/Views/episode.php index 4b04a7d3b78e792b67cc7e7421cc64fe977bc1cd..08a6ae1191b439ef0705ce649cf8299ee97c0c29 100644 --- a/app/Views/episode.php +++ b/app/Views/episode.php @@ -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>