playercast
Version:
Cast to media player and control playback remotely
608 lines (495 loc) • 12.5 kB
JavaScript
const fs = require('fs');
const path = require('path');
const PlayerController = require('media-player-controller');
const { WSClient } = require('./websocket');
const debug = require('debug')('playercast:player');
const cecClient = require('./cec');
const helper = require('./helper');
const keymap = require('./keymap');
const mdns = require('./mdns');
const terminal = require('./terminal');
var websocket;
var controller;
var cec;
var opts;
var isControlled = false;
var isLive = false;
var changingTrack = false;
var status;
module.exports =
{
init: function(config)
{
opts = config;
controller = new PlayerController(opts);
if(!controller._getSupportedPlayers().includes(opts.app))
{
terminal.writeError(`Unsupported media player: ${opts.app}`);
return this.closePlayercast();
}
const finishInit = () =>
{
terminal.enableKeyInput(this);
if(opts.connectWs)
this.createWs();
else
this.createMdns();
}
const onCecInit = (client) =>
{
if(client)
{
if(!terminal.quiet)
process.stdout.write(' OK');
cec = client;
if(opts['cec-alt-remote'])
{
cec.events.on('keypress', (keyName) =>
keymap.cecRemoteAlt(keyName, this)
);
}
else
{
cec.events.on('keypress', (keyName) =>
keymap.cecRemote(keyName, this)
);
}
}
else
{
terminal.writeLine('HDMI-CEC is not supported');
}
if(terminal.quiet)
finishInit();
else
setTimeout(() => finishInit(), 2000);
}
if(
opts['disable-cec']
|| !fs.existsSync('/usr/bin/cec-client')
)
return finishInit();
terminal.writeLine('Checking HDMI-CEC support...');
cecClient().then(client => onCecInit(client));
},
createWs: function(url)
{
if(!opts || websocket) return;
websocket = new WSClient(url || opts.websocket);
websocket.on('connect', onSenderConnect);
websocket.on('disconnect', onSenderDisconnect);
websocket.on('playercast', onMediaCast);
websocket.on('invalid', onPlayerInvalid);
websocket.on('remote-signal', onRemoteSignal);
terminal.writeLine(`Connecting to ${websocket.io.uri}...`);
},
createMdns: function()
{
mdns.listen(opts.name, opts.port);
terminal.writeLine(`${opts.name} waiting for connection...`);
},
closePlayercast: function(err)
{
const shutdown = () =>
{
if(isControlled && websocket)
websocket.emitEvent('show-remote', false);
if(cec) cec.events.closeClient();
if(err)
{
console.error(err);
return process.exit(1);
}
terminal.writeLine('Playercast closed');
process.stdout.write('\n');
process.exit(0);
}
terminal.writeLine('Playercast closing...');
if(controller && controller.process)
controller.quit(() => shutdown());
else
shutdown();
},
action: function(fnc, value)
{
if(!controller || !isControlled) return;
const onActionError = (err) =>
{
if(err) terminal.writeError(err.message);
}
if(typeof value !== 'undefined')
controller[fnc](value, onActionError);
else
controller[fnc](onActionError);
},
setVolume: function(volume)
{
if(!controller || !isControlled) return;
if(volume > 1) volume = 1;
else if(volume < 0) volume = 0;
this.action('setVolume', volume);
},
increaseVolume: function(value)
{
if(!controller || !isControlled) return;
var volume = status.volume + value;
this.setVolume(volume);
},
decreaseVolume: function(value)
{
if(!controller || !isControlled) return;
var volume = status.volume - value;
this.setVolume(volume);
},
seekPercent: function(value)
{
if(!controller || !isControlled) return;
var position = value * status.media.duration;
this.action('seek', position);
},
seekBackward: function(seekTime)
{
if(!controller || !isControlled) return;
var position = status.currentTime - seekTime;
if(position < 0) position = 0;
this.action('seek', position);
},
seekForward: function(seekTime)
{
if(!controller || !isControlled) return;
var position = status.currentTime + seekTime;
if(position >= status.media.duration)
position = status.media.duration - 1;
this.action('seek', position);
},
previousTrack: function()
{
if(
!controller
|| !isControlled
|| changingTrack
|| status.playlist.index === 1
)
return;
changingTrack = true;
terminal.writeLine('Loading new media...');
websocket.emitEvent('playercast-ctl', 'previous-track');
},
nextTrack: function()
{
if(
!controller
|| !isControlled
|| changingTrack
|| status.playlist.index >= status.playlist.length
)
return;
changingTrack = true;
terminal.writeLine('Loading new media...');
websocket.emitEvent('playercast-ctl', 'next-track');
},
changeHost: function(data)
{
if(websocket)
return websocket._changeHost(data);
var url = helper.convToUrl(data);
if(url) this.createWs(url);
},
getServerAddress: function()
{
if(!controller || !isControlled || !websocket)
return null;
return websocket.io.uri;
}
}
function onMediaCast(msg)
{
isControlled = (opts.name === msg.name);
if(!isControlled)
return;
if(!controller)
return terminal.writeError('Controller not initialized!');
if(!changingTrack)
terminal.writeLine('Received media cast');
newStatus(msg);
isLive = (msg.streamType && msg.streamType.startsWith('VIDEO_'));
var isUrl = helper.getIsUrl(msg.filePath);
controller.opts.media = (isUrl) ? msg.filePath : `${websocket.io.uri}/cast`;
opts.subtitles = `${websocket.io.uri}/subs`;
opts.cover = `${websocket.io.uri}/cover`;
const launchPlayer = async(isRestart) =>
{
if(cec && !isRestart)
{
terminal.writeLine('Sending HDMI-CEC signals...');
await cec.ctl.dev0.turnOn();
cec.ctl.setActive().then(() =>
{
if(opts['cec-force-switch'] && cec.hdmi)
cec.ctl.dev0.changeSource(cec.hdmi);
});
}
controller.opts.args = getPlayerArgs(msg, isUrl);
if(debug.enabled)
{
debug(`Media source: ${controller.opts.media}`);
debug(`Player args: ${JSON.stringify(controller.opts.args)}`);
}
terminal.writeLine(`Starting ${opts.app}...`);
controller.launch(err =>
{
if(err)
{
if(isControlled && websocket)
websocket.emitEvent('playercast-error', err.message);
return terminal.writeError(err.message);
}
changingTrack = false;
setPlayerProperties(msg);
handlePlayerLaunch();
});
}
if(!controller.process)
{
debug('No open media player process found');
return launchPlayer(false);
}
setPlayerProperties(msg);
debug(`Loading new media: ${controller.opts.media}`);
controller.load(err =>
{
if(!err) return afterMediaLoad();
debug('Error on media load');
debug(err);
/* Retry loading on fail */
debug('Retrying media load...');
controller.load(err =>
{
if(!err) return afterMediaLoad();
debug('Error on media load retry');
debug(err);
terminal.writeLine('Restarting media player...');
controller.once('app-exit', () => launchPlayer(true));
controller.quit(err =>
{
if(err) terminal.writeError(err.message);
});
});
});
}
function afterMediaLoad()
{
terminal.writeLine('File loaded');
changingTrack = false;
websocket.emitEvent('show-remote', true);
writeStatus();
websocket.emitEvent('status-update', status);
controller.play();
}
function onSenderConnect()
{
terminal.writeLine(`Connected to ${websocket.io.uri}`);
if(opts.name)
websocket.emitEvent('playercast-connect', opts.name);
}
function onSenderDisconnect()
{
if(controller && controller.process)
controller.quit();
isControlled = false;
debug('Media server disconnected');
if(opts.connectWs)
return terminal.writeLine(`Connecting to ${websocket.io.uri}...`);
websocket.io.reconnection(false);
terminal.writeLine(`${opts.name} waiting for connection...`);
}
function onPlayerInvalid(msg)
{
switch(msg)
{
case 'name':
terminal.writeError(
`Playercast name "${opts.name}" is already used on another device!`
);
process.exit(1);
break;
case false:
terminal.writeLine(`${opts.name} waiting for media cast...`);
break;
default:
break;
}
}
function onRemoteSignal(msg)
{
keymap.extRemote(msg, module.exports);
}
function handlePlayerLaunch()
{
terminal.writeLine('Player started');
controller.on('playback', updateStatus);
controller.on('playback-started', onPlaybackStarted);
process.stdout.on('resize', terminal.restoreText);
websocket.emitEvent('show-remote', true);
controller.once('app-exit', (code) =>
{
isControlled = false;
controller.removeListener('playback', updateStatus);
controller.removeListener('playback-started', onPlaybackStarted);
process.stdout.removeListener('resize', terminal.restoreText);
websocket.emitEvent('show-remote', false);
debug('Player exited');
if(cec)
{
cec.ctl.setInactive().then(() =>
{
var hdmiPort = opts['cec-end-hdmi'];
if(!isNaN(hdmiPort))
cec.ctl.dev0.changeSource(hdmiPort);
});
}
if(code)
terminal.writeError(`Player exited with status code: ${code}`);
terminal.writeLine(`${opts.name} waiting for media cast...`);
});
}
function updateStatus(event)
{
switch(event.name)
{
case 'volume':
if(event.value > 1) event.value = 1;
status.volume = event.value;
writeStatus();
break;
case 'time-pos':
status.currentTime = event.value;
writeStatus();
break;
case 'duration':
status.media.duration = event.value;
writeStatus();
break;
case 'pause':
status.playerState = (event.value === true) ? 'PAUSED' : 'PLAYING';
writeStatus();
break;
case 'eof-reached':
if(event.value === true)
return websocket.emitEvent('playercast-ctl', 'track-ended');
break;
default:
terminal.writeError(`Unhandled event: ${event}`);
break;
}
if(!changingTrack)
websocket.emitEvent('status-update', status);
}
function writeStatus()
{
if(!changingTrack)
terminal.writePlayerStatus(status, isLive);
}
function newStatus(msg)
{
status = {
title: getMediaTitle(msg),
playerState: (status && status.playerState) || 'PAUSED',
currentTime: 0,
media: { duration: 0 },
volume: (status && status.volume) || 0,
streamType: msg.streamType || 'VIDEO',
subtitles: (msg.subsPath) ? true : false,
playlist : {
index: (msg.playlist && msg.playlist.index) ? msg.playlist.index : 1,
length: (msg.playlist && msg.playlist.length) ? msg.playlist.length : 1
}
};
if(!debug.enabled) return;
debug('Generated new status:');
debug(status);
}
function getPlayerArgs(selection, isUrl)
{
debug(`Launching ${opts.app} with properties for ${selection.streamType} cast...`);
var args = [];
switch(opts.app)
{
case 'mpv':
args = [
'--fullscreen', '--volume-max=100', '--cache-secs=15',
'--keep-open=yes', '--image-display-duration=inf', '--vid=1',
`--external-file=${opts.cover}`, `--sub-file=${opts.subtitles}`
];
if(!isUrl)
args.unshift('--no-ytdl');
break;
case 'vlc':
case 'cvlc':
case 'vlc-rpc':
args = ['--fullscreen'];
if(process.platform === 'win32')
args.push('--video-on-top');
break;
default:
break;
}
return args;
}
function setPlayerProperties(selection)
{
debug(`Setting ${opts.app} properties for ${selection.streamType} cast...`);
switch(opts.app)
{
case 'mpv':
controller.command([
'set_property', 'force-media-title', status.title
]);
if(selection.streamType === 'PICTURE')
controller.command(['set_property', 'osc', 'no']);
else
controller.command(['set_property', 'osc', 'yes']);
if(selection.addon === 'DESKTOP')
controller.command(['set_property', 'cache', 'no']);
else
controller.command(['set_property', 'cache', 'auto']);
break;
default:
break;
}
controller.setRepeat(false);
}
function onPlaybackStarted(isLoaded)
{
if(!isLoaded) return;
switch(opts.app)
{
case 'vlc':
case 'cvlc':
if(
status.streamType.startsWith('VIDEO')
&& status.subtitles
) {
helper.downloadSubs(opts.subtitles, (err, subsPath) =>
{
if(err) return;
debug('Adding subtitles to VLC...');
controller.addSubs(subsPath, (err) =>
{
if(err) return debug(err);
debug('Subtitles added');
});
});
}
break;
default:
break;
}
}
function getMediaTitle(selection)
{
if(selection.mediaData && selection.mediaData.title)
return selection.mediaData.title;
var title = path.parse(selection.filePath).name;
return (title) ? title : 'Playercast';
}