UNPKG

mpd-avr-client

Version:
1,569 lines (1,347 loc) 55.3 kB
const { readdir, unlink, writeFile, readFile, stat } = require('fs'); const { join, extname, basename } = require('path'); const { connect } = require('net'); const { request } = require('http'); const { of, from, concat, defer, forkJoin, throwError, Observable, Subject, } = require('rxjs'); const { take, takeLast, share, shareReplay, switchMap, concatMap, withLatestFrom, scan, filter, map, catchError, delay, } = require('rxjs/operators'); const { mpdConfPath, mpdHost, mpdPortFallback, avrRequestDisplayNameRegExp, avrTurnOnRegExp, avrStandByRegExp, avrIsOnRegExp, avrIsStandByRegExp, arrowUpKeyupRegExp, arrowDownKeyupRegExp, enterKeyupRegExp, returnKeyupRegExp, playKeyupRegExp, pauseKeyupRegExp, stopKeyupRegExp, nextKeyupRegExp, previousKeyupRegExp, redFunctionKeyupRegExp, greenFunctionKeyupRegExp, blueFunctionKeyupRegExp, volumeStatusRegExp, mpdPortSettingRegExp, playlistFoldersBasePathSettingRegExp, playlistFilesBasePathSettingRegExp, playOrPauseRegExp, playRegExp, stopRegExp, pauseRegExp, tvLaunchProfileTypeTvTypeMap, } = require('./const'); const MpClient = function (_mpClientProcess) { return ((mpClientProcess) => { const mpClientEvent$ = /** @type Observable<MpClientEvent> */ new Observable((subscriber) => { const source = 'mpClient'; const onData = () => new MpService() .getStatus() .then((data) => subscriber.next({ source, data, }) ) .catch((error) => subscriber.next({ source, data: error, }) ); const onClose = (exitCode) => { console.log(`mpc exited with code ${exitCode}\n`); if (exitCode === 0) { return subscriber.complete(); } subscriber.error(exitCode); }; const onUnsubscribe = () => {}; // emit next event mpClientProcess.stdout.on('data', onData); mpClientProcess.stderr.on('data', onData); // emit complete and error event mpClientProcess.on('close', onClose); return onUnsubscribe; }).pipe(share()); const publisher = () => mpClientEvent$; return { publisher }; })(_mpClientProcess); }; const CecClient = function (_cecClientProcess) { return ((cecClientProcess) => { const cecClientEvent$ = /** @type Observable<CecClientEvent>*/ new Observable((subscriber) => { const source = 'cecClient'; const onData = (data) => subscriber.next({ source, data, }); const onClose = (exitCode) => { console.log(`cec-client exited with code ${exitCode}\n`); if (exitCode === 0) { return subscriber.complete(); } subscriber.error(exitCode); }; const onUnsubscribe = () => {}; // emit next event cecClientProcess.stdout.on('data', onData); cecClientProcess.stderr.on('data', onData); // emit complete and error event cecClientProcess.on('close', onClose); return onUnsubscribe; }).pipe(share()); const publisher = () => cecClientEvent$; return { publisher }; })(_cecClientProcess); }; const HttpClient = function () { return (() => { /** * Make an HTTP POST call * @param {string} hostname The hostname * @param {string} path The path * @param {HttpObject} payload The POST request payload * @param {HttpObject} [headerOverrides] Optionally override headers * @returns {Promise<HttpObject>} A promise of the API response */ const post = (hostname, path, payload, headerOverrides = {}) => new Promise((resolve, reject) => { let /** @type OutgoingHttpHeaders */ outgoingHttpHeaders = {}; const payloadString = JSON.stringify(payload); outgoingHttpHeaders['Content-Type'] = 'application/json'; outgoingHttpHeaders['Content-Length'] = Buffer.byteLength(payloadString); let /** @type RequestOptions */ requestOptions = {}; requestOptions.hostname = hostname; requestOptions.port = 80; requestOptions.path = path; requestOptions.method = 'POST'; requestOptions.headers = { ...outgoingHttpHeaders, ...headerOverrides }; const chunks = []; const clientRequest = request(requestOptions, (incomingMessage) => { incomingMessage.on('data', (data) => chunks.push(data)); // upon response end incomingMessage.on('end', () => { try { resolve(JSON.parse(chunks.join(''))); } catch (error) { reject(error); } }); }); clientRequest.on('error', reject); clientRequest.write(payloadString); // upon request end clientRequest.end(); }); return { post }; })(); }; const AvrService = function (_appConfig, _cecClientProcess) { return ((appConfig, cecClientProcess) => { const { osdMaxLength, audioVolumePreset } = /** @type AppConfig */ appConfig; const convertOsdToHex = (message) => [...message.padEnd(osdMaxLength, ' ')] .slice(0, osdMaxLength) .map((char) => char.charCodeAt(0).toString(16).padStart(2, '0')) .join(':'); /** * Run a CEC command * @param {string} command A CEC command * @returns {void} No output */ const runCommand = (command) => void cecClientProcess.stdin.write(command); const requestPowerStatus = () => runCommand('pow 5'); /** * Display a message on the AVR OSD * @param {string} message A message * @returns {void} No output */ const updateOsd = (message) => runCommand(`tx 15:47:${convertOsdToHex(message)}`); const increaseGainByHalfDecibel = () => runCommand('volup'); const decreaseGainByHalfDecibel = () => runCommand('voldown'); const requestAudioVolume = () => runCommand('tx 15:71'); const setActiveSource = () => runCommand('as'); /** * Decode the AVR power status from a CEC Client Event * @param {CecClientEvent} cecClientEvent A CEC Client Event * @returns {AvrPowerStatus} The AVR power status */ const decodeAvrPowerStatus = (cecClientEvent) => { const { data: cecTransmission } = cecClientEvent; if (avrIsOnRegExp.test(cecTransmission)) { return [true]; } if (avrIsStandByRegExp.test(cecTransmission)) { return [false]; } if (avrTurnOnRegExp.test(cecTransmission)) { return [true]; } if (avrStandByRegExp.test(cecTransmission)) { return [false]; } return [undefined]; }; /** * Determine whether an AVR Power Status is valid * @param {AvrPowerStatus} avrPowerStatus An AVR Power Status * @returns {boolean} the validity of an AVR Power Status */ const isAvrPowerStatusValid = ([isAudioDeviceOn]) => isAudioDeviceOn !== undefined; /** * Decode the AVR audio volume status from CEC Client Event * @param {CecClientEvent} cecClientEvent A CEC Client Event * @returns {AvrVolumeStatus} The audio mute and volume in hex */ const decodeAvrVolumeStatus = (cecClientEvent) => { const { data: cecTransmission } = cecClientEvent; if (!volumeStatusRegExp.test(cecTransmission)) { return [undefined]; } const [_, __, ___, volumeInHex] = cecTransmission .toString() .match(volumeStatusRegExp); return [volumeInHex.replace(/:/g, '')]; }; /** * Determine whether a cec-client event is caused by the AVR requesting the host's name * @param {CecClientEvent} cecClientEvent A cec-client event * @returns {boolean} whether a cec-client event is caused by AVR requesting the host's name */ const isAvrRequestDisplayName = (cecClientEvent) => { if (!cecClientEvent) { return false; } const { data: cecTransmission } = cecClientEvent; return avrRequestDisplayNameRegExp.test(cecTransmission); }; /** * Adjust the AVR audio volume * @param {AvrVolumeStatus} avrVolumeStatus The current AVR volume status * @param {number} [audioVolumeOverride] Optionally override the audio volume preset * @returns {Observable<void>} An observable of no output */ const adjustAudioVolume = ( [avrMuteAndVolumeInHex], audioVolumeOverride = audioVolumePreset ) => { let { length } = avrMuteAndVolumeInHex; const muteStatusMaskOverflow = 2 ** ((length / 2) * 8); // 2 ^ (number of bits) const muteStatusMask = muteStatusMaskOverflow / 2; // the left-most bit is the mute status const volumeStatusMask = muteStatusMask - 1; // invert the mask to get a mask for the remainder bits const audioVolumeInDecimal = parseInt(avrMuteAndVolumeInHex, 16) & volumeStatusMask; const vector = Math.floor(audioVolumeOverride - audioVolumeInDecimal); const sign = Math.sign(vector); length = Math.abs(vector) * 2; // note, gain can be adjusted 0.5 Decibel at a time /** * @desc Unfortunately, there is no way to set volume to a particular value using CEC, as a result, audio volume is adjusted one unit at a time */ return from(Array.from({ length }).map(() => Math.max(sign, 0))).pipe( concatMap( (increaseVolume) => new Promise((resolve) => { increaseVolume ? increaseGainByHalfDecibel() : decreaseGainByHalfDecibel(); /** * @desc Unfortunately, there is a limitation to how frequently commands can be transmitted to the AVR, some magic number is used here */ setTimeout(resolve, 500); }) ), takeLast(1) ); }; /** * Determine whether an AVR Volume Status is valid * @param {AvrVolumeStatus} avrPowerStatus An AVR Volume Status * @returns {boolean} the validity of an AVR Volume Status */ const isAvrVolumeStatsValid = ([avrMuteAndVolumeInHex]) => avrMuteAndVolumeInHex !== undefined; return { runCommand, requestPowerStatus, updateOsd, increaseGainByHalfDecibel, decreaseGainByHalfDecibel, requestAudioVolume, setActiveSource, decodeAvrPowerStatus, isAvrPowerStatusValid, decodeAvrVolumeStatus, isAvrVolumeStatsValid, isAvrRequestDisplayName, adjustAudioVolume, }; })(_appConfig, _cecClientProcess); }; const PlaylistService = function () { return (() => { const ls = (path) => new Promise((resolve, reject) => readdir(path, { withFileTypes: true }, (err, dirents) => err ? reject(err) : resolve(dirents) ) ); const rm = (path) => new Promise((resolve) => unlink(path, () => resolve())); const redirect = (data, path) => new Promise((resolve, reject) => writeFile(path, data, { encoding: 'utf8' }, (err) => err ? reject(err) : resolve() ) ); const getFileName = (directory, dirent) => new Promise((resolve) => { if (dirent.isDirectory()) { return resolve(); } const { name } = dirent; if (dirent.isFile()) { return resolve(name); } const filePath = join(directory, name); if (dirent.isSymbolicLink()) { stat(filePath, (err, stats) => { if (err) { return resolve(); } resolve(stats.isFile() ? name : undefined); }); return; } resolve(); }); /** * Index song files within a playlist folder * @param {string} playlistFolderPath The path to a folder containing song files * @returns {Promise<string[]>} a list of song file paths relative to music_directory */ const indexPlaylistFolder = (playlistFolderPath) => { const playlistFolderName = basename(playlistFolderPath); return defer(() => ls(playlistFolderPath)) .pipe( switchMap(from), // emit the songFiles sequentially concatMap((dirent) => getFileName(playlistFolderPath, dirent)), // inspect the songFiles sequentially scan( (acc, fileName) => fileName ? [...acc, join(playlistFolderName, fileName)] : acc, [] ), takeLast(1) ) .toPromise(); }; /** * Get the playlist_directory value configured for MPD * @returns {Promise<string>} A promise of the playlist_directory value */ const getPlaylistFilesBasePath = () => new Promise((resolve, reject) => { readFile(mpdConfPath, (err, data) => { if (err) { return reject(err); } const dataLines = data.toString().split('\n'); for (const dataLine of dataLines) { if (playlistFilesBasePathSettingRegExp.test(dataLine)) { const [_, path] = dataLine.match( playlistFilesBasePathSettingRegExp ); return resolve(path); } } resolve(null); }); }); /** * Get the music_directory value configured for MPD * @returns {Promise<string>} A promise of the music_directory value */ const getPlaylistFoldersBasePath = () => new Promise((resolve, reject) => { readFile(mpdConfPath, (err, data) => { if (err) { return reject(err); } const dataLines = data.toString().split('\n'); for (const dataLine of dataLines) { if (playlistFoldersBasePathSettingRegExp.test(dataLine)) { const [_, path] = dataLine.match( playlistFoldersBasePathSettingRegExp ); return resolve(path); } } resolve(null); }); }); /** * Update the playlist_directory to mirror folders found in music_directory * @returns {Promise<string[]>} The list of playlists */ const updatePlaylists = () => { const playlistFilesBasePath$ = defer(() => getPlaylistFilesBasePath() ).pipe(share()); const playlistFilePaths$ = playlistFilesBasePath$.pipe( switchMap((playlistFilesBasePath) => ls(playlistFilesBasePath).then((dirents) => dirents .filter( (dirent) => dirent.isFile() && /\.m3u$/i.test(extname(dirent.name)) ) .map(({ name }) => join(playlistFilesBasePath, name)) ) ), take(1) ); const removePlaylistFiles$ = playlistFilePaths$.pipe( switchMap(from), // emit the playlistFilePaths sequentially concatMap(rm), // remove the playlistFilePaths sequentially takeLast(1) ); const playlistFoldersBasePath$ = defer(() => getPlaylistFoldersBasePath() ).pipe(share()); const playlistFolders$ = playlistFoldersBasePath$.pipe( switchMap((playlistFoldersBasePath) => ls(playlistFoldersBasePath).then((dirents) => dirents.filter((dirent) => dirent.isDirectory()) ) ), take(1) ); return concat(removePlaylistFiles$, playlistFolders$) .pipe( filter(Boolean), switchMap(from), // emit the playlistFolderPaths sequentially withLatestFrom(playlistFoldersBasePath$, playlistFilesBasePath$), concatMap( ([dirent, playlistFoldersBasePath, playlistFilesBasePath]) => { const { name } = dirent; const playlistFolderPath = join(playlistFoldersBasePath, name); const playlistFilePath = join( playlistFilesBasePath, `${name}.m3u` ); return indexPlaylistFolder(playlistFolderPath) .then((relativePaths) => redirect(relativePaths.join('\n'), playlistFilePath) ) .then(() => name); } ), // index the playlistFolderPaths sequentially scan((acc, playlistFileName) => [...acc, playlistFileName], []), takeLast(1) ) .toPromise(); }; return { updatePlaylists }; })(); }; const MpService = function () { return (() => { const mpdPort$ = new Observable((subscriber) => { readFile(mpdConfPath, (err, data) => { if (err) { return subscriber.error(err); } const dataLines = data.toString().split('\n'); for (const dataLine of dataLines) { if (mpdPortSettingRegExp.test(dataLine)) { const [_, port] = dataLine.match(mpdPortSettingRegExp); subscriber.next(port); subscriber.complete(); return; } } subscriber.next(mpdPortFallback); subscriber.complete(); }); return () => {}; }).pipe(shareReplay()); /** * Establish a nc connection with MPD and send commands * @param {string} host The address to the MPD * @param {string} port The port MPD runs at * @param {string[]} commands The commands to be sent to MPD * @returns {Promise<MpStatus>} A promise of the music player status */ const handShakeAndSendCommands = (host, port, commands) => new Promise((resolve, reject) => { const socket = /** @type Socket */ connect({ host, port }); let connectReturnCode; socket.on('data', (data) => { let dataLines = data.toString().trim().split('\n'); const [returnCode] = dataLines.splice(-1, 1); if (!/^OK\s*/i.test(returnCode)) { socket.end(); reject(returnCode); return; } // if the success response corresponds to the command execution, // then conclude the connection if (connectReturnCode) { socket.end(); dataLines = dataLines.map((dataLine) => { const [key, val] = dataLine.split(':'); return ['"', key.trim(), '":"', val.trim(), '"'].join(''); }); dataLines = ['{', dataLines.join(','), '}']; try { resolve(JSON.parse(dataLines.join(''))); } catch (ex) { reject(ex); } return; } connectReturnCode = returnCode; const commandSet = new Set([ 'command_list_begin', ...commands, 'status', 'command_list_end', '', ]); // if the success response corresponds to the connection initialization, // then send the commands socket.write([...commandSet].join('\n')); }); socket.on('error', (err) => { socket.end(); reject(err); }); }); /** * Send command to MPD through nc * @param {string|string[]} commands The commands * @returns {Promise<MpStatus>} A promise of the music player status */ const sendMpCommand = (...commands) => mpdPort$ .pipe( switchMap((mpdPort) => handShakeAndSendCommands(mpdHost, mpdPort, commands) ), take(1) ) .toPromise(); const getStatus = () => sendMpCommand('status'); const update = () => sendMpCommand('update'); const pause = () => sendMpCommand('pause 1'); /** * Play a particular playlist * @param {string} playlist The playlist * @returns {Promise<MpStatus>} A promise of sending the commands */ const playPlaylist = (playlist) => sendMpCommand('clear', `load "${playlist}"`, 'play'); const resume = () => sendMpCommand('pause 0'); const stop = () => sendMpCommand('stop'); // note, 'stop' wipes the playback progress within the playlist const nextSong = () => sendMpCommand('next'); const previousSong = () => sendMpCommand('previous'); /** * Set the repeat mode * @param {'0'|'1'} repeat Whether to turn on repeat * @returns {Promise<MpStatus>} A promise of sending the command */ const setRepeat = (repeat) => sendMpCommand(`repeat ${repeat}`); /** * Set the random mode * @param {'0'|'1'} random Whether to turn on random * @returns {Promise<MpStatus>} A promise of sending the command */ const setRandom = (random) => sendMpCommand(`random ${random}`); return { getStatus, update, pause, playPlaylist, resume, stop, nextSong, previousSong, setRepeat, setRandom, }; })(); }; const AvrPowerStatusReducer = function (_appConfig, _cecClientProcess) { return ((appConfig, cecClientProcess) => { const avrService = new AvrService(appConfig, cecClientProcess); /** * Get the initial state * @returns {AvrPowerStatus} The initial state */ const getInitState = () => []; /** * @desc The aim of this reducer is to retrieve the AVR power status during the initialization of the application * The following objectives are listed chronologically, and they are executed sequentially to avoid timing issues * - wait for the AVR to request for identification from CEC Client * - request the power status from the AVR */ return [ (acc, _cecClientEvent) => { const { length } = /** @type AvrPowerStatus */ acc; const cecClientEvent = /** @type CecClientEvent */ _cecClientEvent; // if the request for AVR power status has been made if (length) { // then deduce the AVR power statue from transmissions received return avrService.decodeAvrPowerStatus(cecClientEvent); } // if the request for AVR power status has not been made, and // if the AVR is reaching out to identify the host if (avrService.isAvrRequestDisplayName(cecClientEvent)) { // then make a request for AVR power status /** * @desc when CEC Client jumps on the HDMI bus, the AVR reaches out to CEC Client and asks for identification */ avrService.requestPowerStatus(); return [undefined]; } // if the request for AVR power status has not been made, and // if the AVR is not reaching out to identify the host, // then no-op return acc; }, getInitState(), ]; })(_appConfig, _cecClientProcess); }; const AvrWakeUpVolumeStatusReducer = function (_appConfig, _cecClientProcess) { return ((appConfig, cecClientProcess) => { const avrService = new AvrService(appConfig, cecClientProcess); /** * Get the initial state * @returns {AvrVolumeStatus} The initial state */ const getInitState = () => []; /** * @desc The aim of this reducer is to retrieve the AVR audio volume when the AVR wakes up * The following objectives are listed chronologically, and they are executed sequentially to avoid timing issues * - wait for the AVR to wake up * - request the volume status from the AVR */ return [ (acc, cecClientEvent) => { const [isAudioDeviceOn] = avrService.decodeAvrPowerStatus( /** @type {CecClientEvent} */ cecClientEvent ); // if the CEC transmission is regarding audio turning off (i.e. the AVR goes to stand-by mode) if (isAudioDeviceOn === false) { // then reset the reducer return getInitState(); } // if the CEC transmission is not regarding audio turning off, and // if the CEC transmission is regarding audio turning on if (isAudioDeviceOn) { // then request audio volume status /** * @desc Unfortunately, there is a limitation to how frequently commands can be transmitted to the AVR, some magic number is used here */ setTimeout(() => avrService.requestAudioVolume(), 500); return [undefined]; } const { length } = /** @type AvrVolumeStatus */ acc; // if the CEC transmission is not regarding audio turning off, and // if the CEC transmission is not regarding audio turning on, and // if a request for the audio volume status was made if (length) { // then decode the CEC transmission return avrService.decodeAvrVolumeStatus(cecClientEvent); } // if the CEC transmission is not regarding audio turning off, and // if the CEC transmission is not regarding audio turning on, and // if a request for the audio volume status was not made, // then no-op return acc; }, getInitState(), ]; })(_appConfig, _cecClientProcess); }; const AvrAudioSourceSwitchReducer = function (_appConfig, _cecClientProcess) { return ((appConfig, cecClientProcess) => { const { handOverAudioToTvCecCommand, audioVolumePresetForTv } = /** @type AppConfig */ appConfig; const avrService = new AvrService(appConfig, cecClientProcess); const mpService = new MpService(); const tvLaunchService = new TvLaunchService(appConfig); /** * Get the initial state * @returns {[AvrVolumeStatus, MpStatusStateTransition]} The initial state */ const getInitState = () => [[], [undefined, undefined]]; /** * Initiate the audio source switch sequence by requesting the ARR audio volume * @param {AppState} appState The current App State * @param {CecClientEvent} cecClientEvent The received CEC Client Event * @returns {[AvrVolumeStatus, MpStatusStateTransition]} The AVR Volume Status and the MP Status State Transition */ const initiateAudioSourceSwitch = ( { isAudioDeviceOn }, { data: cecTransmission } ) => { // if the AVR is in standby mode if (!isAudioDeviceOn) { // then reset the reducer return getInitState(); } // if the AVR is not in standby mode, and // if the CEC transmission is regarding audio source switching if (blueFunctionKeyupRegExp.test(cecTransmission)) { // then ask AVR for audio volume (to initiate the audio source switching process) avrService.requestAudioVolume(); return [[undefined], [undefined, undefined]]; } // if the AVR is not in standby mode, and // if the CEC transmission is not regarding audio source switching, // then reset the reducer return getInitState(); }; /** * Switch audio source * @param {AvrVolumeStatus} avrVolumeStatus The current AVR Audio Status * @returns {void} No output */ const switchingAudioSource = (avrVolumeStatus) => { avrService.runCommand(handOverAudioToTvCecCommand); if (tvLaunchService.isEnabled()) { tvLaunchService.wakeAndLaunchApp().subscribe(); } if (audioVolumePresetForTv !== undefined) { of(audioVolumePresetForTv) .pipe( /** * @desc Unfortunately, there is a limitation to how frequently commands can be transmitted to the AVR, some magic number is used here */ delay(500), switchMap(() => avrService.adjustAudioVolume( avrVolumeStatus, audioVolumePresetForTv ) ), take(1) ) .subscribe(); } }; /** * @desc The aim of this reducer is to switch audio source to a Smart TV which is connected to a non-HDMI audio input (on the AVR). * The following objectives are listed chronologically, and they are executed sequentially to avoid timing issues * - request AVR audio volume * - request MP playback pause * - request AVR audio source switch, request Smart TV to wake and launch app * - request AVR audio volume adjustment */ return [ (acc, [event, appState]) => { const [avrVolumeStatus, [fromMpStatusState, toMpStatusState]] = /** @type {[AvrVolumeStatus, MpStatusStateTransition]} */ acc; const { source } = /** @type {CecClientEvent|MpClientEvent} */ event; switch (source) { case 'cecClient': const cecClientEvent = /** @type CecClientEvent */ event; const [isAudioDeviceOn] = avrService.decodeAvrPowerStatus(cecClientEvent); // if the CEC transmission is regarding audio turning off (i.e. the AVR goes to stand-by mode) if (isAudioDeviceOn === false) { // then reset the reducer and request the TV to go to standby tvLaunchService.isEnabled() && tvLaunchService.standBy().subscribe(); return getInitState(); } // if the CEC transmission is not regarding audio turning off, and // if the reducer is waiting for MP to respond to playback pause request if (fromMpStatusState && !toMpStatusState) { // then wait for MP to confirm its next state, see case 'mpClient' return acc; } // if the CEC transmission is not regarding audio turning off, and // if the reducer is not waiting for MP to respond to playback pause request, and // if the reducer is not waiting for the AVR to respond to audio volume request if ( !avrVolumeStatus.length || avrService.isAvrVolumeStatsValid(avrVolumeStatus) ) { // then initiate the audio switch sequence return initiateAudioSourceSwitch(appState, cecClientEvent); } const _avrVolumeStatus = avrService.decodeAvrVolumeStatus(cecClientEvent); // if the CEC transmission is not regarding audio turning off, and // if the reducer is not waiting for MP to respond to playback pause request, and // if the reducer is waiting for the AVR to respond to audio volume request, and // if the CEC transmission is not regarding AVR responding to audio volume request if (!avrService.isAvrVolumeStatsValid(_avrVolumeStatus)) { // then no-op return acc; } const { state } = /** @type AppState */ appState; // if the CEC transmission is not regarding audio turning off, and // if the reducer is not waiting for MP to respond to playback pause request, and // if the reducer is waiting for the AVR to respond to audio volume request, and // if the CEC transmission is regarding AVR responding to audio volume request, and // if MP is playing if (playRegExp.test(state)) { // then ask MP to pause avrService.updateOsd('pause'); /** * @desc Unfortunately, there is a mysterious incompatibility issue between cec-client and netcat that prevents sending commands to them in proximity, some magic number is used here */ setTimeout(() => mpService.pause(), 500); return [_avrVolumeStatus, [state, undefined]]; } // if the CEC transmission is not regarding audio turning off, and // if the reducer is not waiting for MP to respond to playback pause request, and // if the reducer is waiting for the AVR to respond to audio volume request, and // if the CEC transmission is regarding AVR responding to audio volume request, and // if MP is not playing, // then do not ask MP to pause and switch audio source directly switchingAudioSource(_avrVolumeStatus); return [_avrVolumeStatus, [state, state]]; case 'mpClient': // if the reducer is not waiting for MP to respond to playback pause request if (!fromMpStatusState || toMpStatusState) { // then reset the reducer return getInitState(); } const { data: mpStatus } = /** @type MpClientEvent */ event; // if the reducer is waiting for MP to respond to playback pause request, and // if MP decides to keep playing if (playRegExp.test(mpStatus.state)) { // then reset the reducer return getInitState(); } // if the reducer is waiting for MP to respond to playback pause request, and // if MP confirms playback is paused, // then switch the audio source /** * @desc Unfortunately, there is a mysterious incompatibility issue between cec-client and netcat that prevents sending commands to them in proximity, some magic number is used here */ setTimeout(() => switchingAudioSource(avrVolumeStatus), 500); return [avrVolumeStatus, [fromMpStatusState, mpStatus.state]]; default: return getInitState(); } }, getInitState(), ]; })(_appConfig, _cecClientProcess); }; const AppStateService = function () { return (() => { /** * Create AppState object * @param {AvrPowerStatus} avrPowerStatus The AVR power status * @param {MpStatus} mpStatus The MP status * @param {string[]} playlists The list of playlist names * @returns {AppState} AppState object */ const createAppState = (avrPowerStatus, mpStatus, playlists) => { const [isAudioDeviceOn] = avrPowerStatus; const { state, song, playlistlength, elapsed, duration, repeat, random } = mpStatus; const showPlaylist = !playOrPauseRegExp.test(state); return /** @type AppState */ { isAudioDeviceOn, showPlaylist, playlistIdx: 0, playlists: /** @type string[] */ [...playlists], state, song, playlistlength, elapsed, duration, repeat, random, }; }; /** * Compare the current and the next application state to determine whether there is a change of state * @param {AppState} currentAppState The current application state * @param {AppState} nextAppState The next application state * @returns {boolean} Whether the application state is considered as changed */ const isAppStateChanged = (currentAppState, nextAppState) => { if (currentAppState.isAudioDeviceOn !== nextAppState.isAudioDeviceOn) { return false; } if (currentAppState.showPlaylist !== nextAppState.showPlaylist) { return false; } const { showPlaylist } = currentAppState; if (showPlaylist) { return currentAppState.playlistIdx === nextAppState.playlistIdx; } return ( currentAppState.state === nextAppState.state && currentAppState.repeat === nextAppState.repeat && currentAppState.random === nextAppState.random ); }; return { createAppState, isAppStateChanged }; })(); }; const AppStateReducer = function (_appConfig, _cecClientProcess) { return ((appConfig, cecClientProcess) => { const avrService = new AvrService(appConfig, cecClientProcess); const mpService = new MpService(); /** * Reaction to AVR power status change * @param {ChildProcessWithoutNullStreams} cecClientProcess The API service for the AVR * @param {boolean} isAudioDeviceOn Whether the AVR is ON * @param {AppState} currentAppState The current application state * @returns {AppState} The next application state */ const onAvrPowerStatusChange = ( cecClientProcess, isAudioDeviceOn, currentAppState ) => { // if there is no change in the power status of the AVR if (isAudioDeviceOn === currentAppState.isAudioDeviceOn) { // then no-op /** * @desc Notes * - The `on 5` command causes the AVR to stop emitting standby events. This application will not turn on the AVR programmatically */ return { ...currentAppState }; } // if there is a change in the power status of the AVR, and // if the AVR turns on if (isAudioDeviceOn) { // then update the application state return { ...currentAppState, isAudioDeviceOn, showPlaylist: !playOrPauseRegExp.test(currentAppState.state), }; } // if there is a change in the power status of the AVR, and // if the AVR turns off, // then reset application mpService.pause(); /** * @desc Unfortunately, there is a mysterious incompatibility issue between cec-client and netcat that prevents sending commands to them in proximity, some magic number is used here */ setTimeout(() => avrService.setActiveSource(), 500); return { ...currentAppState, isAudioDeviceOn, showPlaylist: true }; }; /** * Reaction to Mp status change * @param {MpStatus} mpStatus The new MP status * @param {AppState} currentAppState The current application state * @returns {AppState} The next application state */ const onMpStateChange = ( { state, song, playlistlength, elapsed, duration, repeat, random }, currentAppState ) => { if (playOrPauseRegExp.test(state)) { return { ...currentAppState, showPlaylist: false, state, song, playlistlength, elapsed, duration, repeat, random, }; } if (stopRegExp.test(state)) { return { ...currentAppState, showPlaylist: true, state, song, playlistlength, elapsed, duration, repeat, random, }; } return { ...currentAppState }; }; /** * Reaction to AVR remote control event * @param {CecTransmission} cecTransmission The remote control event * @param {AppState} currentAppState The current application state * @returns {AppState} The next application state */ const onRemoteControlKeyup = (cecTransmission, currentAppState) => { const { showPlaylist, playlists, playlistIdx } = currentAppState; // if the OSD is displaying the playlists if (showPlaylist) { // then handle playlist navigation if (!playlists.length) { return { ...currentAppState }; } /** * @desc arrow up action */ if (arrowUpKeyupRegExp.test(cecTransmission)) { return { ...currentAppState, playlistIdx: (playlists.length + playlistIdx - 1) % playlists.length, }; } /** * @desc arrow down action */ if (arrowDownKeyupRegExp.test(cecTransmission)) { return { ...currentAppState, playlistIdx: (playlists.length + playlistIdx + 1) % playlists.length, }; } /** * @desc enter action */ if (enterKeyupRegExp.test(cecTransmission)) { mpService.playPlaylist(playlists[playlistIdx]); return { ...currentAppState }; } /** * @desc close playlist */ if (returnKeyupRegExp.test(cecTransmission)) { return { ...currentAppState, showPlaylist: !playOrPauseRegExp.test(currentAppState.state), }; } return { ...currentAppState }; } // if the OSD is displaying the player status, // then handle player actions /** * @desc show playlist action */ if (returnKeyupRegExp.test(cecTransmission)) { return { ...currentAppState, showPlaylist: true }; } /** * @desc play action */ if (playKeyupRegExp.test(cecTransmission)) { /** * @desc Unfortunately, there is a mysterious incompatibility issue between cec-client and netcat that prevents sending commands to them in proximity, some magic number is used here */ setTimeout(() => void mpService.resume(), 500); return { ...currentAppState }; } /** * @desc pause action */ if (pauseKeyupRegExp.test(cecTransmission)) { /** * @desc Unfortunately, there is a mysterious incompatibility issue between cec-client and netcat that prevents sending commands to them in proximity, some magic number is used here */ setTimeout(() => mpService.pause(), 500); return { ...currentAppState }; } /** * @desc stop action */ if (stopKeyupRegExp.test(cecTransmission)) { mpService.stop(); return { ...currentAppState }; } /** * @desc next song action */ if (nextKeyupRegExp.test(cecTransmission)) { mpService.nextSong(); return { ...currentAppState }; } /** * @desc previous song action */ if (previousKeyupRegExp.test(cecTransmission)) { mpService.previousSong(); return { ...currentAppState }; } /** * @desc toggle repeat action */ if (redFunctionKeyupRegExp.test(cecTransmission)) { /** * @desc Unfortunately, there is a mysterious incompatibility issue between cec-client and netcat that prevents sending commands to them in proximity, some magic number is used here */ setTimeout( () => void mpService.setRepeat( currentAppState.repeat === '1' ? '0' : '1' ), 500 ); return { ...currentAppState }; } /** * @desc toggle random action */ if (greenFunctionKeyupRegExp.test(cecTransmission)) { /** * @desc Unfortunately, there is a mysterious incompatibility issue between cec-client and netcat that prevents sending commands to them in proximity, some magic number is used here */ setTimeout( () => void mpService.setRandom( currentAppState.random === '1' ? '0' : '1' ), 500 ); return { ...currentAppState }; } return { ...currentAppState }; }; /** * @desc The entire application state */ return [ (currentAppState, event) => { if (!event) { return; } let /** @type AppState */ appState; // if handling the initial application state if (currentAppState === undefined) { // then initialize the application state appState = /** @type AppState */ event; return { ...appState }; } appState = currentAppState; const { data, source } = /** @type {CecClientEvent|MpClientEvent} */ event; // if handling a subsequent actions or state change switch (source) { // then mutate the application state // if the event comes from the music player case 'mpClient': // then update the application state to sync up with the music player state const mpStatus = /** @type MpStatus */ data; return onMpStateChange(mpStatus, appState); // if the event comes from the CEC client case 'cecClient': // then update the application state according to the action const [isAudioDeviceOn] = avrService.decodeAvrPowerStatus( /** @type {CecClientEvent} */ event ); if (isAudioDeviceOn !== undefined) { return onAvrPowerStatusChange( cecClientProcess, isAudioDeviceOn, appState ); } if (!appState.isAudioDeviceOn) { return { ...appState }; } return onRemoteControlKeyup( /** @type CecTransmission */ data, appState ); // if the event comes from an unknown source default: // then no-op return { ...appState }; } }, undefined, ]; })(_appConfig, _cecClientProcess); }; const AppStateRenderer = function (_appConfig, _cecClientProcess) { return ((appConfig, cecClientProcess) => { const avrService = new AvrService(appConfig, cecClientProcess); const rightShiftString = (str, shift) => { const { length } = str; const idx = length - shift; return `${str.slice(idx)}${str.slice(0, idx)}`; }; return (appState) => { const { showPlaylist, playlistIdx, playlists, state, song, playlistlength, elapsed, duration, } = /** @type AppState */ appState; let /** @type string */ message; // if the OSD is displaying the playlists if (showPlaylist) { // then print the current playlist message = playlists[playlistIdx]; return avrService.updateOsd(message); } // if the OSD is displaying the player status, and // if the MP is playing if (playRegExp.test(state)) { // then print the song position in the playlist message = `[${song}/${playlistlength}]`; return avrService.updateOsd(message); } // if the OSD is displaying the player status, and // if the MP is paused, // then print the song progress const elapsedInSeconds = Number(elapsed); const durationInSeconds = Number(duration); const offset = durationInSeconds && Math.floor((elapsedInSeconds / durationInSeconds) * 10); message = `>${Array.from({ length: 10 }).join('_')}`; message = `[${rightShiftString(message, offset)}]`; return avrService.updateOsd(message); }; })(_appConfig, _cecClientProcess); }; const PromptRenderer = function (_appConfig, _cecClientProcess) { return ((appConfig, cecClientProcess) => { const avrService = new AvrService(appConfig, cecClientProcess); return ([cecClientEvent, appState]) => { const { data: cecTransmission } = /** @type CecClientEvent */ cecClientEvent; const { state, repeat, random } = /** @type AppState */ appState; let /** @type string */ message; /** * @desc play action */ if (playKeyupRegExp.test(cecTransmission) && !playRegExp.test(state)) { message = 'play'; return avrService.updateOsd(message); } /** * @desc pause action */ if (pauseKeyupRegExp.test(cecTransmission) && !pauseRegExp.test(state)) { message = 'pause'; return avrService.updateOsd(message); } /** * @desc toggle repeat action */ if (redFunctionKeyupRegExp.test(cecTransmission)) { message = `repeat: ${repeat === '1' ? 'OFF' : 'ON'}`; return avrService.updateOsd(message); } /** * @desc toggle random action */ if (greenFunctionKeyupRegExp.test(cecTransmission)) { message = `random: ${random === '1' ? 'OFF' : 'ON'}`; return avrService.updateOsd(message); } }; })(_appConfig, _cecClientProcess); }; const AppTerminator = function (_cecClientProcess, _mpClientProcess) { return ((cecClientProcess, mpClientProcess) => { const destroy$ = new Subject(); const publisher = () => destroy$; const onExit = (isKillSignal = false) => { cecClientProcess && cecClientProcess.kill(); mpClientProcess && mpClientProcess.kill(); destroy$.next(null); destroy$.complete(); if (isKillSignal) { return; } process.exit(); }; return { publisher, onExit }; })(_cecClientProcess, _mpClientProcess); }; const TvLaunchService = function (_appConfig) { return ((appConfig) => { let /** @type string */ tvLaunchProfilePath; let /** @type TvType */ tvType; for (const tvLaunchProfileType of tvLaunchProfileTypeTvTypeMap.keys()) { if (!appConfig[tvLaunchProfileType]) { continue; } tvLaunchProfilePath = appConfig[tvLaunchProfileType]; tvType = tvLaunchProfileTypeTvTypeMap.get(tvLaunchProfileType); } const httpClient = new HttpClient(); const braviaPayloadBase = /** @type {Pick<BraviaPayload, "version" | "id" | "params">} */ { version: '1.0', id: 1, params: [], }; const tvLaunchProfile$ = /** @type Observable<TvLaunchProfile> */ new Observable((subscriber) => { readFile(tvLaunchProfilePath, (err, data) => { if (err) { return subscriber.error(err); } try { subscriber.next(JSON.parse(data.toString())); subscriber.complete(); } catch (err) { subscriber.error(err); subscriber.complete(); } }); return () => {}; }).pipe(shareReplay()); const isEnabled = () => !!tvType; const getAppTitle = () => tvLaunchProfile$.pipe( map(({ appTitle }) => appTitle), catchError(() => of({})), take(1) ); const wake = () => tvLaunchProfile$.pipe( switchMap((tvLaunchProfile) => { const { hostname } = /** @type TvLaunchProfile