feat(soundbites): add soundbite list and creation forms with audio-clipper component

This commit is contained in:
Yassine Doghri 2022-01-03 13:52:07 +00:00
parent 602654b99b
commit de19317138
24 changed files with 582 additions and 331 deletions

View File

@ -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',

View File

@ -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',

View File

@ -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);
}

View File

@ -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()

View File

@ -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();

View File

@ -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;

View File

@ -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>`;
}
}

View File

@ -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',
],
);

View File

@ -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']);

View File

@ -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]);
}
}

View File

@ -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,

View File

@ -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' =>

View File

@ -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',
];

View File

@ -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',
];

View File

@ -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 lextrait sonore, cela peut être un nombre décimal.',
'duration' => 'Durée',
'duration_hint' =>
'La durée de lextrait sonore (en secondes), cela peut être un nombre décimal.',
'label' => 'Libellé',
'label_hint' => 'Texte qui sera affiché.',
'play' => 'Écouter lextrait sonore',
'delete' => 'Supprimer lextrait sonore',
'bookmark' =>
'Cliquez pour récupérer la position actuelle, cliquez à nouveau pour récupérer la durée.',
'submit' => 'Enregistrer les extraits sonnores',
],
'embed' => [
'add' => 'Ajouter un lecteur intégré',
'title' => 'Lecteur intégré',

View File

@ -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',
];

View File

@ -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 lextrait',
'start_time' => 'Début à',
'duration' => 'Durée',
'submit' => 'Créer lextrait sonore',
],
'play' => 'Lancer lextrait sonore',
'stop' => 'Arrêter lextrait sonore',
'create' => 'Nouvel extrait sonore',
'delete' => 'Supprimer lextrait sonore',
];

View File

@ -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'],
],
]; ?>

View File

@ -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() ?>

View File

@ -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() ?>

View File

@ -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() ?>

View File

@ -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() ?>

View File

@ -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>';
}

View File

@ -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"
/>