bifurqué depuis cliss21/pfm-site
Parent
4f90961263
révision
13b22c5b01
|
@ -1,41 +0,0 @@
|
|||
import AudioPlayerController from '../vendor/audioplayer/controller';
|
||||
|
||||
const LABEL_LOADING = 'Chargement…';
|
||||
const LABEL_PLAY = 'Lire le titre';
|
||||
const LABEL_PAUSE = 'Mettre en pause le titre';
|
||||
|
||||
const CLASS_NAME_ICON = 'fa fa-fw';
|
||||
const CLASS_NAME_ICON_LOADING = `${CLASS_NAME_ICON} fa-circle-o-notch fa-spin`;
|
||||
const CLASS_NAME_ICON_PLAY = `${CLASS_NAME_ICON} fa-play-circle`;
|
||||
const CLASS_NAME_ICON_PAUSE = `${CLASS_NAME_ICON} fa-pause-circle`;
|
||||
|
||||
export default class extends AudioPlayerController {
|
||||
static targets = ['toggler'];
|
||||
|
||||
loadingValueChanged(value, previousValue) {
|
||||
super.loadingValueChanged(value, previousValue);
|
||||
|
||||
this._updateToggleBtn();
|
||||
}
|
||||
|
||||
playingValueChanged(value, previousValue) {
|
||||
super.playingValueChanged(value, previousValue);
|
||||
|
||||
this._updateToggleBtn();
|
||||
}
|
||||
|
||||
_updateToggleBtn() {
|
||||
const icon = this.togglerTarget.querySelector('.fa');
|
||||
|
||||
if (this.loadingValue) {
|
||||
this.togglerTarget.setAttribute('aria-label', LABEL_LOADING);
|
||||
icon.className = CLASS_NAME_ICON_LOADING;
|
||||
} else if (this.playingValue) {
|
||||
this.togglerTarget.setAttribute('aria-label', LABEL_PAUSE);
|
||||
icon.className = CLASS_NAME_ICON_PAUSE;
|
||||
} else {
|
||||
this.togglerTarget.setAttribute('aria-label', LABEL_PLAY);
|
||||
icon.className = CLASS_NAME_ICON_PLAY;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
import { formatTime } from '../vendor/player';
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { url: String };
|
||||
|
||||
connect() {
|
||||
this.audio = new Audio();
|
||||
this.audio.preload = 'metadata';
|
||||
this.audio.addEventListener('loadedmetadata', () => {
|
||||
this._setDuration(this.audio.duration);
|
||||
});
|
||||
|
||||
this.audio.src = this.urlValue;
|
||||
}
|
||||
|
||||
_setDuration(duration) {
|
||||
this.element.innerHTML = formatTime(duration);
|
||||
}
|
||||
}
|
|
@ -3,68 +3,16 @@ __webpack_public_path__ = window.STATIC_URL || '/';
|
|||
|
||||
import '@hotwired/turbo';
|
||||
|
||||
import { Application } from '@hotwired/stimulus';
|
||||
import PlayerController from './vendor/player';
|
||||
import Duration from './controllers/duration';
|
||||
|
||||
import './vendor/bootstrap';
|
||||
import Offcanvas from 'bootstrap/offcanvas';
|
||||
import EventHandler from 'bootstrap/dom/event-handler';
|
||||
import SelectorEngine from 'bootstrap/dom/selector-engine';
|
||||
|
||||
const ATTR_PLAYER_ADD = 'data-player-add';
|
||||
const ATTR_PLAYER_ADD_PODCAST = 'data-player-add-podcast';
|
||||
const ATTR_PLAYER_AUTOPLAY = 'data-player-autoplay';
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------------
|
||||
* Main application
|
||||
* ------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const application = Application.start();
|
||||
application.register('player', PlayerController);
|
||||
application.register('duration', Duration);
|
||||
|
||||
EventHandler.on(
|
||||
document,
|
||||
'click',
|
||||
`[${ATTR_PLAYER_ADD}]`,
|
||||
function ({ delegateTarget }) {
|
||||
const component = window.Unicorn.getComponent('player');
|
||||
|
||||
const song = delegateTarget.getAttribute(ATTR_PLAYER_ADD);
|
||||
const autoplay = delegateTarget.hasAttribute(ATTR_PLAYER_AUTOPLAY)
|
||||
? 'True'
|
||||
: 'False';
|
||||
|
||||
component.callMethod(`add(${song}, ${autoplay})`, 0, null, (err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
EventHandler.on(
|
||||
document,
|
||||
'click',
|
||||
`[${ATTR_PLAYER_ADD_PODCAST}]`,
|
||||
function ({ delegateTarget }) {
|
||||
const component = window.Unicorn.getComponent('player');
|
||||
|
||||
const id = Number.parseInt(
|
||||
delegateTarget.getAttribute(ATTR_PLAYER_ADD_PODCAST),
|
||||
10
|
||||
);
|
||||
const autoplay = delegateTarget.hasAttribute(ATTR_PLAYER_AUTOPLAY)
|
||||
? 'True'
|
||||
: 'False';
|
||||
|
||||
component.callMethod(`add_podcast(${id}, ${autoplay})`, 0, null, (err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
document.documentElement.classList.remove('no-js');
|
||||
|
||||
|
|
|
@ -1,337 +0,0 @@
|
|||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
const STATE_PAUSED = 0;
|
||||
const STATE_LOADING = 1;
|
||||
const STATE_PLAYING = 2;
|
||||
|
||||
/**
|
||||
* Format the time from seconds to MM:SS.
|
||||
* @param {Number} time
|
||||
* @return {String}
|
||||
*/
|
||||
export function formatTime(time) {
|
||||
if (time === Infinity) {
|
||||
return '--:--';
|
||||
}
|
||||
|
||||
const min = Math.floor(time / 60);
|
||||
const sec = Math.round(time - min * 60);
|
||||
|
||||
return `${min.toString().padStart(2, 0)}:${sec.toString().padStart(2, 0)}`;
|
||||
}
|
||||
|
||||
export default class extends Controller {
|
||||
/**
|
||||
* The map of HTMLMediaElement events and related class method listeners.
|
||||
* They will be automatically added to the Audio object once the controller
|
||||
* is connected and removed when it is disconnected.
|
||||
*/
|
||||
static _audioEventsMap = new Map([
|
||||
['error', '_onError'],
|
||||
['ended', '_onEnd'],
|
||||
['play', '_onPlay'],
|
||||
['pause', '_onPause'],
|
||||
['playing', '_onPlaying'],
|
||||
['suspend', '_onSuspend'],
|
||||
['waiting', '_onWaiting'],
|
||||
['seeked', '_onSeeked'],
|
||||
['seeking', '_onSeeking'],
|
||||
['timeupdate', '_onTimeUpdate'],
|
||||
['durationchange', '_onDurationChange'],
|
||||
]);
|
||||
|
||||
initialize() {
|
||||
// Initialize a new AudioPlayer instance and store it in Window to make it
|
||||
// persistent across navigation when using Turbo
|
||||
if (!window.currentAudio) {
|
||||
const audio = new Audio();
|
||||
audio.autoplay = false;
|
||||
audio.preload = 'metadata';
|
||||
|
||||
window.currentAudio = audio;
|
||||
}
|
||||
|
||||
this.audio = window.currentAudio;
|
||||
|
||||
// Bind event handlers to use them in connect() and disconnect()
|
||||
for (const methodName of this.constructor._audioEventsMap.values()) {
|
||||
this[methodName] = this[methodName].bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
for (const [event, methodName] of this.constructor._audioEventsMap) {
|
||||
this.audio.addEventListener(event, this[methodName]);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
for (const [event, methodName] of this.constructor._audioEventsMap) {
|
||||
this.audio.removeEventListener(event, this[methodName]);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
|
||||
/**
|
||||
* Whether a song is currently being played.
|
||||
* @return {Boolean}
|
||||
*/
|
||||
get isPlaying() {
|
||||
return this.audio.paused === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a song is set and can be played.
|
||||
* @return {Boolean}
|
||||
*/
|
||||
get canPlay() {
|
||||
return this.audio.currentSrc !== '' && this.audio.readyState > 0;
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
/**
|
||||
* Play the current song or resume playback.
|
||||
*/
|
||||
play() {
|
||||
if (!this.canPlay) {
|
||||
throw new Error('No song has been loaded or is ready yet');
|
||||
}
|
||||
|
||||
if (this.isPlaying) {
|
||||
throw new Error('The song is already playing');
|
||||
}
|
||||
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the currently played song.
|
||||
*/
|
||||
pause() {
|
||||
if (!this.canPlay) {
|
||||
throw new Error('No song has been loaded or is ready yet');
|
||||
}
|
||||
|
||||
if (!this.isPlaying) {
|
||||
throw new Error('The song is already paused');
|
||||
}
|
||||
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the playing state.
|
||||
*/
|
||||
toggle() {
|
||||
if (this.isPlaying) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
// CSS Classes
|
||||
|
||||
static classes = ['loading', 'playing'];
|
||||
|
||||
// Targets
|
||||
|
||||
static targets = ['currentTime', 'duration', 'progressSlider', 'toggler'];
|
||||
|
||||
currentTimeTargetConnected(element) {
|
||||
this._updateCurrentTimeElement(element);
|
||||
}
|
||||
|
||||
durationTargetConnected(element) {
|
||||
this._updateDurationElement(element);
|
||||
}
|
||||
|
||||
progressSliderTargetConnected(element) {
|
||||
element._inputListener = ({ target }) => this._seek(target.value);
|
||||
element.addEventListener('input', element._inputListener);
|
||||
|
||||
this._updateProgressSliderElement(element);
|
||||
}
|
||||
|
||||
progressSliderTargetDisconnected(element) {
|
||||
element.removeEventListener('input', element._inputListener);
|
||||
}
|
||||
|
||||
// Values
|
||||
|
||||
static values = {
|
||||
autoplay: Boolean,
|
||||
url: String,
|
||||
};
|
||||
|
||||
urlValueChanged() {
|
||||
const { currentSrc } = this.audio;
|
||||
|
||||
if (currentSrc !== this.urlValue) {
|
||||
if (this.isPlaying) {
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
this.audio.src = this.urlValue;
|
||||
|
||||
if (this.urlValue) {
|
||||
// Force the loading state to don't wait for the audio events which could
|
||||
// be delayed depending on the browser or the network
|
||||
this._setState(STATE_LOADING);
|
||||
|
||||
if (currentSrc) {
|
||||
this.audio.load();
|
||||
}
|
||||
|
||||
if (this.autoplayValue) {
|
||||
this.audio.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
/**
|
||||
* Set the current time of the loaded song.
|
||||
* @param {Number} percentage - The percentage to the song duration.
|
||||
*/
|
||||
_seek(percentage) {
|
||||
if (!this.canPlay) {
|
||||
throw new Error('No song has been loaded or is ready yet');
|
||||
}
|
||||
|
||||
if (percentage > 100) {
|
||||
percentage = 100;
|
||||
} else if (percentage < 0) {
|
||||
percentage = 0;
|
||||
}
|
||||
|
||||
this.audio.currentTime = percentage
|
||||
? this.audio.duration * (percentage / 100)
|
||||
: 0;
|
||||
}
|
||||
|
||||
_setState(value) {
|
||||
if (value === STATE_LOADING) {
|
||||
this.togglerTarget.setAttribute('disabled', true);
|
||||
|
||||
this.togglerTarget.classList.remove(...this.playingClasses);
|
||||
this.togglerTarget.classList.add(...this.loadingClasses);
|
||||
} else {
|
||||
this.togglerTarget.removeAttribute('disabled');
|
||||
|
||||
if (value === STATE_PLAYING) {
|
||||
this.togglerTarget.classList.remove(...this.loadingClasses);
|
||||
this.togglerTarget.classList.add(...this.playingClasses);
|
||||
} else {
|
||||
this.togglerTarget.classList.remove(
|
||||
...this.loadingClasses,
|
||||
...this.playingClasses
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateCurrentTimeElement(element) {
|
||||
element.innerText = formatTime(this.audio.currentTime);
|
||||
}
|
||||
|
||||
_updateDurationElement(element) {
|
||||
element.innerText = this.audio.duration
|
||||
? formatTime(this.audio.duration)
|
||||
: '--:--';
|
||||
}
|
||||
|
||||
_updateProgressSliderElement(element) {
|
||||
const { currentTime, duration } = this.audio;
|
||||
const progress = duration ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
element.value = progress;
|
||||
element.style.setProperty('--value', `${progress}%`);
|
||||
element.setAttribute('aria-valuenow', currentTime);
|
||||
|
||||
if (duration) {
|
||||
element.removeAttribute('disabled');
|
||||
} else {
|
||||
element.setAttribute('disabled', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Events handlers
|
||||
|
||||
_onError() {
|
||||
this._setState(STATE_PAUSED);
|
||||
|
||||
this.audio.currentTime = 0;
|
||||
this._onTimeUpdate();
|
||||
|
||||
console.error(
|
||||
`Unable to load audio from ${this.urlValue} (${this.audio.error.message})`
|
||||
);
|
||||
}
|
||||
|
||||
_onPlay() {
|
||||
this._setState(STATE_PLAYING);
|
||||
}
|
||||
|
||||
_onPause() {
|
||||
this._setState(STATE_PAUSED);
|
||||
}
|
||||
|
||||
_onEnd() {
|
||||
this._setState(STATE_PAUSED);
|
||||
|
||||
if (window.Unicorn) {
|
||||
window.Unicorn.call('player', 'next');
|
||||
}
|
||||
}
|
||||
|
||||
_onPlaying() {
|
||||
this._setState(this.isPlaying ? STATE_PLAYING : STATE_PAUSED);
|
||||
}
|
||||
|
||||
_onSuspend() {
|
||||
this._setState(this.isPlaying ? STATE_PLAYING : STATE_PAUSED);
|
||||
}
|
||||
|
||||
_onWaiting() {
|
||||
this._setState(STATE_LOADING);
|
||||
}
|
||||
|
||||
_onSeeked() {
|
||||
if (this.audio.paused === true) {
|
||||
this._setState(STATE_PAUSED);
|
||||
}
|
||||
}
|
||||
|
||||
_onSeeking() {
|
||||
// Consider seeking for the loading state too only when the sound is
|
||||
// paused since it will generally not be handled by 'waiting' event.
|
||||
if (this.audio.paused === true) {
|
||||
this._setState(STATE_LOADING);
|
||||
}
|
||||
}
|
||||
|
||||
_onTimeUpdate() {
|
||||
this.currentTimeTargets.forEach((element) => {
|
||||
this._updateCurrentTimeElement(element);
|
||||
});
|
||||
|
||||
this.progressSliderTargets.forEach((element) => {
|
||||
this._updateProgressSliderElement(element);
|
||||
});
|
||||
}
|
||||
|
||||
_onDurationChange() {
|
||||
this.durationTargets.forEach((element) => {
|
||||
this._updateDurationElement(element);
|
||||
});
|
||||
|
||||
this.progressSliderTargets.forEach((element) => {
|
||||
this._updateProgressSliderElement(element);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,3 +1,9 @@
|
|||
$player-color: $black !default;
|
||||
$player-bg: $lighter !default;
|
||||
$player-height: 45px !default;
|
||||
$player-zindex: $zindex-fixed !default;
|
||||
$player-breakpoint: 768px !default;
|
||||
|
||||
$player-range-thumb-height: 13px !default;
|
||||
$player-range-thumb-bg: $component-active-bg !default;
|
||||
$player-range-thumb-box-shadow: $box-shadow-inset !default;
|
||||
|
@ -9,6 +15,19 @@ $player-range-track-bg: $gray-400 !default;
|
|||
|
||||
$player-range-fill-bg: $component-active-bg !default;
|
||||
|
||||
$player-time-current-color: $orange !default;
|
||||
|
||||
$player-playlist-min-width: 200px !default;
|
||||
$player-playlist-bg: scale-color($player-bg, $lightness: -10%) !default;
|
||||
$player-playlist-border-color: rgba($black, 0.25) !default;
|
||||
|
||||
$player-song-hover-color: $primary !default;
|
||||
$player-song-current-color: $primary !default;
|
||||
|
||||
//
|
||||
// Mixins
|
||||
//
|
||||
|
||||
@mixin player-range-track() {
|
||||
height: $player-range-track-height;
|
||||
user-select: none;
|
||||
|
@ -40,12 +59,57 @@ $player-range-fill-bg: $component-active-bg !default;
|
|||
$player-range-thumb-active-box-shadow-color;
|
||||
}
|
||||
|
||||
//
|
||||
// Styles
|
||||
//
|
||||
|
||||
.player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
z-index: $zindex-fixed;
|
||||
z-index: $player-zindex;
|
||||
width: 100%;
|
||||
background-color: $lighter;
|
||||
color: $player-color;
|
||||
background-color: $player-bg;
|
||||
|
||||
&.is-empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.player__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
|
||||
&--controls,
|
||||
&--current {
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&--current {
|
||||
height: $player-height;
|
||||
}
|
||||
|
||||
@media (min-width: $player-breakpoint) {
|
||||
&--controls {
|
||||
flex: 0 0 auto;
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
&--current {
|
||||
flex: 1 0 0%;
|
||||
}
|
||||
|
||||
&--controls + &--current {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-toggler {
|
||||
|
@ -78,92 +142,174 @@ $player-range-fill-bg: $component-active-bg !default;
|
|||
.player-progress {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-progress__range {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: calc(
|
||||
(#{$player-range-thumb-active-box-shadow-width} * 2) + #{$player-range-thumb-height}
|
||||
);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: $player-range-fill-bg;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: calc(#{$player-range-thumb-height} * 2);
|
||||
transition: box-shadow 0.3s ease;
|
||||
|
||||
// Webkit
|
||||
&::-webkit-slider-runnable-track {
|
||||
@include player-range-track;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
@include player-range-thumb;
|
||||
|
||||
&__range {
|
||||
appearance: none;
|
||||
margin-top: calc(
|
||||
((#{$player-range-thumb-height} - #{$player-range-track-height}) / 2) * -1
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: calc(
|
||||
(#{$player-range-thumb-active-box-shadow-width} * 2) + #{$player-range-thumb-height}
|
||||
);
|
||||
}
|
||||
|
||||
// Mozilla
|
||||
&::-moz-range-track {
|
||||
@include player-range-track;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
@include player-range-thumb;
|
||||
}
|
||||
|
||||
// Focus and active styles
|
||||
&::-moz-focus-outer {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: $player-range-fill-bg;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
border-radius: calc(#{$player-range-thumb-height} * 2);
|
||||
transition: box-shadow 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
// Webkit
|
||||
&::-webkit-slider-runnable-track {
|
||||
@include player-range-track;
|
||||
}
|
||||
|
||||
&:active {
|
||||
&::-webkit-slider-thumb {
|
||||
@include player-range-thumb-active;
|
||||
@include player-range-thumb;
|
||||
|
||||
appearance: none;
|
||||
margin-top: calc(
|
||||
((#{$player-range-thumb-height} - #{$player-range-track-height}) / 2) * -1
|
||||
);
|
||||
}
|
||||
|
||||
// Mozilla
|
||||
&::-moz-range-track {
|
||||
@include player-range-track;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
@include player-range-thumb-active;
|
||||
@include player-range-thumb;
|
||||
}
|
||||
|
||||
// Focus and active styles
|
||||
&::-moz-focus-outer {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
&::-webkit-slider-thumb {
|
||||
@include player-range-thumb-active;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
@include player-range-thumb-active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-time {
|
||||
margin: 0 1rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
|
||||
&--current {
|
||||
color: $orange;
|
||||
color: $player-time-current-color;
|
||||
}
|
||||
}
|
||||
|
||||
.player-current {
|
||||
height: 45px;
|
||||
// Song elements
|
||||
|
||||
.song {
|
||||
padding: 0;
|
||||
clear: both;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
|
||||
&[type='button']:not(:disabled) {
|
||||
&:hover {
|
||||
color: $player-song-hover-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&__title,
|
||||
&__subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 0;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 1rem;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
&__title + &__subtitle {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
&__time {
|
||||
margin: 0 0.5rem 0 1rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.player-current__title,
|
||||
.player-current__subtitle {
|
||||
margin-bottom: 0;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
.song-title {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-current__title {
|
||||
font-size: 1rem;
|
||||
font-weight: $font-weight-bold;
|
||||
// Playlist
|
||||
|
||||
.playlist {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
display: none;
|
||||
min-width: $player-playlist-min-width;
|
||||
max-width: 100%;
|
||||
padding: 1rem;
|
||||
background-color: $player-playlist-bg;
|
||||
|
||||
&__song {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
|
||||
&--current {
|
||||
color: $player-song-current-color;
|
||||
}
|
||||
|
||||
& + & {
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid $player-playlist-border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-current__subtitle {
|
||||
font-size: 0.8rem;
|
||||
.playlist-toggler {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
transition: none;
|
||||
|
||||
&[aria-expanded='true'] {
|
||||
background-color: $player-playlist-bg;
|
||||
|
||||
~ .playlist {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
"license": "AGPL-3.0+",
|
||||
"dependencies": {
|
||||
"@fontsource/open-sans": "^4.5.5",
|
||||
"@hotwired/stimulus": "^3.0.1",
|
||||
"@hotwired/turbo": "^7.1.0",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
"bootstrap": "~5.1.3",
|
||||
|
@ -1695,11 +1694,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@fontsource/open-sans/-/open-sans-4.5.5.tgz",
|
||||
"integrity": "sha512-h1oUPSQpoMnDrnzIZTVS9PPBFhWXS87v6/cd9FY2Xc+GKbOVcjPZxcvUDU1TnCie2QSoYY9aifERRV/d8JHtWQ=="
|
||||
},
|
||||
"node_modules/@hotwired/stimulus": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.0.1.tgz",
|
||||
"integrity": "sha512-oHsJhgY2cip+K2ED7vKUNd2P+BEswVhrCYcJ802DSsblJFv7mPFVk3cQKvm2vHgHeDVdnj7oOKrBbzp1u8D+KA=="
|
||||
},
|
||||
"node_modules/@hotwired/turbo": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-7.1.0.tgz",
|
||||
|
@ -11182,11 +11176,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@fontsource/open-sans/-/open-sans-4.5.5.tgz",
|
||||
"integrity": "sha512-h1oUPSQpoMnDrnzIZTVS9PPBFhWXS87v6/cd9FY2Xc+GKbOVcjPZxcvUDU1TnCie2QSoYY9aifERRV/d8JHtWQ=="
|
||||
},
|
||||
"@hotwired/stimulus": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.0.1.tgz",
|
||||
"integrity": "sha512-oHsJhgY2cip+K2ED7vKUNd2P+BEswVhrCYcJ802DSsblJFv7mPFVk3cQKvm2vHgHeDVdnj7oOKrBbzp1u8D+KA=="
|
||||
},
|
||||
"@hotwired/turbo": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-7.1.0.tgz",
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fontsource/open-sans": "^4.5.5",
|
||||
"@hotwired/stimulus": "^3.0.1",
|
||||
"@hotwired/turbo": "^7.1.0",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
"bootstrap": "~5.1.3",
|
||||
|
|
|
@ -1,178 +0,0 @@
|
|||
from collections import OrderedDict
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django_unicorn.components import UnicornView
|
||||
from wagtail_webradio.models import Podcast
|
||||
|
||||
|
||||
@dataclass
|
||||
class Song:
|
||||
url: str
|
||||
title: str
|
||||
subtitle: str
|
||||
download_url: str = ''
|
||||
thumbnail_url: str = ''
|
||||
|
||||
|
||||
class Playlist(Sequence):
|
||||
def __init__(self):
|
||||
self.data = OrderedDict()
|
||||
self._current_id = None
|
||||
self._previous_id = None
|
||||
self._next_id = None
|
||||
self._last_id = 0
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.data[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.data)
|
||||
|
||||
def __contains__(self, key):
|
||||
return key in self.data
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.data)
|
||||
|
||||
def items(self):
|
||||
return self.data.items()
|
||||
|
||||
@property
|
||||
def current_id(self):
|
||||
return self._current_id
|
||||
|
||||
@current_id.setter
|
||||
def current_id(self, value):
|
||||
if value == self._current_id:
|
||||
return
|
||||
if value not in self:
|
||||
raise ValueError("'%s' is not in the playlist" % value)
|
||||
self._current_id = value
|
||||
self._update_indexes()
|
||||
|
||||
@property
|
||||
def current(self):
|
||||
return None if self._current_id is None else self[self._current_id]
|
||||
|
||||
@property
|
||||
def has_previous(self):
|
||||
return self._previous_id is not None
|
||||
|
||||
@property
|
||||
def has_next(self):
|
||||
return self._next_id is not None
|
||||
|
||||
def add(self, value):
|
||||
self._last_id += 1
|
||||
self.data[self._last_id] = value
|
||||
self._update_indexes()
|
||||
return self._last_id
|
||||
|
||||
def previous(self):
|
||||
if not self.has_previous:
|
||||
raise KeyError("There is no previous song")
|
||||
self.current_id = self._previous_id
|
||||
return self.current_id
|
||||
|
||||
def next(self):
|
||||
if not self.has_next:
|
||||
raise KeyError("There is no next song")
|
||||
self.current_id = self._next_id
|
||||
return self.current_id
|
||||
|
||||
def _update_indexes(self):
|
||||
self._previous_id = None
|
||||
self._next_id = None
|
||||
keys = list(self.data.keys())
|
||||
try:
|
||||
index = keys.index(self._current_id)
|
||||
except ValueError:
|
||||
return
|
||||
if index > 0:
|
||||
self._previous_id = keys[index - 1]
|
||||
if index + 1 < len(keys):
|
||||
self._next_id = keys[index + 1]
|
||||
|
||||
|
||||
class PlayerView(UnicornView):
|
||||
autoplay: bool = False
|
||||
current_id: int = None
|
||||
|
||||
playlist = Playlist()
|
||||
|
||||
class Meta:
|
||||
javascript_exclude = ('current_id', 'playlist')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['current'] = self.playlist.current
|
||||
return context
|
||||
|
||||
# Hooks
|
||||
|
||||
def mount(self):
|
||||
self.current_id = self.playlist.current_id
|
||||
|
||||
def updated_current_id(self, value: int):
|
||||
if value not in self.playlist:
|
||||
raise AttributeError("This id is not in the playlist")
|
||||
|
||||
self.playlist.current_id = value
|
||||
|
||||
# Actions
|
||||
|
||||
def play(self, song_id: int):
|
||||
self._set_property('current_id', song_id)
|
||||
|
||||
def previous(self):
|
||||
try:
|
||||
self.current_id = self.playlist.previous()
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
self.autoplay = True
|
||||
|
||||
def next(self):
|
||||
try:
|
||||
self.current_id = self.playlist.next()
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
self.autoplay = True
|
||||
|
||||
def add(self, song, autoplay: bool = True):
|
||||
if isinstance(song, dict):
|
||||
song = Song(**song)
|
||||
|
||||
song_id = self.playlist.add(song)
|
||||
|
||||
if self.current_id is None or autoplay:
|
||||
self.autoplay = autoplay
|
||||
self.play(song_id)
|
||||
|
||||
def add_podcast(self, podcast_id: int, autoplay: bool = True):
|
||||
podcast = self._get_podcast(podcast_id)
|
||||
|
||||
self.add(self._get_song_from_podcast(podcast), autoplay)
|
||||
|
||||
# Private
|
||||
|
||||
def _get_podcast(self, podcast_id):
|
||||
return Podcast.objects.get(pk=podcast_id)
|
||||
|
||||
def _get_song_from_podcast(self, podcast):
|
||||
song = Song(
|
||||
url=podcast.sound_url,
|
||||
title=podcast.title,
|
||||
subtitle=podcast.radio_show.title,
|
||||
download_url=podcast.sound_url,
|
||||
)
|
||||
|
||||
if podcast.picture:
|
||||
song.thumbnail_url = podcast.picture.get_rendition('fill-45x45').url
|
||||
|
||||
return song
|
|
@ -1,54 +0,0 @@
|
|||
<div class="player{% if not playlist %} d-none{% endif %}" data-controller="player" data-player-loading-class="is-loading" data-player-playing-class="is-playing" data-player-autoplay-value="{{ autoplay|lower }}" data-player-url-value="{{ current.url }}">
|
||||
<div class="container h-100">
|
||||
<div class="row h-100">
|
||||
<div class="col-md-8 d-flex align-items-center">
|
||||
<button class="btn" aria-label="Passer au titre précédent"{% if not playlist.has_previous %} disabled{% endif %} unicorn:click="previous">
|
||||
<i class="fa fa-step-backward" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn player-toggler" aria-label="Basculer la lecture" disabled data-action="player#toggle" data-player-target="toggler" unicorn:ignore>
|
||||
<span class="player-toggler__play">
|
||||
<i class="fa fa-fw fa-play-circle" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Lire</span>
|
||||
</span>
|
||||
<span class="player-toggler__pause">
|
||||
<i class="fa fa-fw fa-pause-circle" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Mettre en pause</span>
|
||||
</span>
|
||||
<span class="player-toggler__loading">
|
||||
<i class="fa fa-fw fa-circle-o-notch fa-spin" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="btn" aria-label="Passer au titre suivant"{% if not playlist.has_next %} disabled{% endif %} unicorn:click="next">
|
||||
<i class="fa fa-step-forward" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<time class="player-time player-time--current px-3" data-player-target="currentTime" unicorn:ignore></time>
|
||||
|
||||
<div class="player-progress" unicorn:ignore>
|
||||
<input type="range" class="player-progress__range" min="0" max="100" step="0.01" value="0" autocomplete="off" role="slider" aria-label="Naviguer dans le son" aria-valuenow="0" aria-valuemin="0" aria-valuemax="0" data-player-target="progressSlider">
|
||||
</div>
|
||||
|
||||
<time class="player-time ps-3" data-player-target="duration" unicorn:ignore>--:--</time>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center player-current">
|
||||
{% if current.thumbnail_url %}
|
||||
<img src="{{ current.thumbnail_url }}" class="me-2 player-current__thumbnail">
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="player-current__title">{{ current.title }}</h5>
|
||||
<h6 class="player-current__subtitle">{{ current.subtitle }}</h6>
|
||||
</div>
|
||||
|
||||
{% if current.download_url %}
|
||||
<a href="{{ current.download_url }}" class="btn player-current__download" aria-label="Télécharger ce podcast">
|
||||
<i class="fa fa-download" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -297,5 +297,5 @@ WAGTAILSEARCH_BACKENDS = {
|
|||
# ------------------------------------------------------------------------------
|
||||
# https://www.django-unicorn.com/docs/settings/
|
||||
UNICORN = {
|
||||
'APPS': ['pfmsite.radio'],
|
||||
'APPS': ['wagtail_webradio'],
|
||||
}
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
window.STATIC_URL = '{% get_static_prefix %}';
|
||||
</script>
|
||||
<script src="{% static "main.js" %}" defer></script>
|
||||
<script src="{% static "wagtail_webradio/player/js/main.js" %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
{% image settings.core.displaysettings.podcast_default_picture fill-480x480 class="img-fluid img-border podcast-img" %}
|
||||
{% endif %}
|
||||
<button class="btn p-0 podcast-img-btn" data-player-add-podcast="{{ podcast.pk }}" data-player-autoplay>{% include "../play_button.svg" with size=120 %}</button>
|
||||
<time class="podcast-img-time" data-controller="duration" data-duration-url-value="{{ podcast.sound_url }}">--:--</time>
|
||||
<time class="podcast-img-time">{{ podcast.get_duration_display }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
|
|
@ -4,13 +4,12 @@ django >=3.2,<4
|
|||
django-environ ==0.4.5
|
||||
django-filter >= 21.1,<22
|
||||
django-tapeforms >=1.1,<1.2 # https://github.com/stephrdev/django-tapeforms/
|
||||
django-unicorn ~=0.42.1
|
||||
|
||||
# Wagtail
|
||||
# ------------------------------------------------------------------------------
|
||||
wagtail >=2.15,<2.16
|
||||
wagtail-cblocks >=0.3,<0.4 # https://forge.cliss21.org/cliss21/wagtail-cblocks
|
||||
wagtail-webradio ==0.1.0
|
||||
wagtail-webradio[components] ~=0.2.1
|
||||
|
||||
# Autre
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
Chargement…
Référencer dans un nouveau ticket