@cassette/core
Version:
A simple, clean, and responsive visual wrapper for the HTML audio tag, built with React.
1,448 lines (1,360 loc) • 49.3 kB
JavaScript
import React, { Component, Fragment, createElement } from 'react';
import PropTypes from 'prop-types';
import arrayFindIndex from 'array-find-index';
import PlayerContext from './PlayerContext';
import GroupContext from './GroupContext';
import * as PlayerPropTypes from './PlayerPropTypes';
import createCustomMediaElement from './factories/createCustomMediaElement';
import ShuffleManager from './utils/ShuffleManager';
import { getStateSnapshot, restoreStateFromSnapshot } from './utils/snapshot';
import getSourceList from './utils/getSourceList';
import getTrackSources from './utils/getTrackSources';
import getTimeRangesArray from './utils/getTimeRangesArray';
import findTrackIndexByUrl from './utils/findTrackIndexByUrl';
import isPlaylistValid from './utils/isPlaylistValid';
import getRepeatStrategy from './utils/getRepeatStrategy';
import convertToNumberWithinIntervalBounds from './utils/convertToNumberWithinIntervalBounds';
import { logError, logWarning } from './utils/console';
import getDisplayText from './utils/getDisplayText';
import getInitialDuration from './utils/getInitialDuration';
import { repeatStrategyOptions, MEDIA_ERR_NETWORK } from './constants';
function playErrorHandler(err) {
logError(err);
if (err.name === 'NotAllowedError') {
const warningMessage =
'Media playback failed at ' +
new Date().toLocaleTimeString() +
'! (Perhaps autoplay is disabled in this browser.)';
logWarning(warningMessage);
}
}
// Existing Media Session API implementations have default handlers
// for play/pause, and may yield unexpected behavior if custom
// play/pause handlers are defined - so let's leave them be.
const supportableMediaSessionActions = [
'previoustrack',
'nexttrack',
'seekbackward',
'seekforward'
];
const defaultState = {
// indicates whether media player should be paused
paused: true,
// elapsed time for active track, in seconds
currentTime: 0,
// The most recent targeted time, in seconds, for seek preview
seekPreviewTime: 0,
/* true if the user is currently dragging the mouse
* to seek a new track position
*/
seekInProgress: false,
/* true if media was playing when seek previewing began,
* it was paused, and it should be resumed on seek
* complete
*/
awaitingResumeOnSeekComplete: false,
// true if media will play once new track has loaded
awaitingPlayAfterTrackLoad: false,
// the duration in seconds of the loaded track
duration: 0,
// array describing the buffered ranges in the loaded track
bufferedRanges: [],
// array describing the already-played ranges in the loaded track
playedRanges: [],
// array describing the seekable ranges in the loaded track
seekableRanges: [],
// true if the media is currently stalled pending data buffering
stalled: false,
// true if the active track should play on the next componentDidUpdate
shouldRequestPlayOnNextUpdate: false,
/* true if an error occurs while fetching the active track media data
* or if its type is not a supported media format
*/
mediaCannotPlay: false,
// maximum currentTime since the current track has been playing
maxKnownTime: 0
};
// assumes playlist is valid
function getGoToTrackState({
prevState,
index,
track,
shouldPlay = true,
shouldForceLoad = false,
startingTime
}) {
const isNewTrack = prevState.activeTrackIndex !== index;
const shouldLoadAsNew = Boolean(isNewTrack || shouldForceLoad);
const currentTime = startingTime || track.startingTime || 0;
return {
duration: getInitialDuration(track),
activeTrackIndex: index,
trackLoading: shouldLoadAsNew,
mediaCannotPlay: prevState.mediaCannotPlay && !shouldLoadAsNew,
currentTime: convertToNumberWithinIntervalBounds(currentTime, 0),
loop: shouldLoadAsNew ? false : prevState.loop,
shouldRequestPlayOnNextUpdate: Boolean(shouldPlay),
awaitingPlayAfterTrackLoad: Boolean(shouldPlay),
awaitingForceLoad: Boolean(shouldForceLoad),
maxKnownTime: shouldLoadAsNew ? 0 : prevState.maxKnownTime
};
}
/**
* Wraps an area which shares a common [`playerContext`](#playercontext)
*/
export class PlayerContextProvider extends Component {
constructor(props) {
super(props);
let currentTime = 0;
let activeTrackIndex = convertToNumberWithinIntervalBounds(
props.startingTrackIndex,
0
);
const playlistIsValid = isPlaylistValid(props.playlist);
if (playlistIsValid && props.playlist[activeTrackIndex]) {
currentTime = props.playlist[activeTrackIndex].startingTime || 0;
}
const { initialStateSnapshot } = props;
let restoredStateFromSnapshot = {};
if (initialStateSnapshot) {
try {
restoredStateFromSnapshot = restoreStateFromSnapshot(
initialStateSnapshot,
props
);
const {
activeTrackIndex: a,
currentTime: c
} = restoredStateFromSnapshot;
if (typeof a === 'number') {
activeTrackIndex = a;
}
if (typeof c === 'number') {
currentTime = c;
}
} catch (err) {
logWarning(err);
logWarning('Loading Cassette state from snapshot failed.');
logWarning(
`Failed snapshot:\n${JSON.stringify(initialStateSnapshot, null, 2)}`
);
}
}
this.state = {
...defaultState,
// index matching requested track (whether track has loaded or not)
activeTrackIndex,
// whether we're waiting on loading metadata for the active track
trackLoading: isPlaylistValid(props.playlist),
// the current timestamp on the active track in seconds
currentTime: convertToNumberWithinIntervalBounds(currentTime, 0),
// the latest volume of the media, between 0 and 1.
volume: convertToNumberWithinIntervalBounds(props.defaultVolume, 0, 1),
// true if the media has been muted
muted: props.defaultMuted,
// whether to loop the active track
loop: props.defaultRepeatStrategy === 'track',
// true if playlist should continue at start after completion
cycle: props.defaultRepeatStrategy === 'playlist',
// whether to randomly pick next track from playlist after one finishes
shuffle: props.defaultShuffle,
// Rate at which media should be played. 1.0 is normal speed.
playbackRate: props.defaultPlaybackRate,
// true if user is currently dragging mouse to change the volume
setVolumeInProgress: false,
// initialize shouldRequestPlayOnNextUpdate from autoplay prop
shouldRequestPlayOnNextUpdate: props.autoplay && playlistIsValid,
awaitingForceLoad: false,
// duration might be set on track object
duration: getInitialDuration(
playlistIsValid && props.playlist[activeTrackIndex]
),
// playlist prop copied to state (for getDerivedStateFromProps)
__playlist__: props.playlist,
// load overrides from previously-captured state snapshot
...restoredStateFromSnapshot
};
// volume at last time we were unmuted and not actively setting volume
this.lastStableVolume = this.state.volume;
// used to keep track of play history when we are shuffling
this.shuffler = new ShuffleManager(getSourceList(props.playlist), {
allowBackShuffle: props.allowBackShuffle
});
// html media element used for playback
this.media = null;
this.videoHostElementList = [];
this.videoHostOccupiedCallbacks = new Map();
this.videoHostVacatedCallbacks = new Map();
// bind internal methods
this.handleTrackPlaybackFailure = this.handleTrackPlaybackFailure.bind(
this
);
this.handlePlayerOnlineAfterFailure = this.handlePlayerOnlineAfterFailure.bind(
this
);
// bind callback methods to pass to descendant elements
this.togglePause = this.togglePause.bind(this);
this.selectTrackIndex = this.selectTrackIndex.bind(this);
this.forwardSkip = this.forwardSkip.bind(this);
this.backSkip = this.backSkip.bind(this);
this.seekPreview = this.seekPreview.bind(this);
this.seekComplete = this.seekComplete.bind(this);
this.setVolume = this.setVolume.bind(this);
this.setVolumeComplete = this.setVolumeComplete.bind(this);
this.toggleMuted = this.toggleMuted.bind(this);
this.toggleShuffle = this.toggleShuffle.bind(this);
this.setRepeatStrategy = this.setRepeatStrategy.bind(this);
this.setPlaybackRate = this.setPlaybackRate.bind(this);
this.registerVideoHostElement = this.registerVideoHostElement.bind(this);
this.renderVideoIntoHostElement = this.renderVideoIntoHostElement.bind(
this
);
this.unregisterVideoHostElement = this.unregisterVideoHostElement.bind(
this
);
this.updateVideoHostElement = this.updateVideoHostElement.bind(this);
// bind media event handlers
this.handleMediaPlay = this.handleMediaPlay.bind(this);
this.handleMediaPause = this.handleMediaPause.bind(this);
this.handleMediaSrcrequest = this.handleMediaSrcrequest.bind(this);
this.handleMediaEnded = this.handleMediaEnded.bind(this);
this.handleMediaEmptied = this.handleMediaEmptied.bind(this);
this.handleMediaStalled = this.handleMediaStalled.bind(this);
this.handleMediaCanplaythrough = this.handleMediaCanplaythrough.bind(this);
this.handleMediaCanplay = this.handleMediaCanplay.bind(this);
this.handleMediaTimeupdate = this.handleMediaTimeupdate.bind(this);
this.handleMediaLoadeddata = this.handleMediaLoadeddata.bind(this);
this.handleMediaVolumechange = this.handleMediaVolumechange.bind(this);
this.handleMediaDurationchange = this.handleMediaDurationchange.bind(this);
this.handleMediaProgress = this.handleMediaProgress.bind(this);
this.handleMediaLoopchange = this.handleMediaLoopchange.bind(this);
this.handleMediaRatechange = this.handleMediaRatechange.bind(this);
}
componentDidMount() {
const media = (this.media = createCustomMediaElement(
this.props.createMediaElement()
));
const {
defaultPlaybackRate,
crossOrigin,
playlist,
autoplayDelayInSeconds,
mediaElementRef,
getPosterImageForTrack,
getMediaTitleAttributeForTrack,
onActiveTrackUpdate
} = this.props;
const {
volume,
muted,
playbackRate,
loop,
activeTrackIndex,
shouldRequestPlayOnNextUpdate
} = this.state;
// initialize media properties
// We used to set currentTime here.. now waiting for loadeddata.
// This avoids an issue where some browsers ignore or delay currentTime
// updates when in the HAVE_NOTHING state.
media.defaultPlaybackRate = defaultPlaybackRate;
if (crossOrigin) {
media.crossOrigin = crossOrigin;
}
media.volume = volume;
media.muted = muted;
media.playbackRate = playbackRate;
media.loop = loop;
media.setAttribute('playsinline', '');
media.setAttribute('webkit-playsinline', '');
media.setAttribute('preload', 'metadata');
media.setAttribute(
'poster',
getPosterImageForTrack(playlist[activeTrackIndex])
);
media.setAttribute(
'title',
getMediaTitleAttributeForTrack(playlist[activeTrackIndex])
);
// add listeners for media events
media.addEventListener('play', this.handleMediaPlay);
media.addEventListener('pause', this.handleMediaPause);
media.addEventListener('ended', this.handleMediaEnded);
media.addEventListener('stalled', this.handleMediaStalled);
media.addEventListener('emptied', this.handleMediaEmptied);
media.addEventListener('canplay', this.handleMediaCanplay);
media.addEventListener('canplaythrough', this.handleMediaCanplaythrough);
media.addEventListener('timeupdate', this.handleMediaTimeupdate);
media.addEventListener('loadeddata', this.handleMediaLoadeddata);
media.addEventListener('volumechange', this.handleMediaVolumechange);
media.addEventListener('durationchange', this.handleMediaDurationchange);
media.addEventListener('progress', this.handleMediaProgress);
media.addEventListener('ratechange', this.handleMediaRatechange);
media.addEventListener('error', this.handleTrackPlaybackFailure);
// add listeners for special events
media.addEventListener('srcrequest', this.handleMediaSrcrequest);
media.addEventListener('loopchange', this.handleMediaLoopchange);
// set source elements for current track
this.setMediaElementSources();
// initially mount media element in the hidden container (this may change)
this.mediaContainer.appendChild(media);
if (shouldRequestPlayOnNextUpdate) {
this.setState({
shouldRequestPlayOnNextUpdate: false
});
this.delayTimeout = setTimeout(() => {
this.togglePause(false);
}, autoplayDelayInSeconds * 1000);
}
if (mediaElementRef) {
mediaElementRef(media);
}
if (onActiveTrackUpdate) {
onActiveTrackUpdate({
track: playlist[activeTrackIndex],
trackIndex: activeTrackIndex,
previousTrack: null,
previousTrackIndex: null
});
}
}
static getDerivedStateFromProps(nextProps, prevState) {
const newPlaylist = nextProps.playlist;
if (newPlaylist === prevState.__playlist__) {
// reference comparison is equal so we'll
// assume the playlist is unchanged.
return null;
}
const baseNewState = {
__playlist__: newPlaylist
};
// check if the new playlist is invalid
if (!isPlaylistValid(newPlaylist)) {
return {
...defaultState,
...baseNewState,
activeTrackIndex: 0,
trackLoading: false
};
}
// check if the activeTrackIndex doesn't need to be updated
const prevSources = getTrackSources(
prevState.__playlist__,
prevState.activeTrackIndex
);
if (newPlaylist[prevState.activeTrackIndex]) {
// the sources if we stay on the same track index
const currentSources = getTrackSources(
newPlaylist,
prevState.activeTrackIndex
);
// non-comprehensive but probably accurate check
if (prevSources[0].src === currentSources[0].src) {
// our active track index already matches
return baseNewState;
}
}
/* if the track we're already playing is in the new playlist, update the
* activeTrackIndex.
*/
const newTrackIndex = findTrackIndexByUrl(newPlaylist, prevSources[0].src);
if (newTrackIndex !== -1) {
return {
...baseNewState,
activeTrackIndex: newTrackIndex
};
}
// if not, then load the first track in the new playlist, and pause.
return {
...baseNewState,
...getGoToTrackState({
prevState,
track: newPlaylist[0],
index: 0,
shouldPlay: false,
shouldForceLoad: true
}),
mediaCannotPlay: false,
awaitingPlayAfterTrackLoad: false
};
}
componentDidUpdate(prevProps, prevState) {
this.media.defaultPlaybackRate = this.props.defaultPlaybackRate;
this.media.crossOrigin = this.props.crossOrigin;
this.shuffler.setList(getSourceList(this.props.playlist));
this.shuffler.setOptions({
allowBackShuffle: this.props.allowBackShuffle
});
const prevSources = getTrackSources(
prevProps.playlist,
prevState.activeTrackIndex
);
const newSources = getTrackSources(
this.props.playlist,
this.state.activeTrackIndex
);
const prevTrack = prevProps.playlist[prevState.activeTrackIndex];
const newTrack = this.props.playlist[this.state.activeTrackIndex];
if (
this.state.awaitingForceLoad ||
prevSources[0].src !== newSources[0].src
) {
this.setMediaElementSources();
this.media.setAttribute(
'poster',
this.props.getPosterImageForTrack(newTrack)
);
this.media.setAttribute(
'title',
this.props.getMediaTitleAttributeForTrack(newTrack)
);
this.setState({
awaitingForceLoad: false
});
if (!this.state.shuffle) {
// after toggling off shuffle, we defer clearing the shuffle
// history until we actually change tracks - if the user quickly
// toggles shuffle off then back on again, we don't want to have
// lost our history.
this.shuffler.clear();
}
// If track changes before player is back online, the previous track
// shouldn't be reloaded.
window.removeEventListener('online', this.handlePlayerOnlineAfterFailure);
}
if (this.props.onActiveTrackUpdate && prevTrack !== newTrack) {
this.props.onActiveTrackUpdate({
track: newTrack,
trackIndex: this.state.activeTrackIndex,
previousTrack: prevTrack,
previousTrackIndex: prevState.activeTrackIndex
});
}
if (prevProps !== this.props && !this.media.paused) {
// update running media session based on new props
this.stealMediaSession();
}
if (this.state.shouldRequestPlayOnNextUpdate) {
this.setState({
shouldRequestPlayOnNextUpdate: false
});
// media.currentSrc is updated asynchronously so we should
// play async to avoid weird intermediate state issues
setTimeout(() => {
this.togglePause(false);
});
}
clearTimeout(this.snapshotUpdateTimeout);
this.snapshotUpdateTimeout = setTimeout(() => {
if (this.props.onStateSnapshot) {
this.props.onStateSnapshot(getStateSnapshot(this.state));
}
}, 100);
}
componentWillUnmount() {
const { media } = this;
// Media element creation will have failed if MutationObserver isn't
// supported by the browser. The parent might use an Error Boundary
// to display a fallback and so we try to avoid triggering *additional*
// errors while the component unmounts.
if (media) {
// remove listeners for media events
media.removeEventListener('play', this.handleMediaPlay);
media.removeEventListener('pause', this.handleMediaPause);
media.removeEventListener('ended', this.handleMediaEnded);
media.removeEventListener('stalled', this.handleMediaStalled);
media.removeEventListener('emptied', this.handleMediaEmptied);
media.removeEventListener(
'canplaythrough',
this.handleMediaCanplaythrough
);
media.removeEventListener('canplay', this.handleMediaCanplay);
media.removeEventListener('timeupdate', this.handleMediaTimeupdate);
media.removeEventListener('loadeddata', this.handleMediaLoadeddata);
media.removeEventListener('volumechange', this.handleMediaVolumechange);
media.removeEventListener(
'durationchange',
this.handleMediaDurationchange
);
media.removeEventListener('progress', this.handleMediaProgress);
media.removeEventListener('ratechange', this.handleMediaRatechange);
media.removeEventListener('error', this.handleTrackPlaybackFailure);
// remove special event listeners on the media element
media.removeEventListener('srcrequest', this.handleMediaSrcrequest);
media.removeEventListener('loopchange', this.handleMediaLoopchange);
const sourceElements = media.querySelectorAll('source');
for (const sourceElement of sourceElements) {
sourceElement.removeEventListener(
'error',
this.handleTrackPlaybackFailure
);
}
}
clearTimeout(this.gapLengthTimeout);
clearTimeout(this.delayTimeout);
window.removeEventListener('online', this.handlePlayerOnlineAfterFailure);
}
stealMediaSession() {
if (
// eslint-disable-next-line no-undef
!(window.MediaSession && navigator.mediaSession instanceof MediaSession)
) {
return;
}
// eslint-disable-next-line no-undef
navigator.mediaSession.metadata = new MediaMetadata(
this.props.playlist[this.state.activeTrackIndex]
);
supportableMediaSessionActions
.map(action => {
if (this.props.supportedMediaSessionActions.indexOf(action) === -1) {
return null;
}
const seekLength = this.props.mediaSessionSeekLengthInSeconds;
switch (action) {
case 'play':
return this.togglePause.bind(this, false);
case 'pause':
return this.togglePause.bind(this, true);
case 'previoustrack':
return this.backSkip;
case 'nexttrack':
return this.forwardSkip;
case 'seekbackward':
return () => (this.media.currentTime -= seekLength);
case 'seekforward':
return () => (this.media.currentTime += seekLength);
default:
return undefined;
}
})
.forEach((handler, i) => {
navigator.mediaSession.setActionHandler(
supportableMediaSessionActions[i],
handler
);
});
}
setMediaElementSources() {
// remove current sources
const { playlist } = this.props;
let firstChild;
while ((firstChild = this.media.firstChild)) {
this.media.removeChild(firstChild);
}
if (isPlaylistValid(playlist)) {
const sources = getTrackSources(playlist, this.state.activeTrackIndex);
// add new sources
for (const source of sources) {
const sourceElement = document.createElement('source');
sourceElement.src = source.src;
if (source.type) {
sourceElement.type = source.type;
}
sourceElement.addEventListener(
'error',
this.handleTrackPlaybackFailure
);
this.media.appendChild(sourceElement);
}
}
// cancel playback and re-scan new sources
this.media.load();
}
reloadActiveTrack({ shouldPlay = false } = {}) {
const { playlist } = this.props;
const { activeTrackIndex, paused, currentTime } = this.state;
if (!isPlaylistValid(playlist)) {
return;
}
this.goToTrack({
track: playlist[activeTrackIndex],
shouldForceLoad: true,
shouldPlay: shouldPlay || !paused,
startingTime: currentTime,
index: activeTrackIndex
});
}
handlePlayerOnlineAfterFailure() {
this.reloadActiveTrack();
window.removeEventListener('online', this.handlePlayerOnlineAfterFailure);
}
handleTrackPlaybackFailure(event) {
const shouldPlay = !this.state.paused;
this.setState({
mediaCannotPlay: true
});
const error = event.target.error;
// If there's a network error, the current track should be reloaded
// in order to recover from the error state the player is in.
if (
event.target === this.media &&
error &&
error.code === MEDIA_ERR_NETWORK
) {
if (window.navigator.onLine) {
this.reloadActiveTrack({ shouldPlay });
} else {
window.addEventListener('online', this.handlePlayerOnlineAfterFailure);
}
}
if (this.props.onTrackPlaybackFailure) {
this.props.onTrackPlaybackFailure({
track: this.props.playlist[this.state.activeTrackIndex],
trackIndex: this.state.activeTrackIndex,
event
});
}
}
registerVideoHostElement(hostElement, { onHostOccupied, onHostVacated }) {
this.videoHostElementList = this.videoHostElementList.concat(hostElement);
this.videoHostOccupiedCallbacks.set(hostElement, onHostOccupied);
this.videoHostVacatedCallbacks.set(hostElement, onHostVacated);
}
renderVideoIntoHostElement(hostElement) {
if (this.videoHostElementList.indexOf(hostElement) === -1) {
return;
}
cancelAnimationFrame(this.videoHostUpdateRaf);
this.videoHostUpdateRaf = requestAnimationFrame(() =>
this.updateVideoHostElement(hostElement)
);
}
unregisterVideoHostElement(hostElement) {
this.videoHostElementList = this.videoHostElementList.filter(
elem => elem !== hostElement
);
this.videoHostOccupiedCallbacks.delete(hostElement);
this.videoHostVacatedCallbacks.delete(hostElement);
if (this.media.parentNode === hostElement) {
this.updateVideoHostElement();
}
}
updateVideoHostElement(hostElement) {
if (!hostElement) {
hostElement = this.videoHostElementList[0] || this.mediaContainer;
} else {
// move hostElement to front of list
this.videoHostElementList = [hostElement].concat(
this.videoHostElementList.filter(elem => elem !== hostElement)
);
}
const playing = !this.media.paused;
const oldHostElement = this.media.parentNode;
if (hostElement === oldHostElement) {
return;
}
hostElement.appendChild(this.media);
// according to the HTML spec playback should continue, but
// some browsers pause the element whenever it is moved around, so
// let's make sure playback resumes if that's the case.
if (playing && this.media.paused) {
this.media.play();
}
const onVacated = this.videoHostVacatedCallbacks.get(oldHostElement);
if (onVacated) {
onVacated(this.media);
}
const onOccupied = this.videoHostOccupiedCallbacks.get(hostElement);
if (onOccupied) {
onOccupied(this.media);
}
}
handleMediaPlay() {
this.setState(
state =>
state.paused === false && state.awaitingPlayAfterTrackLoad === false
? null
: { paused: false, awaitingPlayAfterTrackLoad: false }
);
this.stealMediaSession();
}
handleMediaPause() {
this.setState(state => (state.paused === true ? null : { paused: true }));
}
handleMediaSrcrequest(e) {
const { playlist } = this.props;
const sources = getTrackSources(playlist, this.state.activeTrackIndex);
if (arrayFindIndex(sources, s => s.src === e.srcRequested) !== -1) {
// we're good! nothing to update.
return;
}
// looks like 'src' was set from outside our component.
// let's see if we can use it.
const newTrackIndex = findTrackIndexByUrl(playlist, e.srcRequested);
if (newTrackIndex === -1) {
logError(
`Source '${e.srcRequested}' does not exist in the loaded playlist. ` +
`Make sure you've updated the 'playlist' prop to ` +
`PlayerContextProvider before you select this track!`
);
return;
}
this.selectTrackIndex(newTrackIndex);
}
handleMediaEnded() {
if (this.state.seekInProgress) {
// nothing to do if we're in the middle of a seek
// (this can happen if we're in seekMode: immediate)
return;
}
clearTimeout(this.gapLengthTimeout);
const { playlist, loadFirstTrackOnPlaylistComplete } = this.props;
if (!isPlaylistValid(playlist)) {
return;
}
const { cycle, activeTrackIndex } = this.state;
if (!cycle && activeTrackIndex + 1 >= playlist.length) {
if (loadFirstTrackOnPlaylistComplete) {
this.goToTrack({
index: 0,
track: playlist[0],
shouldPlay: false,
shouldForceLoad: true
});
}
return;
}
this.gapLengthTimeout = setTimeout(
this.forwardSkip,
this.props.gapLengthInSeconds * 1000
);
}
handleMediaStalled() {
this.setState(state => (state.stalled === true ? null : { stalled: true }));
}
handleMediaEmptied() {
this.setState(state => (state.paused === true ? null : { paused: true }));
}
handleMediaCanplay() {
this.setState(
state => (state.trackLoading === false ? null : { trackLoading: false })
);
}
handleMediaCanplaythrough() {
this.setState(
state => (state.stalled === false ? null : { stalled: false })
);
}
handleMediaTimeupdate() {
const { currentTime, played } = this.media;
const { onTimeUpdate, playlist } = this.props;
const { activeTrackIndex, trackLoading } = this.state;
if (trackLoading) {
// we'll get another time update when the track loads
// but for now this helps us avoid unnecessarily
// jumping back to currentTime: 0 in the UI while
// the track is loading.
return;
}
this.setState(state => ({
currentTime,
playedRanges: getTimeRangesArray(played),
maxKnownTime: Math.max(state.maxKnownTime, currentTime)
}));
if (onTimeUpdate) {
onTimeUpdate({
currentTime,
track: playlist[activeTrackIndex],
trackIndex: activeTrackIndex
});
}
}
handleMediaLoadeddata() {
if (this.media.currentTime !== this.state.currentTime) {
this.media.currentTime = this.state.currentTime;
}
}
handleMediaVolumechange() {
const { volume, muted } = this.media;
this.setState({ volume, muted });
}
handleMediaDurationchange() {
const { duration } = this.media;
const activeTrack = this.props.playlist[this.state.activeTrackIndex];
if (duration === Infinity) {
// This *could* be because we're consuming an unbounded stream.
// It could also be because of a weird iOS bug that we want to
// try to prevent. See https://github.com/benwiley4000/cassette/issues/355
// If we still end up with Infinity duration multiple times for
// the same track, we'll assume it's correct.
if (
activeTrack.isUnboundedStream ||
activeTrack === this.activeTrackAtLastDurationChange
) {
this.setState({
duration,
currentTime: 0
});
this.media.currentTime = 0;
} else {
const { paused } = this.state;
this.media.load();
if (!paused) {
// media.currentSrc is updated asynchronously so we should
// play async to avoid weird intermediate state issues
setTimeout(() => {
this.togglePause(false);
});
}
}
} else {
this.setState({ duration });
}
this.activeTrackAtLastDurationChange = activeTrack;
}
handleMediaProgress() {
this.setState({
bufferedRanges: getTimeRangesArray(this.media.buffered),
seekableRanges: getTimeRangesArray(this.media.seekable)
});
}
handleMediaLoopchange() {
const { loop } = this.media;
this.setState(state => (state.loop === loop ? null : { loop }));
}
handleMediaRatechange() {
const { playbackRate } = this.media;
this.setState(
state => (state.playbackRate === playbackRate ? null : { playbackRate })
);
}
togglePause(value) {
clearTimeout(this.delayTimeout);
const pause = typeof value === 'boolean' ? value : !this.state.paused;
if (pause) {
this.media.pause();
return;
}
if (!this.media.currentSrc) {
return;
}
try {
const playPromise = this.media.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise
.catch(err => {
// AbortError is pretty much always called because we're skipping
// tracks quickly or hitting pause before a track has a chance to
// play. It's pretty safe to just ignore these error messages.
if (err.name !== 'AbortError') {
return Promise.reject(err);
}
})
.catch(playErrorHandler);
}
} catch (err) {
playErrorHandler(err);
}
}
// assumes playlist is valid - don't call without checking
goToTrack(args) {
clearTimeout(this.delayTimeout);
this.setState(prevState => getGoToTrackState({ prevState, ...args }));
}
selectTrackIndex(index) {
const { playlist } = this.props;
if (!isPlaylistValid(playlist)) {
return;
}
if (index < 0 || index > playlist.length) {
logWarning(`Playlist index ${index} is out of bounds!`);
return;
}
if (this.state.shuffle) {
this.shuffler.pickNextItem(index, this.state.activeTrackIndex);
}
this.goToTrack({ index, track: playlist[index] });
}
backSkip() {
const { playlist, stayOnBackSkipThreshold } = this.props;
const { media } = this;
const { cycle, activeTrackIndex, shuffle } = this.state;
if (
!isPlaylistValid(playlist) ||
media.currentTime >= stayOnBackSkipThreshold ||
(!cycle && activeTrackIndex < 1)
) {
media.currentTime = 0;
return;
}
let index;
if (shuffle) {
const previousItem = this.shuffler.findPreviousItem(activeTrackIndex);
if (previousItem === undefined) {
// if we aren't allowing backShuffle then we'll hit a stopping point.
media.currentTime = 0;
return;
}
index = findTrackIndexByUrl(playlist, previousItem);
} else {
index = activeTrackIndex - 1;
if (index < 0) {
index = playlist.length - 1;
}
}
this.goToTrack({ index, track: playlist[index], shouldForceLoad: true });
}
forwardSkip() {
const { playlist } = this.props;
const { cycle, activeTrackIndex, shuffle } = this.state;
if (
!isPlaylistValid(playlist) ||
(!cycle && activeTrackIndex + 1 >= playlist.length)
) {
return;
}
let index;
if (shuffle) {
index = findTrackIndexByUrl(
playlist,
this.shuffler.findNextItem(activeTrackIndex)
);
} else {
index = activeTrackIndex + 1;
if (index >= playlist.length) {
index = 0;
}
}
this.goToTrack({ index, track: playlist[index], shouldForceLoad: true });
}
seekPreview(targetTime) {
if (!isPlaylistValid(this.props.playlist)) {
return;
}
const baseStateUpdate = {
seekPreviewTime: targetTime,
seekInProgress: true
};
switch (this.props.seekMode) {
case 'paused':
this.setState(({ paused, awaitingResumeOnSeekComplete }) => ({
...baseStateUpdate,
awaitingResumeOnSeekComplete: paused
? awaitingResumeOnSeekComplete
: true,
currentTime: targetTime
}));
if (!this.state.trackLoading) {
this.media.currentTime = targetTime;
}
if (!this.state.paused) {
this.togglePause(true);
}
break;
case 'immediate':
this.setState(({ paused, awaitingResumeOnSeekComplete }) => ({
...baseStateUpdate,
awaitingResumeOnSeekComplete: paused
? awaitingResumeOnSeekComplete
: true,
currentTime: targetTime
}));
if (!this.state.trackLoading) {
this.media.currentTime = targetTime;
}
if (this.state.awaitingResumeOnSeekComplete && !this.media.ended) {
// if we earlier encountered an 'ended' state,
// un-pausing becomes necessary to resume playback
this.togglePause(false);
}
break;
case 'onrelease':
this.setState(baseStateUpdate);
break;
}
}
seekComplete(targetTime) {
const {
seekPreviewTime,
awaitingResumeOnSeekComplete,
trackLoading
} = this.state;
const baseStateUpdate = {
seekInProgress: false,
awaitingResumeOnSeekComplete: false
};
const currentTime =
typeof targetTime === 'number' ? targetTime : seekPreviewTime;
if (isNaN(currentTime)) {
this.setState(baseStateUpdate);
return;
}
this.setState({
...baseStateUpdate,
/* we'll update currentTime on the media listener hook anyway,
* but that might not happen for a bit... so the optimistic update
* helps us avoid the progress bar jumping around and confusing the user.
* https://github.com/benwiley4000/cassette/issues/209
*/
currentTime
});
if (!trackLoading) {
this.media.currentTime = currentTime;
}
if (awaitingResumeOnSeekComplete) {
if (this.media.ended) {
this.forwardSkip();
} else {
this.togglePause(false);
}
}
}
setVolume(volume, inProgress = true) {
if (inProgress && !this.state.setVolumeInProgress) {
this.setState({
setVolumeInProgress: true
});
}
const volumeInBounds = convertToNumberWithinIntervalBounds(volume, 0, 1);
this.media.muted = volumeInBounds === 0 ? true : false;
this.media.volume = volumeInBounds;
}
setVolumeComplete(volume) {
if (typeof volume === 'number') {
this.setVolume(volume, false);
}
this.setState({
setVolumeInProgress: false
});
if (!this.media.muted) {
this.lastStableVolume = this.media.volume;
}
}
toggleMuted(value) {
const muted = typeof value === 'boolean' ? value : !this.state.muted;
this.media.muted = muted;
if (!muted) {
this.media.volume = this.lastStableVolume;
}
}
toggleShuffle(value) {
const shuffle = typeof value === 'boolean' ? value : !this.state.shuffle;
this.setState({ shuffle });
}
setRepeatStrategy(repeatStrategy) {
if (repeatStrategyOptions.indexOf(repeatStrategy) === -1) {
logWarning(
'repeatStrategy "' +
repeatStrategy +
'" is not one of: ' +
repeatStrategyOptions.split(', ') +
'.'
);
return;
}
switch (repeatStrategy) {
case 'track':
// state update is automatic
this.media.loop = true;
break;
case 'playlist':
this.setState({
loop: false,
cycle: true
});
this.media.loop = false;
break;
case 'none':
this.setState({
loop: false,
cycle: false
});
this.media.loop = false;
break;
}
}
setPlaybackRate(rate) {
this.media.playbackRate = rate;
}
getControlProps() {
const { props, state } = this;
const playerContext = {
playlist: props.playlist,
activeTrackIndex: state.activeTrackIndex,
trackLoading: state.trackLoading,
paused: state.paused,
currentTime: state.currentTime,
seekPreviewTime: state.seekPreviewTime,
seekInProgress: state.seekInProgress,
awaitingPlayResume:
state.awaitingResumeOnSeekComplete || state.awaitingPlayAfterTrackLoad,
duration:
state.duration === Infinity ? state.maxKnownTime : state.duration,
bufferedRanges: state.bufferedRanges,
playedRanges: state.playedRanges,
seekableRanges: state.seekableRanges,
volume: state.volume,
muted: state.muted,
shuffle: state.shuffle,
stalled: state.stalled,
playbackRate: state.playbackRate,
mediaCannotPlay: state.mediaCannotPlay,
setVolumeInProgress: state.setVolumeInProgress,
repeatStrategy: getRepeatStrategy(state.loop, state.cycle),
registerVideoHostElement: this.registerVideoHostElement,
renderVideoIntoHostElement: this.renderVideoIntoHostElement,
unregisterVideoHostElement: this.unregisterVideoHostElement,
onTogglePause: this.togglePause,
onSelectTrackIndex: this.selectTrackIndex,
onBackSkip: this.backSkip,
onForwardSkip: this.forwardSkip,
onSeekPreview: this.seekPreview,
onSeekComplete: this.seekComplete,
onSetVolume: this.setVolume,
onSetVolumeComplete: this.setVolumeComplete,
onToggleMuted: this.toggleMuted,
onToggleShuffle: this.toggleShuffle,
onSetRepeatStrategy: this.setRepeatStrategy,
onSetPlaybackRate: this.setPlaybackRate,
reloadActiveTrack: this.reloadActiveTrack
};
if (this.playerContext) {
// only update this.playerContext if something has changed
for (const key of Object.keys(this.playerContext)) {
if (playerContext[key] !== this.playerContext[key]) {
this.playerContext = playerContext;
break;
}
}
} else {
// first time - nothing to compare
this.playerContext = playerContext;
}
return this.playerContext;
}
render() {
const playerContext = this.getControlProps();
return (
<Fragment>
<div ref={elem => (this.mediaContainer = elem)} hidden />
<PlayerContext.Provider value={playerContext}>
{typeof this.props.children === 'function'
? this.props.children(playerContext)
: this.props.children}
</PlayerContext.Provider>
</Fragment>
);
}
}
PlayerContextProvider.propTypes = {
/**
* An array of [`track`](#track) objects to play in order (except when
* shuffle mode is turned on)
**/
playlist: PropTypes.arrayOf(PlayerPropTypes.track.isRequired).isRequired,
/** Set to `true` to play media on player load. Think before doing this. It is
* often a bad idea, although it can make sense if your app is expressly made
* for media playback and you're resuming playback from the last session.
* Note that some platforms will disallow this from happening under many
* scenarios, and in these cases, `autoplay` will simply fail gracefully.
*/
autoplay: PropTypes.bool.isRequired,
/**
* If your app uses a custom implementation of the `HTMLMediaElement`, you
* can supply your own factory function to return it. This is an advanced
* edge case.
*/
createMediaElement: PropTypes.func.isRequired,
/** If you're using `autoplay` but want to wait a few seconds before the
* media playback kicks in, you can specify that timeout in second here.
*/
autoplayDelayInSeconds: PropTypes.number.isRequired,
/**
* Similar to `autoplayDelayInSeconds` but for the pause between tracks
* (if you want to evoke the feeling of listening to one of those old CDs with
* a negative countdown before it starts, which you never asked for).
*/
gapLengthInSeconds: PropTypes.number.isRequired,
/** A [`crossOriginAttribute`](#crossoriginattribute) value */
crossOrigin: PlayerPropTypes.crossOriginAttribute,
/** The starting volume (0-1) */
defaultVolume: PropTypes.number.isRequired,
/** The starting `muted` value (`true` or `false`) */
defaultMuted: PropTypes.bool,
/** The starting [`repeatStrategy`](#repeatstrategy) */
defaultRepeatStrategy: PlayerPropTypes.repeatStrategy.isRequired,
/** Will shuffle mode be active by default? */
defaultShuffle: PropTypes.bool,
/** The starting playback rate (1 is normal, 0.5 is half, 2 is double) */
defaultPlaybackRate: PropTypes.number.isRequired,
/**
* The starting track index (an advanced use case.. normally you should put
* the track you want to hear first at the start of the playlist, unless
* you are loading an `initialStateSnapshot` which will override this value
* anyway
*/
startingTrackIndex: PropTypes.number.isRequired,
/**
* Set this `false` if the player should rest of the final track when
* the playlist has completed. Ignored unless the
* current [`repeatStrategy`](#repeatstrategy) is `none`
*/
loadFirstTrackOnPlaylistComplete: PropTypes.bool,
/** Use this to set the player's [`seekMode`](#seekmode) */
seekMode: PlayerPropTypes.seekMode.isRequired,
/**
* The default media element behavior is to reset the `playbackRate` to 1
* whenever a new source is loaded. Set this prop to `true` to maintain the
* same irregular playback rate across multiple tracks.
*/
maintainPlaybackRate: PropTypes.bool.isRequired,
/**
* By default, activating a back skip in shuffle mode will select the previous
* track in the shuffled list, but if the current track was the first selected
* track, back skip will be disabled. Setting this prop to `true` will
* select new arbitrary tracks in the "past" if back skip is used beyond
* the buffered history.
*/
allowBackShuffle: PropTypes.bool,
/**
* The number of seconds before pressing back skip becomes "back to
* beginning of current track" rather than "go to the previous track"
*/
stayOnBackSkipThreshold: PropTypes.number.isRequired,
/**
* An array of [`mediaSessionAction`](#mediasessionaction) types to display
* in the end users's system UI, when applicable.
* It is *not* the same as the `controls` array.
*/
supportedMediaSessionActions: PropTypes.arrayOf(
PlayerPropTypes.mediaSessionAction.isRequired
).isRequired,
/** The number of seconds to seek back or forward when the Media Session API
* backseek/forwardseek buttons are activated in the end user's system UI
*/
mediaSessionSeekLengthInSeconds: PropTypes.number.isRequired,
/**
* A function called on component mount and component unmount with a reference
* to the underlying media element. Generally not recommended for use, but
* can be used as an escape hatch for features that aren't well-supported by
* Cassette (if you find yourself needing this, you may want to
* [open a new issue](https://github.com/benwiley4000/cassette/issues/new)
* to talk about adding first-class support for your use case).
*/
mediaElementRef: PropTypes.func,
/**
* If you're using `onStateSnapshot` to save snapshots of the media player
* state as a serializable object, you should pass that restored object here
* to preserve the user's state from the previous session
*/
initialStateSnapshot: PropTypes.object,
/**
* Called whenever a new state snapshot is generated. The internals of this
* snapshot are not documented and may change in a non-major release, so it's
* not safe to rely on them directly. Instead, the state snapshot should
* be serialized to JSON with `JSON.stringify` and restored later with
* `JSON.parse` to be passed as the `initialStateSnapshot` prop
*/
onStateSnapshot: PropTypes.func,
/**
* A function called whenever the active track is set or updated. Passed an
* object with the properties `track`, `trackIndex`, `previousTrack` and
* `previousTrackIndex` (these may be `null` or `undefined`)
*/
onActiveTrackUpdate: PropTypes.func,
/**
* A function called when the media element's `currentTime` attribute has
* changed. Passed an object with the properties `currentTime`, `track` and
* `trackIndex`
*/
onTimeUpdate: PropTypes.func,
/**
* A function called when playback of the current track has failed for some
* reason. Passed an object with the properties `event`,
* `track` and `trackIndex`
*/
onTrackPlaybackFailure: PropTypes.func,
/**
* A function which receives a [`track`](#track) object (if one is active)
* and returns a url pointing to a poster image representing the current
* track which should be used in a [`VideoDisplay`](#videodisplay) when the
* media content hasn't yet loaded
*/
getPosterImageForTrack: PropTypes.func.isRequired,
/**
* A function which receives a [`track`](#track) object (if one is active)
* and returns the value for the media element's `title` attribute, which
* may be used in iOS to display information about the current track in the
* system UI
*/
getMediaTitleAttributeForTrack: PropTypes.func.isRequired,
/**
* Either a renderable React node or a render prop function like the
* one passed into [`PlayerContextConsumer`](#playercontextconsumer)
*/
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired
};
PlayerContextProvider.defaultProps = {
autoplay: false,
autoplayDelayInSeconds: 0,
createMediaElement: () => document.createElement('video'),
gapLengthInSeconds: 0,
defaultVolume: 1,
defaultMuted: false,
defaultRepeatStrategy: 'playlist',
defaultShuffle: false,
defaultPlaybackRate: 1,
startingTrackIndex: 0,
loadFirstTrackOnPlaylistComplete: true,
seekMode: 'immediate',
maintainPlaybackRate: false,
allowBackShuffle: false,
stayOnBackSkipThreshold: 5,
supportedMediaSessionActions: ['play', 'pause', 'previoustrack', 'nexttrack'],
mediaSessionSeekLengthInSeconds: 10,
getPosterImageForTrack(track) {
return track && track.artwork ? track.artwork[0].src : '';
},
getMediaTitleAttributeForTrack: getDisplayText
};
export class PlayerContextGroupMember extends Component {
componentDidMount() {
this.props.groupContext.registerMediaElement(this.mediaElement);
}
componentWillUnmount() {
// Media element might not exist
// (see componentWillUnmount of PlayerContextProvider)
if (this.mediaElement) {
this.props.groupContext.unregisterMediaElement(this.mediaElement);
}
}
render() {
const { groupContext, props } = this.props;
const { mediaElementRef, ...rest } = props;
return (
<PlayerContextProvider
{...groupContext.groupProps}
{...rest}
mediaElementRef={ref => {
if (mediaElementRef) {
mediaElementRef(ref);
}
this.mediaElement = ref;
}}
/>
);
}
}
PlayerContextGroupMember.propTypes = {
groupContext: PropTypes.shape({
groupProps: PropTypes.object.isRequired,
registerMediaElement: PropTypes.func.isRequired,
unregisterMediaElement: PropTypes.func.isRequired
}).isRequired
};
function PlayerContextGroupConsumer(props) {
return (
<GroupContext.Consumer>
{groupContext => {
if (!groupContext) {
return createElement(PlayerContextProvider, props);
}
return createElement(PlayerContextGroupMember, {
groupContext,
props
});
}}
</GroupContext.Consumer>
);
}
export default PlayerContextGroupConsumer;