From de19317138a2106deb825c1eed7dda036ed7dac3 Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> Date: Mon, 3 Jan 2022 13:52:07 +0000 Subject: [PATCH] feat(soundbites): add soundbite list and creation forms with audio-clipper component --- .../2021-12-09-130000_add_clips.php | 3 +- app/Entities/Clip/BaseClip.php | 4 +- app/Helpers/rss_helper.php | 4 +- app/Models/ClipModel.php | 76 ++++--- app/Resources/js/admin.ts | 3 +- app/Resources/js/modules/Soundbites.ts | 87 -------- app/Resources/js/modules/play-soundbite.ts | 198 ++++++++++++++++++ modules/Admin/Config/Routes.php | 21 +- .../Admin/Controllers/EpisodeController.php | 78 ------- .../Admin/Controllers/SoundbiteController.php | 163 ++++++++++++++ .../Controllers/VideoClipsController.php | 8 +- modules/Admin/Language/en/Episode.php | 19 -- .../Admin/Language/en/EpisodeNavigation.php | 3 +- modules/Admin/Language/en/Soundbite.php | 27 +++ modules/Admin/Language/fr/Episode.php | 20 -- .../Admin/Language/fr/EpisodeNavigation.php | 3 +- modules/Admin/Language/fr/Soundbite.php | 27 +++ themes/cp_admin/episode/_sidebar.php | 2 +- themes/cp_admin/episode/soundbites.php | 72 ------- themes/cp_admin/episode/soundbites_list.php | 48 +++++ themes/cp_admin/episode/soundbites_new.php | 35 ++++ themes/cp_admin/episode/video_clip.php | 4 +- themes/cp_admin/episode/video_clips_list.php | 4 +- themes/cp_admin/episode/video_clips_new.php | 4 +- 24 files changed, 582 insertions(+), 331 deletions(-) delete mode 100644 app/Resources/js/modules/Soundbites.ts create mode 100644 app/Resources/js/modules/play-soundbite.ts create mode 100644 modules/Admin/Controllers/SoundbiteController.php create mode 100644 modules/Admin/Language/en/Soundbite.php create mode 100644 modules/Admin/Language/fr/Soundbite.php delete mode 100644 themes/cp_admin/episode/soundbites.php create mode 100644 themes/cp_admin/episode/soundbites_list.php create mode 100644 themes/cp_admin/episode/soundbites_new.php diff --git a/app/Database/Migrations/2021-12-09-130000_add_clips.php b/app/Database/Migrations/2021-12-09-130000_add_clips.php index 0c42a92299..cf5a24fd54 100644 --- a/app/Database/Migrations/2021-12-09-130000_add_clips.php +++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php @@ -39,10 +39,9 @@ class AddClips extends Migration 'type' => 'DECIMAL(7,3)', 'unsigned' => true, ], - 'label' => [ + 'title' => [ 'type' => 'VARCHAR', 'constraint' => 128, - 'null' => true, ], 'type' => [ 'type' => 'ENUM', diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php index 00057c2ed2..e989d86542 100644 --- a/app/Entities/Clip/BaseClip.php +++ b/app/Entities/Clip/BaseClip.php @@ -29,7 +29,7 @@ use Modules\Auth\Entities\User; * @property Podcast $podcast * @property int $episode_id * @property Episode $episode - * @property string $label + * @property string $title * @property double $start_time * @property double $end_time * @property double $duration @@ -68,7 +68,7 @@ class BaseClip extends Entity 'id' => 'integer', 'podcast_id' => 'integer', 'episode_id' => 'integer', - 'label' => 'string', + 'title' => 'string', 'start_time' => 'double', 'duration' => 'double', 'type' => 'string', diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index bee957f962..d30520986e 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -255,7 +255,7 @@ if (! function_exists('get_rss_feed')) { $comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug)); $comments->addAttribute('contentType', 'application/podcast-activity+json'); - if ($episode->transcript->file_url !== '') { + if ($episode->transcript !== null) { $transcriptElement = $item->addChild('transcript', null, $podcastNamespace); $transcriptElement->addAttribute('url', $episode->transcript->file_url); $transcriptElement->addAttribute( @@ -275,7 +275,7 @@ if (! function_exists('get_rss_feed')) { foreach ($episode->soundbites as $soundbite) { // TODO: differentiate video from soundbites? - $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace); + $soundbiteElement = $item->addChild('soundbite', $soundbite->title, $podcastNamespace); $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time); $soundbiteElement->addAttribute('duration', (string) $soundbite->duration); } diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php index 94364cd209..fca84b0ce5 100644 --- a/app/Models/ClipModel.php +++ b/app/Models/ClipModel.php @@ -39,7 +39,7 @@ class ClipModel extends Model 'id', 'podcast_id', 'episode_id', - 'label', + 'title', 'start_time', 'duration', 'type', @@ -89,33 +89,6 @@ class ClipModel extends Model parent::__construct($db, $validation); } - /** - * Gets all clips for an episode - * - * @return Soundbite[] - */ - public function getEpisodeSoundbites(int $podcastId, int $episodeId): array - { - $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites"; - if (! ($found = cache($cacheName))) { - $found = $this->where([ - 'episode_id' => $episodeId, - 'podcast_id' => $podcastId, - 'type' => 'audio', - ]) - ->orderBy('start_time') - ->findAll(); - - foreach ($found as $key => $soundbite) { - $found[$key] = new Soundbite($soundbite->toArray()); - } - - cache() - ->save($cacheName, $found, DECADE); - } - return $found; - } - public function getVideoClipById(int $videoClipId): ?VideoClip { $cacheName = "video-clip#{$videoClipId}"; @@ -184,6 +157,53 @@ class ClipModel extends Model return $found; } + public function getSoundbiteById(int $soundbiteId): ?Soundbite + { + $cacheName = "soundbite#{$soundbiteId}"; + if (! ($found = cache($cacheName))) { + $clip = $this->find($soundbiteId); + + if ($clip === null) { + return null; + } + + // @phpstan-ignore-next-line + $found = new Soundbite($clip->toArray()); + + cache() + ->save($cacheName, $found, DECADE); + } + + return $found; + } + + /** + * Gets all clips for an episode + * + * @return Soundbite[] + */ + public function getEpisodeSoundbites(int $podcastId, int $episodeId): array + { + $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_soundbites"; + if (! ($found = cache($cacheName))) { + $found = $this->where([ + 'episode_id' => $episodeId, + 'podcast_id' => $podcastId, + 'type' => 'audio', + ]) + ->orderBy('start_time') + ->findAll(); + + foreach ($found as $key => $soundbite) { + $found[$key] = new Soundbite($soundbite->toArray()); + } + + cache() + ->save($cacheName, $found, DECADE); + } + return $found; + } + public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool { cache() diff --git a/app/Resources/js/admin.ts b/app/Resources/js/admin.ts index 310cc336b8..d9ee93cb82 100644 --- a/app/Resources/js/admin.ts +++ b/app/Resources/js/admin.ts @@ -10,11 +10,11 @@ import "./modules/markdown-preview"; import "./modules/markdown-write-preview"; import MultiSelect from "./modules/MultiSelect"; import "./modules/permalink-edit"; +import "./modules/play-soundbite"; import PublishMessageWarning from "./modules/PublishMessageWarning"; import Select from "./modules/Select"; import SidebarToggler from "./modules/SidebarToggler"; import Slugify from "./modules/Slugify"; -import Soundbites from "./modules/Soundbites"; import ThemePicker from "./modules/ThemePicker"; import Time from "./modules/Time"; import Tooltip from "./modules/Tooltip"; @@ -31,7 +31,6 @@ SidebarToggler(); ClientTimezone(); DateTimePicker(); Time(); -Soundbites(); Clipboard(); ThemePicker(); PublishMessageWarning(); diff --git a/app/Resources/js/modules/Soundbites.ts b/app/Resources/js/modules/Soundbites.ts deleted file mode 100644 index 16fb174b44..0000000000 --- a/app/Resources/js/modules/Soundbites.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * TODO: refactor file - */ -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", () => { - // get values from inputs to play soundbite - const startTime: HTMLInputElement | null | undefined = - soundbitePlayButton.parentElement?.parentElement?.querySelector( - 'input[data-field-type="start_time"]' - ); - const duration: HTMLInputElement | null | undefined = - soundbitePlayButton.parentElement?.parentElement?.querySelector( - 'input[data-field-type="duration"]' - ); - - if (startTime && duration) { - playSoundbite( - audioPlayer, - parseFloat(startTime.value), - parseFloat(duration.value) - ); - } - }); - } - } - } -}; - -export default Soundbites; diff --git a/app/Resources/js/modules/play-soundbite.ts b/app/Resources/js/modules/play-soundbite.ts new file mode 100644 index 0000000000..26f24086dc --- /dev/null +++ b/app/Resources/js/modules/play-soundbite.ts @@ -0,0 +1,198 @@ +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +@customElement("play-soundbite") +export class PlaySoundbite extends LitElement { + @property({ attribute: "audio-src" }) + audioSrc!: string; + + @property({ type: Number, attribute: "start-time" }) + startTime!: number; + + @property({ type: Number }) + duration!: number; + + @property({ attribute: "play-label" }) + playLabel!: string; + + @property({ attribute: "playing-label" }) + playingLabel!: string; + + @state() + _audio: HTMLAudioElement | null = null; + + @state() + _isPlaying = false; + + @state() + _isLoading = false; + + _audioEvents = [ + { + name: "play", + onEvent: () => { + this._isPlaying = true; + }, + }, + { + name: "pause", + onEvent: () => { + this._isPlaying = false; + }, + }, + { + name: "timeupdate", + onEvent: () => { + if (this._audio) { + console.log( + this._audio.currentTime, + this.startTime, + this.startTime + this.duration + ); + if (this._audio.currentTime < this.startTime) { + this._isLoading = true; + this._audio.currentTime = this.startTime; + } else if (this._audio.currentTime > this.startTime + this.duration) { + this.stopSoundbite(); + } else { + this._isLoading = false; + } + } + }, + }, + ]; + + playSoundbite() { + if (this._audio === null) { + this._audio = new Audio(this.audioSrc); + for (const event of this._audioEvents) { + this._audio.addEventListener(event.name, event.onEvent); + } + } + + this._audio.currentTime = this.startTime; + this._audio.play(); + } + + stopSoundbite() { + if (this._audio !== null) { + this._audio.pause(); + this._audio.currentTime = this.startTime; + } + } + + disconnectedCallback(): void { + if (this._audio) { + for (const event of this._audioEvents) { + this._audio.removeEventListener(event.name, event.onEvent); + } + } + } + + static styles = css` + button { + background-color: hsl(var(--color-accent-base)); + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.5rem; + font-size: 0.875rem; + border: 2px solid transparent; + border-radius: 9999px; + + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + } + + button:hover { + background-color: hsl(var(--color-accent-hover)); + } + + button:focus { + outline: none; + box-shadow: 0 0 0 2px hsl(var(--color-background-base)), + 0 0 0 4px hsl(var(--color-accent-base)); + } + + button.playing { + background-color: hsl(var(--color-background-base)); + border: 2px solid hsl(var(--color-accent-base)); + } + + button.playing:hover { + background-color: hsl(var(--color-background-elevated)); + } + + button.playing svg { + color: hsl(var(--color-accent-base)); + } + + svg { + color: hsl(var(--color-accent-contrast)); + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .animate-spin { + animation: spin 3s linear infinite; + } + `; + + render(): TemplateResult<1> { + return html`<button + @click="${this._isPlaying ? this.stopSoundbite : this.playSoundbite}" + title="${this._isPlaying ? this.playingLabel : this.playLabel}" + > + ${this._isLoading + ? html`<svg + class="animate-spin" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + > + <circle + opacity="0.25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + stroke-width="4" + ></circle> + <path + opacity="0.75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" + ></path> + </svg>` + : this._isPlaying + ? html`<svg + class="animate-spin" + viewBox="0 0 24 24" + fill="currentColor" + width="1em" + height="1em" + > + <g> + <path fill="none" d="M0 0h24v24H0z" /> + <path + d="M13 9.17A3 3 0 1 0 15 12V2.458c4.057 1.274 7 5.064 7 9.542 0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2c.337 0 .671.017 1 .05v7.12z" + /> + </g> + </svg>` + : html`<svg + viewBox="0 0 24 24" + fill="currentColor" + width="1em" + height="1em" + > + <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>`} + </button>`; + } +} diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index dcaf460e12..c22fba6998 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -334,24 +334,33 @@ $routes->group( ); $routes->get( 'soundbites', - 'EpisodeController::soundbitesEdit/$1/$2', + 'SoundbiteController::list/$1/$2', [ - 'as' => 'soundbites-edit', + 'as' => 'soundbites-list', + 'filter' => 'permission:podcast_episodes-edit', + ], + ); + $routes->get( + 'soundbites/new', + 'SoundbiteController::create/$1/$2', + [ + 'as' => 'soundbites-create', 'filter' => 'permission:podcast_episodes-edit', ], ); $routes->post( - 'soundbites', - 'EpisodeController::soundbitesAttemptEdit/$1/$2', + 'soundbites/new', + 'SoundbiteController::attemptCreate/$1/$2', [ + 'as' => 'soundbites-create', 'filter' => 'permission:podcast_episodes-edit', ], ); $routes->get( 'soundbites/(:num)/delete', - 'EpisodeController::soundbiteDelete/$1/$2/$3', + 'SoundbiteController::delete/$1/$2/$3', [ - 'as' => 'soundbite-delete', + 'as' => 'soundbites-delete', 'filter' => 'permission:podcast_episodes-edit', ], ); diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index 7cdb144a8f..a2a2c57e2b 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -15,7 +15,6 @@ use App\Entities\EpisodeComment; use App\Entities\Location; use App\Entities\Podcast; use App\Entities\Post; -use App\Models\ClipModel; use App\Models\EpisodeCommentModel; use App\Models\EpisodeModel; use App\Models\MediaModel; @@ -719,83 +718,6 @@ class EpisodeController extends BaseController return redirect()->route('episode-list', [$this->podcast->id]); } - public function soundbitesEdit(): string - { - helper(['form']); - - $data = [ - 'podcast' => $this->podcast, - 'episode' => $this->episode, - ]; - - replace_breadcrumb_params([ - 0 => $this->podcast->title, - 1 => $this->episode->title, - ]); - return view('episode/soundbites', $data); - } - - public function soundbitesAttemptEdit(): RedirectResponse - { - $soundbites = $this->request->getPost('soundbites'); - $rules = [ - 'soundbites.0.start_time' => - 'permit_empty|required_with[soundbites.0.duration]|decimal|greater_than_equal_to[0]', - 'soundbites.0.duration' => - 'permit_empty|required_with[soundbites.0.start_time]|decimal|greater_than_equal_to[0]', - ]; - foreach (array_keys($soundbites) as $soundbite_id) { - $rules += [ - "soundbites.{$soundbite_id}.start_time" => 'required|decimal|greater_than_equal_to[0]', - "soundbites.{$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 as $soundbite_id => $soundbite) { - $data = [ - 'podcast_id' => $this->podcast->id, - 'episode_id' => $this->episode->id, - 'start_time' => (float) $soundbite['start_time'], - 'duration' => (float) $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(string $clipId): RedirectResponse - { - (new ClipModel())->deleteClip($this->podcast->id, $this->episode->id, (int) $clipId); - - return redirect()->route('clips-edit', [$this->podcast->id, $this->episode->id]); - } - public function embed(): string { helper(['form']); diff --git a/modules/Admin/Controllers/SoundbiteController.php b/modules/Admin/Controllers/SoundbiteController.php new file mode 100644 index 0000000000..c13b2453c6 --- /dev/null +++ b/modules/Admin/Controllers/SoundbiteController.php @@ -0,0 +1,163 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\Admin\Controllers; + +use App\Entities\Clip\Soundbite; +use App\Entities\Episode; +use App\Entities\Podcast; +use App\Models\ClipModel; +use App\Models\EpisodeModel; +use App\Models\MediaModel; +use App\Models\PodcastModel; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; + +class SoundbiteController extends BaseController +{ + protected Podcast $podcast; + + protected Episode $episode; + + public function _remap(string $method, string ...$params): mixed + { + if ( + ($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->podcast = $podcast; + + if (count($params) > 1) { + if ( + ! ($episode = (new EpisodeModel()) + ->where([ + 'id' => $params[1], + 'podcast_id' => $params[0], + ]) + ->first()) + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->episode = $episode; + + unset($params[1]); + unset($params[0]); + } + + return $this->{$method}(...$params); + } + + public function list(): string + { + $soundbitesBuilder = (new ClipModel('audio')) + ->where([ + 'podcast_id' => $this->podcast->id, + 'episode_id' => $this->episode->id, + 'type' => 'audio', + ]) + ->orderBy('created_at', 'desc'); + + $soundbites = $soundbitesBuilder->paginate(10); + + $data = [ + 'podcast' => $this->podcast, + 'episode' => $this->episode, + 'soundbites' => $soundbites, + 'pager' => $soundbitesBuilder->pager, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => $this->episode->title, + ]); + return view('episode/soundbites_list', $data); + } + + public function create(): string + { + helper(['form']); + + $data = [ + 'podcast' => $this->podcast, + 'episode' => $this->episode, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => $this->episode->title, + ]); + return view('episode/soundbites_new', $data); + } + + public function attemptCreate(): RedirectResponse + { + $rules = [ + 'title' => 'required', + 'start_time' => 'required|greater_than_equal_to[0]', + 'duration' => 'required|greater_than[0]', + ]; + + if (! $this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $newSoundbite = new Soundbite([ + 'title' => $this->request->getPost('title'), + 'start_time' => (float) $this->request->getPost('start_time'), + 'duration' => (float) $this->request->getPost('duration',), + 'type' => 'audio', + 'status' => '', + 'podcast_id' => $this->podcast->id, + 'episode_id' => $this->episode->id, + 'created_by' => user_id(), + 'updated_by' => user_id(), + ]); + + $clipModel = new ClipModel('audio'); + if (! $clipModel->save($newSoundbite)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $clipModel->errors()); + } + + return redirect()->route('soundbites-list', [$this->podcast->id, $this->episode->id]); + } + + public function delete(string $soundbiteId): RedirectResponse + { + $soundbite = (new ClipModel())->getSoundbiteById((int) $soundbiteId); + + if ($soundbite === null) { + throw PageNotFoundException::forPageNotFound(); + } + + if ($soundbite->media === null) { + // delete Clip directly + (new ClipModel())->delete($soundbite->id); + } else { + $mediaModel = new MediaModel(); + if (! $mediaModel->deleteMedia($soundbite->media)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $mediaModel->errors()); + } + } + + return redirect()->route('soundbites-list', [$this->podcast->id, $this->episode->id]); + } +} diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php index 712377e8b2..2552b5eac4 100644 --- a/modules/Admin/Controllers/VideoClipsController.php +++ b/modules/Admin/Controllers/VideoClipsController.php @@ -101,7 +101,7 @@ class VideoClipsController extends BaseController replace_breadcrumb_params([ 0 => $this->podcast->title, 1 => $this->episode->title, - 2 => $videoClip->label, + 2 => $videoClip->title, ]); return view('episode/video_clip', $data); } @@ -140,8 +140,8 @@ class VideoClipsController extends BaseController public function attemptCreate(): RedirectResponse { $rules = [ - 'label' => 'required', - 'start_time' => 'required|numeric', + 'title' => 'required', + 'start_time' => 'required|greater_than_equal_to[0]', 'duration' => 'required|greater_than[0]', 'format' => 'required|in_list[' . implode(',', array_keys(config('MediaClipper')->formats)) . ']', 'theme' => 'required|in_list[' . implode(',', array_keys(config('Colors')->themes)) . ']', @@ -163,7 +163,7 @@ class VideoClipsController extends BaseController ]; $videoClip = new VideoClip([ - 'label' => $this->request->getPost('label'), + 'title' => $this->request->getPost('title'), 'start_time' => (float) $this->request->getPost('start_time'), 'duration' => (float) $this->request->getPost('duration',), 'theme' => $theme, diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php index b3b2354684..7b3d32189d 100644 --- a/modules/Admin/Language/en/Episode.php +++ b/modules/Admin/Language/en/Episode.php @@ -144,25 +144,6 @@ return [ 'understand' => 'I understand, I want to delete the episode', 'submit' => 'Delete', ], - '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' => 'Save soundbites', - ], 'embed' => [ 'title' => 'Embeddable player', 'label' => diff --git a/modules/Admin/Language/en/EpisodeNavigation.php b/modules/Admin/Language/en/EpisodeNavigation.php index 6511ff5c00..8eb2b1b5f6 100644 --- a/modules/Admin/Language/en/EpisodeNavigation.php +++ b/modules/Admin/Language/en/EpisodeNavigation.php @@ -16,7 +16,8 @@ return [ 'episode-persons-manage' => 'Manage persons', 'embed-add' => 'Embeddable player', 'clips' => 'Clips', - 'soundbites-edit' => 'Soundbites', 'video-clips-list' => 'Video clips', 'video-clips-create' => 'New video clip', + 'soundbites-list' => 'Soundbites', + 'soundbites-create' => 'New soundbite', ]; diff --git a/modules/Admin/Language/en/Soundbite.php b/modules/Admin/Language/en/Soundbite.php new file mode 100644 index 0000000000..8faed932fe --- /dev/null +++ b/modules/Admin/Language/en/Soundbite.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'list' => [ + 'title' => 'Soundbites', + 'soundbite' => 'Soundbite', + ], + 'form' => [ + 'title' => 'New soundbite', + 'soundbite_title' => 'Soundbite title', + 'start_time' => 'Start at', + 'duration' => 'Duration', + 'submit' => 'Create soundbite', + ], + 'play' => 'Play soundbite', + 'stop' => 'Stop soundbite', + 'create' => 'New soundbite', + 'delete' => 'Delete soundbite', +]; diff --git a/modules/Admin/Language/fr/Episode.php b/modules/Admin/Language/fr/Episode.php index 796a7aa904..38cb216d1b 100644 --- a/modules/Admin/Language/fr/Episode.php +++ b/modules/Admin/Language/fr/Episode.php @@ -151,26 +151,6 @@ return [ 'understand' => 'Je comprends, Je veux supprimer l’épisode', 'submit' => 'Supprimer', ], - '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' => 'Enregistrer les extraits sonnores', - ], 'embed' => [ 'add' => 'Ajouter un lecteur intégré', 'title' => 'Lecteur intégré', diff --git a/modules/Admin/Language/fr/EpisodeNavigation.php b/modules/Admin/Language/fr/EpisodeNavigation.php index b8c82ff7ad..ac8ca8bf14 100644 --- a/modules/Admin/Language/fr/EpisodeNavigation.php +++ b/modules/Admin/Language/fr/EpisodeNavigation.php @@ -16,7 +16,8 @@ return [ 'episode-persons-manage' => 'Gestion des intervenants', 'embed' => 'Lecteur intégré', 'clips' => 'Extraits', - 'soundbites-edit' => 'Extraits sonores', 'video-clips-list' => 'Extraits video', 'video-clips-create' => 'Nouvel extrait video', + 'soundbites-list' => 'Extraits sonores', + 'soundbites-create' => 'Nouvel extrait sonore', ]; diff --git a/modules/Admin/Language/fr/Soundbite.php b/modules/Admin/Language/fr/Soundbite.php new file mode 100644 index 0000000000..0cb3dddb95 --- /dev/null +++ b/modules/Admin/Language/fr/Soundbite.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'list' => [ + 'title' => 'Extraits sonores', + 'soundbite' => 'Extrait sonore', + ], + 'form' => [ + 'title' => 'Nouvel extrait sonore', + 'soundbite_title' => 'Titre de l’extrait', + 'start_time' => 'Début à ', + 'duration' => 'Durée', + 'submit' => 'Créer l’extrait sonore', + ], + 'play' => 'Lancer l’extrait sonore', + 'stop' => 'Arrêter l’extrait sonore', + 'create' => 'Nouvel extrait sonore', + 'delete' => 'Supprimer l’extrait sonore', +]; diff --git a/themes/cp_admin/episode/_sidebar.php b/themes/cp_admin/episode/_sidebar.php index 10c0affe9d..50b2d703a7 100644 --- a/themes/cp_admin/episode/_sidebar.php +++ b/themes/cp_admin/episode/_sidebar.php @@ -7,7 +7,7 @@ $podcastNavigation = [ ], 'clips' => [ 'icon' => 'clapperboard', - 'items' => ['video-clips-list', 'video-clips-create', 'soundbites-edit'], + 'items' => ['video-clips-list', 'video-clips-create', 'soundbites-list', 'soundbites-create'], ], ]; ?> diff --git a/themes/cp_admin/episode/soundbites.php b/themes/cp_admin/episode/soundbites.php deleted file mode 100644 index 111483e371..0000000000 --- a/themes/cp_admin/episode/soundbites.php +++ /dev/null @@ -1,72 +0,0 @@ -<?= $this->extend('_layout') ?> - -<?= $this->section('title') ?> -<?= lang('Episode.soundbites_form.title') ?> -<?= $this->endSection() ?> - -<?= $this->section('pageTitle') ?> -<?= lang('Episode.soundbites_form.title') ?> -<?= $this->endSection() ?> - -<?= $this->section('headerRight') ?> -<Button variant="primary" type="submit" form="soundbites-form"><?= lang('Episode.soundbites_form.submit') ?></Button> -<?= $this->endSection() ?> - - -<?= $this->section('content') ?> - -<form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-xl"> -<?= csrf_field() ?> - -<Forms.Section - title="<?= lang('Episode.soundbites_form.info_section_title') ?>" - subtitle="<?= lang('Episode.soundbites_form.info_section_subtitle') ?>" > - - <?php - $table = new \CodeIgniter\View\Table(); - - $table->setHeading( - lang('Episode.soundbites_form.start_time') . hint_tooltip(lang('Episode.soundbites_form.start_time_hint')), - lang('Episode.soundbites_form.duration') . hint_tooltip(lang('Episode.soundbites_form.duration_hint')), - lang('Episode.soundbites_form.label') . hint_tooltip(lang('Episode.soundbites_form.label_hint')), - '', - '' - ); - - foreach ($episode->soundbites as $soundbite) { - $table->addRow( - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][start_time]' value='{$soundbite->start_time}' data-type='soundbite-field' data-field-type='start_time' required='true' />", - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[{$soundbite->id}][duration]' value='{$soundbite->duration}' data-type='soundbite-field' data-field-type='duration' required='true' />", - "<Forms.Input class='flex-1' name='soundbites[{$soundbite->id}][label]' value='{$soundbite->label}' />", - "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='{$soundbite->id}'>" . lang('Episode.soundbites_form.play') . '</IconButton>', - '<IconButton uri=' . route_to( - 'soundbite-delete', - $podcast->id, - $episode->id, - $soundbite->id, - ) . " variant='danger' glyph='delete-bin'>" . lang('Episode.soundbites_form.delete') . '</IconButton>' - ); - } - - $table->addRow( - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][start_time]' data-type='soundbite-field' data-field-type='start_time' />", - "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio->duration}' name='soundbites[0][duration]' data-type='soundbite-field' data-field-type='duration' />", - "<Forms.Input class='flex-1' name='soundbites[0][label]' />", - "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='0'>" . lang('Episode.soundbites_form.play') . '</IconButton>', - ); - - echo $table->generate(); - - ?> - - <div class="flex items-center gap-x-2"> - <audio controls preload="auto" class="flex-1 w-full"> - <source src="<?= $episode->audio->file_url ?>" type="<?= $episode->audio->file_mimetype ?>"> - Your browser does not support the audio tag. - </audio> - <IconButton glyph="timer" variant="info" data-type="get-soundbite" data-start-time-field-name="soundbites[0][start_time]" data-duration-field-name="soundbites[0][duration]" ><?= lang('Episode.soundbites_form.bookmark') ?></IconButton> - </div> -</Forms.Section> -</form> - -<?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/soundbites_list.php b/themes/cp_admin/episode/soundbites_list.php new file mode 100644 index 0000000000..d6ee8af171 --- /dev/null +++ b/themes/cp_admin/episode/soundbites_list.php @@ -0,0 +1,48 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Soundbite.list.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Soundbite.list.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('headerRight') ?> +<Button uri="<?= route_to('soundbites-create', $podcast->id, $episode->id) ?>" variant="primary" iconLeft="add"><?= lang('Soundbite.create') ?></Button> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<?= data_table( + [ + [ + 'header' => lang('Soundbite.list.soundbite'), + 'cell' => function ($soundbite): string { + return '<div class="flex gap-x-2"><play-soundbite audio-src="' . $soundbite->episode->audio->file_url . '" start-time="' . $soundbite->start_time . '" duration="' . $soundbite->duration . '" play-label="' . lang('Soundbite.play') . '" playing-label="' . lang('Soundbite.stop') . '"></play-soundbite><div class="flex flex-col"><span class="text-sm font-semibold">' . $soundbite->title . '</span><span class="text-xs">' . format_duration((int) $soundbite->duration) . '</span></div></div>'; + }, + ], + [ + 'header' => lang('Common.actions'), + 'cell' => function ($soundbite): string { + return '<button id="more-dropdown-' . $soundbite->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $soundbite->id . '-menu" aria-haspopup="true" aria-expanded="false">' . + icon('more') . + '</button>' . + '<DropdownMenu id="more-dropdown-' . $soundbite->id . '-menu" labelledby="more-dropdown-' . $soundbite->id . '" offsetY="-24" items="' . esc(json_encode([ + [ + 'type' => 'link', + 'title' => lang('Soundbite.delete'), + 'uri' => route_to('soundbites-delete', $soundbite->podcast_id, $soundbite->episode_id, $soundbite->id), + 'class' => 'font-semibold text-red-600', + ], + ])) . '" />'; + }, + ], + ], + $soundbites, + 'mb-6', +) ?> + +<?= $pager->links() ?> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/soundbites_new.php b/themes/cp_admin/episode/soundbites_new.php new file mode 100644 index 0000000000..0452d37d98 --- /dev/null +++ b/themes/cp_admin/episode/soundbites_new.php @@ -0,0 +1,35 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Soundbite.form.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Soundbite.form.title') ?> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col"> +<?= csrf_field() ?> + + <Forms.Field + name="title" + label="<?= lang('Soundbite.form.soundbite_title') ?>" + required="true" + class="max-w-sm" + /> + <audio-clipper start-time="<?= old('start_time', 0) ?>" duration="<?= old('duration', 30) ?>" min-duration="10" volume=".5" height="50" class="mt-8"> + <audio slot="audio" src="<?= $episode->audio->file_url ?>" preload="auto"> + Your browser does not support the <code>audio</code> element. + </audio> + <input slot="start_time" type="number" name="start_time" placeholder="<?= lang('VideoClip.form.start_time') ?>" step="0.001" /> + <input slot="duration" type="number" name="duration" placeholder="<?= lang('VideoClip.form.duration') ?>" step="0.001" /> + </audio-clipper> + + <Button variant="primary" type="submit" class="self-end mt-4" iconRight="arrow-right"><?= lang('Soundbite.form.submit') ?></Button> + +</form> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/video_clip.php b/themes/cp_admin/episode/video_clip.php index 0504104c25..46f51a1501 100644 --- a/themes/cp_admin/episode/video_clip.php +++ b/themes/cp_admin/episode/video_clip.php @@ -2,13 +2,13 @@ <?= $this->section('title') ?> <?= lang('VideoClip.title', [ - 'videoClipLabel' => $videoClip->label, + 'videoClipLabel' => $videoClip->title, ]) ?> <?= $this->endSection() ?> <?= $this->section('pageTitle') ?> <?= lang('VideoClip.title', [ - 'videoClipLabel' => $videoClip->label, + 'videoClipLabel' => $videoClip->title, ]) ?> <?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php index f79a3b7cf3..6da9bc9d9f 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -62,7 +62,7 @@ use CodeIgniter\I18n\Time; 'portrait' => 'aspect-[9/16]', 'squared' => 'aspect-square', ]; - return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center w-full group gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full ring-2 ring-white -bottom-1 -left-1" data-tooltip="bottom" title="' . lang('Settings.theme.' . $videoClip->theme['name']) . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . $videoClip->format . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '"><Icon glyph="play"/></span></div></div><div class="flex flex-col"><div class="text-sm">#' . $videoClip->id . ' – <span class="font-semibold group-hover:underline">' . $videoClip->label . '</span><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span></div><span class="text-xs">' . format_duration((int) $videoClip->duration) . '</span></div></a>'; + return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center w-full group gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full ring-2 ring-white -bottom-1 -left-1" data-tooltip="bottom" title="' . lang('Settings.theme.' . $videoClip->theme['name']) . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . $videoClip->format . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '"><Icon glyph="play"/></span></div></div><div class="flex flex-col"><div class="text-sm">#' . $videoClip->id . ' – <span class="font-semibold group-hover:underline">' . $videoClip->title . '</span><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span></div><span class="text-xs">' . format_duration((int) $videoClip->duration) . '</span></div></a>'; }, ], [ @@ -89,7 +89,7 @@ use CodeIgniter\I18n\Time; $downloadButton = ''; if ($videoClip->media) { helper('misc'); - $filename = 'clip-' . slugify($videoClip->label) . "-{$videoClip->start_time}-{$videoClip->end_time}"; + $filename = 'clip-' . slugify($videoClip->title) . "-{$videoClip->start_time}-{$videoClip->end_time}"; $downloadButton = '<IconButton glyph="download" uri="' . $videoClip->media->file_url . '" download="' . $filename . '">' . lang('VideoClip.download_clip') . '</IconButton>'; } diff --git a/themes/cp_admin/episode/video_clips_new.php b/themes/cp_admin/episode/video_clips_new.php index dbfcd9fce2..1c9b3796a9 100644 --- a/themes/cp_admin/episode/video_clips_new.php +++ b/themes/cp_admin/episode/video_clips_new.php @@ -17,7 +17,7 @@ <img slot="preview_image" src="<?= $episode->cover->thumbnail_url ?>" alt="<?= $episode->cover->description ?>" /> </video-clip-previewer> <audio-clipper start-time="<?= old('start_time', 0) ?>" duration="<?= old('duration', 30) ?>" min-duration="10" volume=".5" height="50"> - <audio slot="audio" src="<?= $episode->audio->file_url ?>" class="w-full" preload="auto"> + <audio slot="audio" src="<?= $episode->audio->file_url ?>" preload="auto"> Your browser does not support the <code>audio</code> element. </audio> <input slot="start_time" type="number" name="start_time" placeholder="<?= lang('VideoClip.form.start_time') ?>" step="0.001" /> @@ -28,7 +28,7 @@ <div class="flex flex-col items-end w-full max-w-xl xl:max-w-sm 2xl:max-w-xl gap-y-4"> <Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" > <Forms.Field - name="label" + name="title" label="<?= lang('VideoClip.form.clip_title') ?>" required="true" /> -- GitLab