docbrown/app/assets/javascripts/initializers/initializePodcastPlayback.js
Fernando Valverde 0bc7b0ea21
Podcast playback update for iOS app native implementation (#6035) [deploy]
* First experiment with supressing audio playback on native iOS

* Adds load audio message posting

* First version of refactor working on web

* handlePodcastMessages now receives messages sent into JS from native iOS app

* bump back to "v2" local storage key or playback state

* terminatePodcastBar now clears the currentAudioState

* function getMediaState now initializeMedia, fixes audioExistAndIsPlaying for web, better playAudio error management & others

* Fixes bug in playAudio() & clears audioInitialized when no Podcast was initialized

* Codeclimate tweaks

* Reorders functions to comply with codeclimate

* More codeclimate reordering & fixes

* Upgrades the audio bridge messages to JSON instead of strings

* codeclimate doh
2020-03-09 12:09:42 -04:00

515 lines
15 KiB
JavaScript

'use strict'
/**
* 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()
*/
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 isNativeIOS() {
return navigator.userAgent === 'DEV-Native-ios';
}
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 {
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 {
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 playAudio(audio) {
return new Promise(function (resolve, reject) {
var currentState = currentAudioState();
if (isNativeIOS()) {
sendPodcastMessage({'action': 'play', 'seconds': 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 startAudioPlayback(audio) {
if (isNativeIOS()) {
var titleElement = document.getElementsByClassName('title')[0];
if (titleElement !== undefined) {
// We do have the Podcast data (not always true)
var message = {
'action': 'metadata',
'episodeName': titleElement.querySelector('h1').innerText,
'podcastName': titleElement.querySelector('img').alt
}
sendPodcastMessage(message);
}
}
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 {
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 {
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 {
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'});
}
}
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) {
audioInitialized = false
return;
}
if (!isNativeIOS()) {
audio.currentTime = currentState.currentTime || 0;
}
loadAudio(audio);
if (currentState.playing) {
playAudio(audio).catch(function(error) {
console.log(error); // eslint-disable-line no-console
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);
}
}