docbrown/app/assets/javascripts/initializers/initializePodcastPlayback.js
Josh Puetz 2abe830377
[deploy] Home Page Feed redesign + top comments (#7579)
* feed cards

* frontend

* preactify

* preactify

* .

* fix

* fixing things and adding dropdown

* whoops.

* whoops 2.

* revert body bg color change

* search snippet

* search snippet

* get rid of featured article

* get rid of featured article...

* cover to its own component

* videos

* videos

* reading time

* small-save-filled asset

* author adjustments

* adjustments

* Author --> Meta

* whoops.

* loader

* logged out state

* .

* dropping what's not needed for now

* better name for cover

* get rid of nav overflow

* bringin comments placeholder back

* bringing comments placeholder back

* publish date

* fix

* date

* .

* save button icon

* ghost button

* lets skip the icon for saving for now

* more buttons

* counting logic

* Display top comments on homepage feed

* Comment list and item components

* Remove 'Top Comments' title

* Remove subscribe button, add reading time

* Update snapshots and tests

* Fix article and follower scrolling

* Added reading time

* (Try) to do flare tags

* Button component from Pawel

* More button styles from Pawel

* Comment count style from Pawel

* Handle empty parens for age on older articles

* Dont show 'more comment' button for more than 2 comments

* cover ratio

* flare tag

* cover fix

* fix buttons

* more button fixes

* tags fix

* responsiveness fix

* reading time  + a bit of responsiveness

* Reading time in more places

* snapshots

* default color for flare tag

* Update CommentsList test snapshot with new styles

* fixing search results

* fixing buttons

* Fix podcast card tests by updating snapshots

* Line clamping for top comments

* Reflect save button state after click before mouseout

* Now the entire feed card is clickable. (#7638)

* Now pointer cursor is only on feed posts. (#7727)

* Revert podcast card styling

* Update test snapshot

* merge

* Fix rspec on feed page

* Reduce complexity of class check for feed card clicking

* PR feedback/fixes

* Fix featured article test and snapshot

* Last minute test tweaks

* clean up crayons

* padding issue + empty div

* Correct merge error

* Fix search results tests

* Test fixes and snapshot updates

* themes

* podcasts colors

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>
Co-authored-by: Nick Taylor <nick@iamdeveloper.com>
2020-05-12 07:57:57 -05:00

614 lines
17 KiB
JavaScript

/**
* This script hunts for podcast's "Record" for both the podcast_episde's
* show page and an article page containing podcast liquid tag. It handles
* playback and makes sure the record will spin when the podcast is currently
* playing.
*
* The media is initialized (once) and the "state" is stored using localStorage.
* When playback is the website's responsability it's run using the `audio` HTML
* element. The iOS app uses a bridging strategy that sends messages using
* webkit messageHandlers and receives incoming messages through the
* `contentaudio` element, which allows for native audio playback.
*
* The high level functions are the following:
* - spinPodcastRecord()
* - findAndApplyOnclickToRecords()
* - initializeMedia()
* - currentAudioState()
* - saveMediaState()
*
* The following are useful eslint disables for this file in particular. Because
* of the way it's wrapped around its own function (own context) we don't have
* the problem of using a method before it's defined:
*/
/* eslint no-use-before-define: 0 */
/* eslint no-param-reassign: 0 */
var audioInitialized = false;
function initializePodcastPlayback() {
function getById(name) {
return document.getElementById(name);
}
function getByClass(name) {
return document.getElementsByClassName(name);
}
function newAudioState() {
if (!window.name) {
window.name = Math.random();
}
return {
html: document.getElementById('audiocontent').innerHTML,
currentTime: 0,
playing: false,
muted: false,
volume: 1,
duration: 1,
updated: new Date().getTime(),
windowName: window.name,
};
}
function currentAudioState() {
try {
var currentState = JSON.parse(
localStorage.getItem('media_playback_state_v2'),
);
if (!currentState || window.name !== currentState.windowName) {
return newAudioState();
}
return currentState;
} catch (e) {
console.log(e); // eslint-disable-line no-console
return newAudioState();
}
}
function audioExistAndIsPlaying() {
var audio = getById('audio');
var currentState = currentAudioState();
return audio && currentState.playing;
}
function recordExist() {
return getById(`record-${window.activeEpisode}`);
}
function spinPodcastRecord(customMessage) {
if (audioExistAndIsPlaying() && recordExist()) {
getById(`record-${window.activeEpisode}`).classList.add('playing');
changeStatusMessage(customMessage);
}
}
function stopRotatingActivePodcastIfExist() {
if (window.activeEpisode && getById(`record-${window.activeEpisode}`)) {
getById(`record-${window.activeEpisode}`).classList.remove('playing');
window.activeEpisode = undefined;
}
}
function findRecords() {
var podcastPageRecords = getByClass('record-wrapper');
var podcastLiquidTagrecords = getByClass('podcastliquidtag__record');
if (podcastPageRecords.length > 0) {
return podcastPageRecords;
}
return podcastLiquidTagrecords;
}
function isNativePlayer() {
return isNativeIOS() || isNativeAndroid();
}
function isNativeIOS() {
return navigator.userAgent === 'DEV-Native-ios';
}
function isNativeAndroid() {
return (
navigator.userAgent === 'DEV-Native-android' &&
typeof AndroidBridge !== 'undefined' &&
AndroidBridge !== null &&
AndroidBridge.metadataPodcast !== undefined
);
}
function saveMediaState(state) {
var currentState = state || currentAudioState();
var newState = newAudioState();
newState.currentTime = currentState.currentTime;
newState.playing = currentState.playing;
newState.muted = currentState.muted;
newState.volume = currentState.volume;
newState.duration = currentState.duration;
localStorage.setItem('media_playback_state_v2', JSON.stringify(newState));
return newState;
}
function sendPodcastMessage(message) {
try {
if (
window &&
window.webkit &&
window.webkit.messageHandlers &&
window.webkit.messageHandlers.podcast
) {
window.webkit.messageHandlers.podcast.postMessage(message);
}
} catch (err) {
console.log(err.message); // eslint-disable-line no-console
}
}
function applyOnclickToPodcastBar(audio) {
var currentState = currentAudioState();
getById('barPlayPause').onclick = function () {
playPause(audio);
};
getById('mutebutt').onclick = function () {
muteUnmute(audio);
};
getById('volbutt').onclick = function () {
muteUnmute(audio);
};
getById('bufferwrapper').onclick = function (e) {
goToTime(e, audio);
};
getById('volumeslider').value = currentState.volume * 100;
getById('volumeslider').onchange = function (e) {
updateVolume(e, audio);
};
getById('speed').onclick = function () {
changePlaybackRate(audio);
};
getById('closebutt').onclick = function () {
terminatePodcastBar(audio);
};
}
function podcastBarAlreadyExistAndPlayingTargetEpisode(episodeSlug) {
return getById('audiocontent').innerHTML.indexOf(`${episodeSlug}`) !== -1;
}
function updateProgressListener(audio) {
return function (e) {
var bufferValue = 0;
if (audio.currentTime > 0) {
var bufferEnd = audio.buffered.end(audio.buffered.length - 1);
bufferValue = (bufferEnd / audio.duration) * 100;
}
updateProgress(audio.currentTime, audio.duration, bufferValue);
};
}
function loadAudio(audio) {
if (isNativeIOS()) {
sendPodcastMessage({
action: 'load',
url: audio.querySelector('source').src,
});
} else if (isNativeAndroid()) {
AndroidBridge.loadPodcast(audio.querySelector('source').src);
} else {
audio.load();
}
}
function loadAndPlayNewPodcast(episodeSlug) {
getById('audiocontent').innerHTML = getById(
`hidden-audio-${episodeSlug}`,
).innerHTML;
var audio = getById('audio');
audio.addEventListener('timeupdate', updateProgressListener(audio), false);
loadAudio(audio);
playPause(audio);
applyOnclickToPodcastBar(audio);
}
function findAndApplyOnclickToRecords() {
var records = findRecords();
Array.prototype.forEach.call(records, function (record) {
var episodeSlug = record.getAttribute('data-episode');
var podcastSlug = record.getAttribute('data-podcast');
record.onclick = function () {
if (podcastBarAlreadyExistAndPlayingTargetEpisode(episodeSlug)) {
var audio = getById('audio');
if (audio) {
playPause(audio);
}
} else {
stopRotatingActivePodcastIfExist();
loadAndPlayNewPodcast(episodeSlug);
}
};
});
}
function changePlaybackRate(audio) {
var currentState = currentAudioState();
var el = getById('speed');
var speed = parseFloat(el.getAttribute('data-speed'));
if (speed === 2) {
el.setAttribute('data-speed', 0.5);
el.innerHTML = '0.5x';
currentState.playbackRate = 0.5;
} else {
el.setAttribute('data-speed', speed + 0.5);
el.innerHTML = speed + 0.5 + 'x';
currentState.playbackRate = speed + 0.5;
}
saveMediaState(currentState);
if (isNativeIOS()) {
sendPodcastMessage({
action: 'rate',
rate: currentState.playbackRate.toString(),
});
} else if (isNativeAndroid()) {
AndroidBridge.ratePodcast(currentState.playbackRate);
} else {
audio.playbackRate = currentState.playbackRate;
}
}
function changeStatusMessage(message) {
var statusBox = getById(`status-message-${window.activeEpisode}`);
if (statusBox) {
if (message) {
statusBox.classList.add('showing');
statusBox.innerHTML = message;
} else {
statusBox.classList.remove('showing');
}
} else if (
message === 'initializing...' &&
document.querySelector('.status-message')
) {
document.querySelector('.status-message').innerHTML = message;
}
}
function startPodcastBar() {
getById('barPlayPause').classList.add('playing');
getById('progressBar').classList.add('playing');
getById('animated-bars').classList.add('playing');
}
function pausePodcastBar() {
getById('barPlayPause').classList.remove('playing');
getById('animated-bars').classList.remove('playing');
}
function sendPlayMessage(atSeconds) {
if (isNativeIOS()) {
sendPodcastMessage({
action: 'play',
seconds: atSeconds,
});
} else {
AndroidBridge.playPodcast(atSeconds);
}
}
function playAudio(audio) {
return new Promise(function (resolve, reject) {
var currentState = currentAudioState();
if (isNativePlayer()) {
sendPlayMessage(currentState.currentTime.toString());
setPlaying(true);
resolve();
} else {
audio.currrentTime = currentState.currentTime;
audio
.play()
.then(function () {
setPlaying(true);
resolve();
})
.catch(function (error) {
console.log(error); // eslint-disable-line no-console
setPlaying(false);
reject();
});
}
});
}
function fetchMetadataString() {
var episodeContainer = getByClass('podcast-episode-container')[0];
if (episodeContainer === undefined) {
episodeContainer = getByClass('podcastliquidtag')[0];
}
return episodeContainer.dataset.meta;
}
function sendMetadataMessage() {
try {
var metadata = JSON.parse(fetchMetadataString());
var message = {
action: 'metadata',
episodeName: metadata.episodeName,
podcastName: metadata.podcastName,
podcastImageUrl: metadata.podcastImageUrl,
};
if (isNativeIOS()) {
sendPodcastMessage(message);
} else {
AndroidBridge.metadataPodcast(
metadata.episodeName,
metadata.podcastName,
metadata.podcastImageUrl,
);
}
} catch (e) {
console.log('Unable to load Podcast Episode metadata', e); // eslint-disable-line no-console
}
}
function startAudioPlayback(audio) {
if (isNativePlayer()) {
sendMetadataMessage();
}
playAudio(audio)
.then(function () {
spinPodcastRecord();
startPodcastBar();
})
.catch(function (error) {
playAudio(audio);
setTimeout(function () {
spinPodcastRecord('initializing...');
startPodcastBar();
}, 5);
});
}
function pauseAudioPlayback(audio) {
if (isNativeIOS()) {
sendPodcastMessage({ action: 'pause' });
} else if (isNativeAndroid()) {
AndroidBridge.pausePodcast();
} else {
audio.pause();
}
setPlaying(false);
stopRotatingActivePodcastIfExist();
pausePodcastBar();
}
function playPause(audio) {
window.activeEpisode = audio.getAttribute('data-episode');
window.activePodcast = audio.getAttribute('data-podcast');
var currentState = currentAudioState();
if (!currentState.playing) {
ga(
'send',
'event',
'click',
'play podcast',
`${window.activePodcast} ${window.activeEpisode}`,
null,
);
changeStatusMessage('initializing...');
startAudioPlayback(audio);
} else {
ga(
'send',
'event',
'click',
'pause podcast',
`${window.activePodcast} ${window.activeEpisode}`,
null,
);
pauseAudioPlayback(audio);
changeStatusMessage(null);
}
}
function muteUnmute(audio) {
var currentState = currentAudioState();
getById('mutebutt').classList.add(
currentState.muted ? 'hidden' : 'showing',
);
getById('volumeindicator').classList.add(
currentState.muted ? 'showing' : 'hidden',
);
getById('mutebutt').classList.remove(
currentState.muted ? 'showing' : 'hidden',
);
getById('volumeindicator').classList.remove(
currentState.muted ? 'hidden' : 'showing',
);
currentState.muted = !currentState.muted;
if (isNativeIOS()) {
sendPodcastMessage({
action: 'muted',
muted: currentState.muted.toString(),
});
} else if (isNativeAndroid()) {
AndroidBridge.mutePodcast(currentState.muted);
} else {
audio.muted = currentState.muted;
}
saveMediaState(currentState);
}
function updateVolume(e, audio) {
var currentState = currentAudioState();
currentState.volume = e.target.value / 100;
if (isNativeIOS()) {
sendPodcastMessage({ action: 'volume', volume: currentState.volume });
} else {
audio.volume = currentState.volume;
}
saveMediaState(currentState);
}
function updateProgress(currentTime, duration, bufferValue) {
var progress = getById('progress');
var buffer = getById('buffer');
var time = getById('time');
var value = 0;
var firstDecimal = currentTime - Math.floor(currentTime);
if (currentTime > 0) {
value = Math.floor((100.0 / duration) * currentTime);
if (firstDecimal < 0.4) {
// Rewrite to mediaState storage every few beats.
var currentState = currentAudioState();
currentState.duration = duration;
currentState.currentTime = currentTime;
saveMediaState(currentState);
}
}
if (progress && time && currentTime > 0) {
progress.style.width = value + '%';
buffer.style.width = bufferValue + '%';
time.innerHTML =
readableDuration(currentTime) + ' / ' + readableDuration(duration);
}
}
function goToTime(e, audio) {
var currentState = currentAudioState();
var progress = getById('progress');
var time = getById('time');
if (e.clientX > 128) {
var percent = (e.clientX - 128) / (window.innerWidth - 133);
var duration = currentState.duration;
currentState.currentTime = duration * percent; // jumps to 29th secs
if (isNativeIOS()) {
sendPodcastMessage({
action: 'seek',
seconds: currentState.currentTime.toString(),
});
} else if (isNativeAndroid()) {
AndroidBridge.seekPodcast(currentState.currentTime);
} else {
audio.currentTime = currentState.currentTime;
}
time.innerHTML =
readableDuration(currentState.currentTime) +
' / ' +
readableDuration(currentState.duration);
progress.style.width = percent * 100.0 + '%';
}
}
function readableDuration(seconds) {
var sec = Math.floor(seconds);
var min = Math.floor(sec / 60);
min = min >= 10 ? min : '0' + min;
sec = Math.floor(sec % 60);
sec = sec >= 10 ? sec : '0' + sec;
return min + ':' + sec;
}
function terminatePodcastBar(audio) {
audio.removeEventListener(
'timeupdate',
updateProgressListener(audio),
false,
);
getById('audiocontent').innerHTML = '';
stopRotatingActivePodcastIfExist();
saveMediaState(newAudioState());
if (isNativeIOS()) {
sendPodcastMessage({ action: 'terminate' });
} else if (isNativeAndroid()) {
AndroidBridge.terminatePodcast();
}
}
function handlePodcastMessages(mutation) {
if (mutation.type !== 'attributes') {
return;
}
var message = {};
try {
var messageData = getById('audiocontent').dataset.podcast;
message = JSON.parse(messageData);
} catch (e) {
console.log(e); // eslint-disable-line no-console
return;
}
var currentState = currentAudioState();
if (message.action === 'tick') {
currentState.currentTime = message.currentTime;
currentState.duration = message.duration;
saveMediaState(currentState);
updateProgress(currentState.currentTime, currentState.duration, 100);
} else {
console.log('Unrecognized podcast message: ', message); // eslint-disable-line no-console
}
}
function addMutationObserver() {
var mutationObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
handlePodcastMessages(mutation);
});
});
mutationObserver.observe(getById('audiocontent'), { attributes: true });
}
function initializeMedia() {
var currentState = currentAudioState();
document.getElementById('audiocontent').innerHTML = currentState.html;
var audio = getById('audio');
if (audio === undefined || audio === null) {
audioInitialized = false;
return;
}
if (!isNativeIOS()) {
audio.currentTime = currentState.currentTime || 0;
}
loadAudio(audio);
if (currentState.playing) {
playAudio(audio).catch(function (error) {
pausePodcastBar();
});
}
setTimeout(function () {
audio.addEventListener(
'timeupdate',
updateProgressListener(audio),
false,
);
addMutationObserver();
}, 500);
applyOnclickToPodcastBar(audio);
}
function setPlaying(playing) {
var currentState = currentAudioState();
currentState.playing = playing;
saveMediaState(currentState);
}
spinPodcastRecord();
findAndApplyOnclickToRecords();
if (!audioInitialized) {
audioInitialized = true;
initializeMedia();
}
var audio = getById('audio');
var audioContent = getById('audiocontent');
if (audio && audioContent && audioContent.innerHTML.length < 25) {
// audio not already loaded
loadAudio(audio);
}
}