Skip to content

Instantly share code, notes, and snippets.

@mherchel
Last active September 19, 2021 13:15
Show Gist options
  • Save mherchel/ac37b3fd3f83018a6760724ec5806f07 to your computer and use it in GitHub Desktop.
Save mherchel/ac37b3fd3f83018a6760724ec5806f07 to your computer and use it in GitHub Desktop.
Lullabot.com MP3 Player
{{ attach_library('lullabotcom/mp3player') }}
{% for item in items %}
{# Default implentation of audio element is hidden from modern browsers and shown to IE11. #}
<audio class="u-noscript-visible u-ie11-visible mp3player--default" controls>
<source src="{{ item.content }}" type="audio/mpeg">
</audio>
<div class="u-noscript-hidden u-ie11-hidden mp3player">
<audio src="{{ item.content }}" preload="auto"></audio>
<button class="mp3player_play-pause u-no-style">
<span class="visually-hidden">Play</span>
</button>
<div class="mp3player__location">
<span class="mp3player__current--current">00:00</span>
&nbsp;/&nbsp;
<span class="mp3player__current--end"></span>
</div>
<label class="visually-hidden" for="mp3player__scrubber">Playback Location</label>
<input type="range" id="mp3player__scrubber" class="mp3player__scrubber" min="0" max="3600" step="1" value="0" style="--max: 3600; --val: 0">
<button type="button" class="mp3player__playback-rate">
<span class="mp3player__playback-rate__label visually-hidden">Playback Rate:</span>
<span class="mp3player__playback-rate__value">1</span>
<span class="mp3player__playback-rate__x">x</span>
</button>
<a href="{{ item.content }}" class="mp3player__download" download>
<div class="mp3player__download__icon">
{% include "@lullabotcom/svg_imports/icon-download.svg" %}
</div>
<span class="mp3player__download__text">Download</span>
</a>
</div>
{% endfor %}
<div class="mp3extra">
{{ content.field_audio_media }}
{% if node.field_transcript.value %}
<a href="#transcript" class="mp3extra__transcript">
<div class="mp3extra__transcript__icon">
{% include "@lullabotcom/svg_imports/icon-transcript.svg" %}
</div>
<span class="mp3extra__transcript__text">Transcript</span>
</a>
{% endif %}
<div class="mp3extra__image">
<img src="{{ podcast_image_uri|image_style('thumbnail') }}" alt="" />
</div>
<div class="mp3extra__subscribe">
<button id="mp3extra__subscribe__button" class="mp3extra__subscribe__button u-no-style" aria-haspopup="true" aria-controls="mp3extra__subscribe-menu">
<span class="mp3extra__subscribe__text">Subscribe</span>
</button>
<ul id="mp3extra__subscribe-menu" class="mp3extra__subscribe-menu">
{% if parent.field_itunes_url.0.url %}
<li>
<a target="_blank" href="{{ parent.field_itunes_url.0.url }}">
{% include "@lullabotcom/svg_imports/itunes.svg" %}
iTunes
</a>
</li>
{% endif %}
{% if parent.field_google_play_url.0.url %}
<li>
<a target="_blank" href="{{ parent.field_google_play_url.0.url }}">
{% include "@lullabotcom/svg_imports/google-play.svg" %}
Google Play
</a>
</li>
{% endif %}
{% if parent.field_stitcher_url.0.url %}
<li>
<a target="_blank" href="{{ parent.field_stitcher_url.0.url }}">
{% include "@lullabotcom/svg_imports/stitcher.svg" %}
Stitcher
</a>
</li>
{% endif %}
{% if parent.field_spotify_url.0.url %}
<li>
<a target="_blank" href="{{ parent.field_spotify_url.0.url }}">
{% include "@lullabotcom/svg_imports/spotify.svg" %}
Spotify
</a>
</li>
{% endif %}
{% if parent.field_rss_feed_url.0.url %}
<li>
<a target="_blank" href="{{ parent.field_rss_feed_url.0.url }}">
{% include "@lullabotcom/svg_imports/rss.svg" %}
RSS
</a>
</li>
{% endif %}
</ul>
</div>
</div>
'use strict';
(function () {
/*
* MP3 player(s)
*
* This handles the binding of the various HTML elements to the HTMLMediaElement object,
* as well as resuming the audio if the event where the user navigated away and returned.
*/
Drupal.behaviors.mp3Player = {
'attach': function (context) {
context.querySelectorAll('.mp3player').forEach(mp3player => {
const playButton = mp3player.querySelector('.mp3player_play-pause');
const playbackRateButton = mp3player.querySelector('.mp3player__playback-rate');
const audio = mp3player.querySelector('audio');
const currentTimeElement = mp3player.querySelector('.mp3player__current--current');
const durationElement = mp3player.querySelector('.mp3player__current--end');
const scrubber = mp3player.querySelector('.mp3player__scrubber');
const audioStorageNameSpace = 'mp3Player.lastTimeStamp.' + audio.src;
const playbackRateStorageNameSpace = 'mp3Player.playbackRate.' + audio.src;
const lastTimeStamp = parseInt(localStorage.getItem(audioStorageNameSpace)) - 5;
function setupAudio() {
durationElement.textContent = formatTime(audio.duration);
scrubber.max = parseInt(audio.duration);
// If the user has listened to the episode before, resume 5 seconds before
// where they left off... unless they left off within 30 seconds of the end.
if (audioShouldResume()) {
console.info('Woot 🎉 We\'re resuming where you left off the last time you were here!');
scrubber.value = lastTimeStamp;
currentTimeElement.textContent = formatTime(lastTimeStamp);
changePlaybackRate(getCurrentPlaybackRate());
}
// set slider max CSS variable for playback progress styling purposes
// we only need to do this once when the player is setup
scrubber.style.setProperty('--max', parseInt(scrubber.max));
updateCSSVar();
}
function resumeAudio() {
if (audioShouldResume()) {
// HTMLMediaElement.fastSeek() is needed for FF to seek properly.
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/fastSeek
if (audio.fastSeek) {
audio.fastSeek(parseInt(lastTimeStamp));
}
else {
audio.currentTime = lastTimeStamp;
}
scrubber.value = lastTimeStamp;
}
}
// Change the audio playback rate, update the DOM, and store the setting in localStorage.
function changePlaybackRate(rate) {
const playbackRateText = playbackRateButton.querySelector('.mp3player__playback-rate__value');
playbackRateText.textContent = rate;
audio.playbackRate = rate;
localStorage.setItem(playbackRateStorageNameSpace, rate);
}
// Get the current playback rate.
function getCurrentPlaybackRate() {
return parseFloat(localStorage.getItem(playbackRateStorageNameSpace) || 1);
}
// Has the user listened to this audio before, and then navigated away?
function audioShouldResume() {
return lastTimeStamp > 5 && lastTimeStamp < audio.duration - 30;
}
function handlePlayPauseClick() {
if (audio.paused) {
playButton.setAttribute('aria-pressed', true);
playButton.querySelector('span').textContent = 'Click to Pause';
audio.play();
}
else {
playButton.setAttribute('aria-pressed', false);
playButton.querySelector('span').textContent = 'Click to Play';
audio.pause();
}
}
function handlePlaybackRateClick() {
const playbackRateValues = [0.5, 0.75, 1, 1.25, 1.5, 2,];
const currentLocation = playbackRateValues.indexOf(getCurrentPlaybackRate());
const newSpeed = (currentLocation !== playbackRateValues.length - 1) ? playbackRateValues[currentLocation + 1] : playbackRateValues[0];
changePlaybackRate(newSpeed);
}
function handleScrubberInput() {
if (audio.fastSeek) {
audio.fastSeek(scrubber.value);
}
else {
audio.currentTime = scrubber.value;
}
currentTimeElement.textContent = formatTime(scrubber.value);
updateCSSVar();
}
function handleProgress(e) {
currentTimeElement.textContent = formatTime(e.target.currentTime);
scrubber.value = parseInt(e.target.currentTime);
// Update localStorage with currentTime, in case we need to pick up where they left off.
localStorage.setItem(audioStorageNameSpace, Math.floor(e.target.currentTime));
updateCSSVar();
}
function updateCSSVar() {
// set current slider value CSS variable as a hook for styling playback progress in Webkit browsers
scrubber.style.setProperty('--val', parseInt(scrubber.value));
}
// Chrome doesn't fire 'loadedmetadata' or 'loadedmetadata' event listeners as expected,
// so we execute this when the behavior fires.
setupAudio();
playButton.addEventListener('click', handlePlayPauseClick);
playbackRateButton.addEventListener('click', handlePlaybackRateClick);
scrubber.addEventListener('input', handleScrubberInput);
audio.addEventListener('timeupdate', handleProgress);
// We have to bind to the 'playing' event for the audio to actually resume at the correct location
// in Mobile Safari 🤮. Note this behavior doesn't work in Chrome, so we also have to fire on
// the 'loadedmetadata' event 🤮. None of these work in desktop Safari, so we bind
// to the 'canplaythrough' event 🤮.
audio.addEventListener('loadedmetadata', setupAudio, {'once': true, 'passive': true,});
audio.addEventListener('canplaythrough', setupAudio, {'once': true, 'passive': true,});
audio.addEventListener('playing', resumeAudio, {'once': true, 'passive': true,});
});
function formatTime(duration) {
let minutes = parseInt(duration / 60);
let seconds = parseInt(duration) - (minutes * 60);
minutes = (minutes < 10) ? '0' + minutes : minutes;
seconds = (seconds < 10) ? '0' + seconds : seconds;
// Firefox does not execute the 'loadedmetadata' event listener, so we execute handleLoadMetaData()
// early. This strips out any NaNs that exist when handleLoadMetaData() fires early in Chrome, or Safari.
minutes = String(minutes).replace('NaN', '00');
seconds = String(seconds).replace('NaN', '00');
return minutes + ':' + seconds;
}
},
};
/*
* Sticky the MP3 player to the top and bottom of the screen when it'd normally be outside of the viewport.
*/
Drupal.behaviors.stickyPlayer = {
'attach': function (context) {
const mp3Player = context.querySelector('.mp3extra');
const headerHeight = context.querySelector('.site-header').clientHeight;
let offset;
let viewportHeight;
function handleResize() {
offset = context.querySelector('.mp3-placeholder').getBoundingClientRect();
// Offset only calculates offset relative to the viewport. If the page is
// reloaded scrolled halfway down, we need to account for scroll position.
offset.topPage = offset.top + window.pageYOffset;
// Can trigger reflow, so only do when browser is resized.
viewportHeight = window.innerHeight;
}
function handleScroll() {
const scrollPos = window.pageYOffset;
if (scrollPos >= offset.topPage - headerHeight) {
mp3Player.classList.remove('fixed-bottom');
mp3Player.classList.add('fixed', 'fixed-top');
}
else if (scrollPos + viewportHeight <= offset.topPage + mp3Player.clientHeight) {
mp3Player.classList.remove('fixed-top');
mp3Player.classList.add('fixed', 'fixed-bottom');
}
else {
mp3Player.classList.remove('fixed', 'fixed-top', 'fixed-bottom');
}
}
// Trigger resize and scroll events on pageload.
handleResize();
handleScroll();
Drupal.helper.optimizedScroll.add(handleScroll);
Drupal.helper.optimizedResize.add(handleResize);
},
};
})();
@import '../core/functions';
@import '../core/mixins';
@import '../core/variables';
.mp3player {
@include vr-all-breakpoints(margin-top, 3);
@include vr-all-breakpoints(margin-bottom, 3);
display: flex;
justify-content: space-between;
height: vr(kilo, 2);
background: palette(grayscale-95);
font-size: font-size(1);
@include breakpoint($mp3-break-medium) {
height: vr(lima, 2);
}
// Hide from IE11 which does not support web audio API.
@include ie11 {
display: none;
}
// If embedded with subscribe, transcript, etc, do not add margin.
.mp3extra & {
flex-basis: 100%;
margin: 0;
}
> * {
display: flex;
justify-content: center;
align-items: center;
}
// Hide invisible audio elements, which are treated as blocks within flex
// context in Firefox.
> audio:not([controls]) {
display: none;
}
}
// Default audio player. Shown to IE11 and non-JS browsers.
.mp3player--default {
display: none;
width: 80%;
margin: vr(lima, 2) auto;
@include ie11 {
display: block;
min-height: 60px;
margin: 0;
}
}
// Play / pause button.
.mp3player_play-pause {
position: relative;
width: vr(kilo, 2); // Equal to height.
height: auto;
flex-shrink: 0;
border: 0;
background-color: palette(blue-60);
overflow: hidden;
transition: all 0.2s;
@include breakpoint($mp3-break-medium) {
width: vr(lima, 2);
}
&:active {
background-color: palette(blue-50);
}
&:before {
content: '';
position: absolute;
top: 50%;
height: 0;
width: 0;
left: calc(50% + 3px);
transform: translate(-50%, -50%) scale(0.7);
border-top: 15px solid transparent;
border-left: 25px solid palette(grayscale-100);
border-bottom: 15px solid transparent;
@include breakpoint($mp3-break-medium) {
transform: translate(-50%, -50%);
}
}
&[aria-pressed='true']:before {
height: 22px;
width: 18px;
left: 50%;
border-top: 0;
border-left: solid 6px palette(grayscale-100);
border-right: solid 6px palette(grayscale-100);
border-bottom: 0;
}
}
// Time start / remaining.
.mp3player__location {
flex-basis: 140px;
padding: 0 10px;
color: palette(grayscale-40);
font-size: font-size(-1);
@include breakpoint($mp3-break-medium) {
font-size: font-size(1);
}
}
.mp3player__playback-rate {
&[class][class][class] {
align-self: center;
height: vr(lima, 1);
width: vr(lima, 1);
background-color: transparent;
color: palette(grayscale-40);
@include breakpoint($mp3-break-small) {
margin-left: 10px;
}
&:hover,
&:focus {
background-color: palette(grayscale-80);
}
}
}
.mp3player__download {
display: none;
border: 0;
padding: 0 5px;
color: palette(blue-45);
font-style: italic;
font-size: font-size(0);
@include breakpoint($mp3-break-medium) {
padding: 0 15px;
}
@include breakpoint(400px) {
display: flex;
}
}
.mp3player__download__text {
margin-left: 15px;
@include breakpoint($mp3-break-large, 'max-width') {
@include element-invisible;
}
}
.mp3player__download__icon {
display: flex;
justify-content: center;
align-items: center;
height: vr(lima, 1);
width: vr(lima, 1);
background-color: palette(blue-60);
svg {
height: 20px;
width: 20px;
}
}
// Stying the scrubber
// @see https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
.mp3player__scrubber {
--thumb-size: 20px;
--ratio: calc(var(--val)/var(--max));
--x-pos: calc(0.5 * var(--thumb-size) + var(--ratio) * (100% - var(--thumb-size)));
flex-grow: 1;
-webkit-appearance: none;
background: transparent;
align-self: center;
cursor: pointer;
@include breakpoint($mp3-break-small, max-width) {
position: absolute;
left: 1%;
bottom: -20px;
width: 98%;
}
&:focus {
outline: none;
&::-webkit-slider-thumb {
background: palette(grayscale-100);
box-shadow: 0 0 0 4px palette(blue-60);
outline: 2px solid transparent; // Windows High Contrast mode does not show backgrounds or box-shadows.
}
&::-moz-range-thumb {
background: palette(grayscale-100);
box-shadow: 0 0 0 4px palette(blue-60);
outline: 2px solid transparent; // Windows High Contrast mode does not show backgrounds or box-shadows.
}
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 2px;
background: linear-gradient(palette(blue-60), palette(blue-60)) 0/var(--x-pos) 100% no-repeat palette(grayscale-75);
}
&::-webkit-slider-thumb {
height: 20px;
width: 20px;
border: 0;
border-radius: 50%;
background: palette(blue-60);
-webkit-appearance: none;
margin-top: -9px;
transition: all 0.3s $cubic-bezier, outline 0s $cubic-bezier;
@media (pointer: fine) {
height: 12px;
width: 12px;
margin-top: -5px;
}
}
&::-moz-range-track {
width: 100%;
height: 2px;
background: palette(grayscale-75);
}
&::-moz-range-thumb {
height: 12px;
width: 12px;
border: 0;
border-radius: 50%;
background: palette(blue-60);
transition: all 0.3s $cubic-bezier;
}
&::-moz-range-progress {
height: 2px;
background: palette(blue-60);
}
&::-ms-track {
width: 100%;
height: 2px;
background: transparent;
border-color: transparent;
border-width: 16px 0;
color: transparent;
}
&::-ms-fill-lower {
background: palette(blue-60);
}
&::-ms-fill-upper {
background: palette(grayscale-75);
}
&::-ms-thumb {
height: 12px;
width: 12px;
border: 0;
border-radius: 50%;
background: palette(blue-60);
margin-top: 0;
}
&:focus::-ms-fill-lower {
background: palette(blue-60);
}
&:focus::-ms-fill-upper {
background: palette(blue-60);
}
}
// The .mp3extra div transitions between normal position: static to position: fixed
// when it would normally reside outside the viewport. This .mp3-placeholder wrapping
// div ensures that the content does not collapse around it (and create a visual jump
// that the user would notice.
.mp3-placeholder {
@include vr-all-breakpoints(margin-top, 3);
@include vr-all-breakpoints(margin-bottom, 3);
height: vr(kilo, 2);
@include breakpoint($mp3-break-medium) {
height: vr(lima, 2);
}
}
.mp3extra {
position: relative;
display: flex;
justify-content: space-between;
height: vr(kilo, 2);
background: palette(grayscale-95);
font-size: font-size(1);
@include breakpoint($mp3-break-medium) {
height: vr(lima, 2);
border: solid 1px transparent;
}
// Only fix the headers if the browser height isn't too small.
@media (min-height: 600px) and (min-width: map-get($breakpoints, $mp3-break-medium)) {
&.fixed {
position: fixed;
width: calc(100% - #{2 * map-get($body-padding, kilo)});
max-width: $site-max-width;
z-index: 5;
margin: 0;
border: solid 1px palette(grayscale-90);
}
&.fixed-top {
top: 72px;
filter: drop-shadow(0 10px 20px palette(grayscale-100));
}
&.fixed-bottom {
bottom: 0;
filter: drop-shadow(0 -10px 20px palette(grayscale-100));
}
}
}
.mp3extra__transcript {
display: none;
justify-content: center;
align-items: center;
border: 0;
color: palette(blue-45);
font-style: italic;
font-size: font-size(0);
@include breakpoint(570px) {
display: flex;
}
@include breakpoint($mp3-break-medium) {
padding: 0 15px 0 0;
}
@include breakpoint($mp3-break-large) {
padding: 0 15px;
}
}
.mp3extra__transcript__icon {
display: flex;
justify-content: center;
align-items: center;
height: vr(lima, 1);
width: vr(lima, 1);
background-color: palette(blue-60);
svg {
max-height: 16px;
width: 14px;
}
}
.mp3extra__transcript__text {
margin-left: 15px;
@include breakpoint($mp3-break-large, 'max-width') {
@include element-invisible;
}
}
.mp3extra__image {
display: none;
flex-grow: 0;
flex-shrink: 0;
width: vr(lima, 2);
@include breakpoint($mp3-break-medium) {
display: block;
}
img {
height: 100%;
width: auto;
}
}
.mp3extra__subscribe {
position: relative;
display: flex;
justify-content: center;
white-space: nowrap;
}
.mp3extra__subscribe__text {
@media (min-width: map-get($breakpoints, $mp3-break-small)) and (max-width: map-get($breakpoints, $mp3-break-medium)) {
@include element-invisible;
}
}
.mp3extra__subscribe__button {
@include font-family(sans);
height: auto;
border: 0;
padding-right: 10px;
background: transparent;
appearance: none;
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
font-size: font-size(-3);
color: palette(grayscale-50);
transition: all 0.2s;
@include breakpoint(350px) {
padding-right: 20px;
}
@include breakpoint($mp3-break-medium) {
padding: 0 20px;
font-size: font-size(-1);
}
&:hover,
&:focus,
&:active {
background-color: transparent;
outline: 0;
color: palette(blue-50);
&:after {
border-color: palette(blue-50);
}
}
&:after {
content: '';
display: inline-block;
vertical-align: middle;
width: 8px;
height: 8px;
border-bottom: solid 2px palette(grayscale-50);
border-left: solid 2px palette(grayscale-50);
margin-top: -7px;
margin-left: 10px;
transform: rotate(-45deg);
}
}
.mp3extra__subscribe-menu {
display: none; // Hide by default
position: absolute;
top: 100%;
right: 0;
width: 160px;
margin: 0;
padding: 0;
list-style: none;
line-height: 1.1;
font-size: font-size(-1);
z-index: 1; // Ensure appears above blockquote below.
.mp3extra__subscribe:hover &,
.mp3extra__subscribe__button:focus + & {
display: block;
}
// This goes on its own line because in MSEdge (where it's unsupported) it will also break all accompanying selectors.
.mp3extra__subscribe:focus-within & {
display: block;
}
.fixed-bottom & {
top: auto;
bottom: 100%;
border-bottom: solid 1px palette(grayscale-85);
}
li {
margin: 0;
padding: 0;
}
a {
display: flex;
align-items: center;
padding: 10px 20px;
border-top: solid 1px palette(grayscale-85);
border-bottom: 0;
background: palette(grayscale-95);
transition: background 0.2s;
&:focus,
&:hover {
background: palette(grayscale-85);
}
}
path,
svg {
fill: palette(grayscale-50);
width: 20px;
margin-right: 10px;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment