mediamonkeyserver
Version:
MediaMonkey Server
388 lines (318 loc) • 11.3 kB
JavaScript
import Server from 'server';
import { audioPlayer } from 'Fragments/AudioPlayer';
import { videoPlayer } from 'Fragments/VideoPlayer';
import { notifyPlaybackState, subscribePlaybackStateChange, notifyVideoShow, notifyVideoHide } from 'actions';
import Hls from 'hls.js';
import Debug from 'debug';
const debug = Debug('mms:playback');
const debugError = Debug('mms:error:playback');
// TODO: USe https://github.com/mediaelement/mediaelement for playback? Offers several renderers, etc.
// == Casting ==
var castingClientID = null;
export function getCastingClientID() {
return castingClientID;
}
export function setCastingClientID(newClientID) {
castingClientID = newClientID;
}
// == Server events ==
Server.addEventHandler('play_item', (mediaItem) => {
LocalPlayback.playItem(mediaItem);
});
Server.addEventHandler('play_pause', () => {
LocalPlayback.pause();
});
Server.addEventHandler('stop', () => {
LocalPlayback.stop();
});
Server.addEventHandler('seek', (newTime) => {
LocalPlayback.seek(newTime);
});
Server.addEventHandler('playback_state', (playerID) => {
if (playerID === castingClientID) {
// We are notified about playback state of the Player we Cast to => refresh our UI
Server.getPlayers().then(players => {
for (var player of players) {
if (player.id === playerID) {
state.castingPlaying = (player.status === 'playing');
state.castingActive = (player.status === 'playing' || player.status === 'paused');
state.mediaItem = player.mediaItem;
state.castingCurrentTime = player.currentTime;
state.castingLastUpdate = performance.now();
notifyPlaybackState();
}
}
});
}
});
// == Server notifications ==
var lastStateSent = null;
var lastMediaItem = null;
// TODO: Send notifications about playback position every ~10 seconds.
subscribePlaybackStateChange((data) => {
if (castingClientID)
return; // Don't notify server in case we're casting to another player
var newstate = data.state;
var mediaItem = data.mediaItem || state.mediaItem;
var send = (data.state !== lastStateSent) || // Different state => send notification
((lastMediaItem || {}).db_id !== (mediaItem || {}).db_id); // Diffent item playing => send notification
if (newstate === 'seeked') {
send = true; // We need to notify about the seek right away
newstate = 'playing';
}
if (send) {
lastStateSent = newstate;
lastMediaItem = Object.assign({}, mediaItem);
Server.updatePlaybackState(newstate, mediaItem, Playback.getCurrentTime());
}
});
// == Playback ==
var state = {
activeAVPlayer: null,
mediaItem: null,
getPlaying: function () { return castingClientID ? this.castingPlaying : this.activeAVPlayer && !this.activeAVPlayer.ended && !this.activeAVPlayer.paused; },
getActive: function () { return castingClientID ? this.castingActive : this.activeAVPlayer && !this.activeAVPlayer.ended; },
castingPlaying: false,
castingActive: false,
castingCurrentTime: null,
castingLastUpdate: null,
};
var hls;
var lastHlsRecoverMedia;
var lastHlsAudioCodecSwap;
function isHLSMimeType(mimeType) {
return ['application/x-mpegurl', 'application/vnd.apple.mpegurl', 'audio/mpegurl', 'video/mpegurl', 'audio/hls', 'video/hls'].indexOf(mimeType.toLowerCase()) > -1;
}
class LocalPlayback {
// HLS playback start
static startHls(video, url) {
if (Hls.isSupported()) {
hls = new Hls();
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
debug('HLS manifest parsed, going to play');
const playRes = video.play();
if (playRes) // The Promise isn't returned by all browsers (e.g. Edge, atm).
playRes.catch((error) => {
debugError(`HLS playback failed (${error})`);
if (error.name !== 'NotAllowedError') // Don't stop, so that user can restart playback on mobile devices, where play() fails beause it's execute async (not in click event).
LocalPlayback.stop();
});
});
hls.on(Hls.Events.ERROR, (msg, error) => {
debugError(`HLS playback failed (${error})`);
switch (error.type) {
case Hls.ErrorTypes.MEDIA_ERROR:
// HLS.js doesn't sometimes like the seeked transcoded streams, we try to recover from these problems here
if (!lastHlsRecoverMedia || Date.now() - lastHlsRecoverMedia > 3000) {
lastHlsRecoverMedia = Date.now();
debug('HLS recovering media error');
hls.recoverMediaError();
video.play();
break;
}
if (!lastHlsAudioCodecSwap || Date.now() - lastHlsAudioCodecSwap > 3000) {
lastHlsAudioCodecSwap = Date.now();
debug('HLS swapping audio codec');
hls.swapAudioCodec();
video.play();
}
break;
case Hls.ErrorTypes.NETWORK_ERROR:
hls.destroy();
hls.startLoad();
break;
default:
LocalPlayback.stop();
}
});
}
// hls.js is not supported on platforms that do not have Media Source Extensions (MSE) enabled.
// When the browser has built-in HLS support (check using `canPlayType`), we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video element throught the `src` property.
// This is using the built-in support of the plain video element, without using hls.js.
else if (video.canPlayType('application/vnd.apple.mpegurl') || video.canPlayType('application/x-mpegURL')) {
video.src = url;
video.addEventListener('canplay', function () {
const playRes = video.play();
if (playRes) // The Promise isn't returned by all browsers (e.g. Edge, atm).
playRes.catch((error) => {
debugError(`Native HLS playback failed (${error})`);
if (error.name !== 'NotAllowedError') // Don't stop, so that user can restart playback on mobile devices, where play() fails beause it's execute async (not in click event).
LocalPlayback.stop();
});
});
}
}
static playItem(mediaItem) {
debug(`playItem: ${mediaItem}`);
if (state.getActive())
LocalPlayback.stop(); // To close any existing player
// Prepare audio or video player
var video = mediaItem.mimeType.startsWith('video/');
if (video) {
state.activeAVPlayer = videoPlayer;
notifyVideoShow();
} else {
state.activeAVPlayer = audioPlayer;
}
state.mediaItem = mediaItem;
notifyPlaybackState('playing', state.mediaItem);
// Get info about the format
Server.getMediaStreamInfo(mediaItem).then(info => {
if (isHLSMimeType(info.stream.mimeType)) {
// HLS is handled by the hls.js library
LocalPlayback.startHls(state.activeAVPlayer, Server.getMediaStreamURL(mediaItem));
} else {
// Everything else is handled natively by HTML5 <audio>/<video> elements
state.activeAVPlayer.src = Server.getMediaStreamURL(mediaItem);//mediaItem.streamURL;
const playRes = state.activeAVPlayer.play();
if (playRes) // The Promise isn't returned by all browsers (e.g. Edge, atm).
playRes.catch((error) => {
debugError(`Playback failed (${error})`);
if (error.name !== 'NotAllowedError') // Don't stop, so that user can restart playback on mobile devices, where play() fails beause it's execute async (not in click event).
LocalPlayback.stop();
});
}
}).catch((err) => {
debugError(`Playback info not received (${err})`);
LocalPlayback.stop();
});
}
static pause() {
debug('Local playback pause');
if (!state.activeAVPlayer) {
// We are in 'stopped' state
if (state.mediaItem) {
// There was an item played, restart the playback
LocalPlayback.playItem(state.mediaItem);
}
return;
}
if (state.activeAVPlayer.paused) {
state.activeAVPlayer.play();
} else {
state.activeAVPlayer.pause();
}
notifyPlaybackState(state.getPlaying() ? 'playing' : 'paused', state.mediaItem);
}
static stop() {
debug('Local playback stop');
if (!state.activeAVPlayer)
return;
state.activeAVPlayer.pause();
if (state.activeAVPlayer === videoPlayer)
notifyVideoHide();
if (hls) {
hls.detachMedia();
hls.destroy();
hls = undefined;
} else {
// JH: Disabled in order to not cause an error (empty src). It probably isn't needed anyway?
// if (state.activeAVPlayer)
// state.activeAVPlayer.src = '';
}
state.activeAVPlayer = null;
notifyPlaybackState('stopped');
}
static seek(newTime) {
if (!state.activeAVPlayer)
return;
state.activeAVPlayer.currentTime = newTime;
}
}
// == Global Playback == (including Casting)
class Playback {
static playMediaItem(mediaItem) {
if (castingClientID) {
// Temporarily set the casting state, until we're notified from the target player about the actual state
state.mediaItem = mediaItem;
state.currentCurrentTime = 0;
state.castingLastUpdate = performance.now();
state.castingActive = true;
state.castingPlaying = true;
Server.playItem(castingClientID, mediaItem);
} else {
LocalPlayback.playItem(mediaItem);
}
}
static playPause() {
if (castingClientID) {
Server.playPause(castingClientID);
} else {
LocalPlayback.pause();
}
}
static stop() {
if (castingClientID) {
Server.stop(castingClientID);
} else {
LocalPlayback.stop();
}
}
static getPlaying() {
return state.getPlaying();
}
static getActive() {
return state.getActive();
}
static getCurrentMediaItem() {
return state.mediaItem;
}
static getDuration() {
var res = null;
if (castingClientID) {
if (state.mediaItem)
res = state.mediaItem.duration;
} else {
if (state.activeAVPlayer) {
res = state.activeAVPlayer.duration;
}
}
return res;
}
static getCurrentTime() {
var res = null;
if (castingClientID) {
res = state.castingCurrentTime + (state.castingPlaying ? performance.now() - state.castingLastUpdate : 0) / 1000;
} else {
if (state.activeAVPlayer) {
res = state.activeAVPlayer.currentTime;
}
}
return res;
}
static setCurrentTime(newTime) {
if (castingClientID) {
Server.seek(castingClientID, newTime);
} else {
if (state.activeAVPlayer) {
state.activeAVPlayer.currentTime = newTime;
}
}
}
}
// == HTML Playback events ==
export function addPlayerListeners(player) {
player.addEventListener('paused', () => notifyPlaybackState('paused'), true);
player.addEventListener('play', () => notifyPlaybackState('playing'), true);
player.addEventListener('seeked', () => notifyPlaybackState('seeked'), true);
player.addEventListener('playing', () => {
// Update duration from the player (in case is isn't known yet, or incorrectly).
if (player.duration) {
const mediaItem = Playback.getCurrentMediaItem();
if (mediaItem)
mediaItem.duration = player.duration;
}
notifyPlaybackState('playing');
}, true);
player.addEventListener('ended', () => {
notifyVideoHide();
notifyPlaybackState('stopped');
}, true);
player.addEventListener('error', () => {
notifyVideoHide();
notifyPlaybackState('stopped');
}, true);
}
export default Playback;