Google Home(Nest Hub)でmp3再生中にシーク/音量変更/一時停止・再開/ミュートを操作する [プログラミング]
google-home-notifierを用いて、Google Home(Nest Hub)で音楽ファイル(mp3)を
再生する方法は調べればいくらでも出てくるが、再生中にシークや音量の変更、
一時停止→再開、ミュートの切り替えなどの操作をする方法はあまり出てこなかったのでメモ。
再生する方法は調べればいくらでも出てくるが、再生中にシークや音量の変更、
一時停止→再開、ミュートの切り替えなどの操作をする方法はあまり出てこなかったのでメモ。
簡単に言うと、google-home-notifierが用いているcastv2-clientというモジュールに
これらの機能について既にインタフェースがあるので、単純にこれを呼び出すだけ。
前提
・node.jsとexpress-generatorがインストールされていること
方針
・google-home-notifierを操作するexpressサーバを立ち上げて、
URLを叩くことで操作する
下記手順は、修正したコード前提での操作となっている。
①express app名、appディレクトリ移動後npm update を実行し、
expressのアプリをセットアップする
②npm install google-home-notifier とnpm install aa を実行する
③node_modules/google-home-notifier/google-home-notifier.jsを、
①の直下にcustom-google-home-notifier.jsという名前でコピーする
※後の手順で内容を差し替えるので、ファイル名さえあれば空でも良い
④node_modules/castv2-client/index.js及びlibフォルダを
階層を保持したまま①の直下にcustom-castv2-clientというフォルダ名でコピーする
⑤custom-google-home-notifier.jsを以下のように変更する
⑥custom-castv2-client/lib/controllers/media.jsを以下のように変更する
⑦routes/index.jsを以下のように変更する
※サンプルのためmp3のURLは固定。
適宜リクエストのパラメータで受け付ける等にする。
⑧npm startでサーバを立ち上げる
⑨サーバのIPアドレス:3000にアクセスするとmp3の再生が始まる
⑩サーバのIPアドレス:3000/pauseで一時停止、/playで再開、/stopで停止、
/seek/{変化量} でシーク、/volume/{変化量} で音量変更、/muteでミュート切り替え
以下、コードの説明
・custom-google-home-notifier.js
これらの機能について既にインタフェースがあるので、単純にこれを呼び出すだけ。
前提
・node.jsとexpress-generatorがインストールされていること
方針
・google-home-notifierを操作するexpressサーバを立ち上げて、
URLを叩くことで操作する
下記手順は、修正したコード前提での操作となっている。
①express app名、appディレクトリ移動後npm update を実行し、
expressのアプリをセットアップする
②npm install google-home-notifier とnpm install aa を実行する
③node_modules/google-home-notifier/google-home-notifier.jsを、
①の直下にcustom-google-home-notifier.jsという名前でコピーする
※後の手順で内容を差し替えるので、ファイル名さえあれば空でも良い
④node_modules/castv2-client/index.js及びlibフォルダを
階層を保持したまま①の直下にcustom-castv2-clientというフォルダ名でコピーする
⑤custom-google-home-notifier.jsを以下のように変更する
var Client = require('./custom-castv2-client').Client;
var DefaultMediaReceiver = require('./custom-castv2-client').DefaultMediaReceiver;
var mdns = require('mdns');
var browser = mdns.createBrowser(mdns.tcp('googlecast'));
var deviceAddress;
var language;
var device = function(name, lang = 'en') {
device = name;
language = lang;
return this;
};
var ip = function(ip) {
deviceAddress = ip;
return this;
}
var googletts = require('google-tts-api');
var googlettsaccent = 'us';
var accent = function(accent) {
googlettsaccent = accent;
return this;
}
var notify = function(message, callback) {
if (!deviceAddress){
browser.start();
browser.on('serviceUp', function(service) {
console.log('Device "%s" at %s:%d', service.name, service.addresses[0], service.port);
if (service.name.includes(device.replace(' ', '-'))){
deviceAddress = service.addresses[0];
getSpeechUrl(message, deviceAddress, function(res) {
callback(res);
});
}
browser.stop();
});
}else {
getSpeechUrl(message, deviceAddress, function(res) {
callback(res);
});
}
};
var play = function(mp3_url, callback) {
if (!deviceAddress){
browser.start();
browser.on('serviceUp', function(service) {
console.log('Device "%s" at %s:%d', service.name, service.addresses[0], service.port);
if (service.name.includes(device.replace(' ', '-'))){
deviceAddress = service.addresses[0];
getPlayUrl(mp3_url, deviceAddress, function(res) {
callback(res);
});
}
browser.stop();
});
}else {
getPlayUrl(mp3_url, deviceAddress, function(res) {
callback(res);
});
}
};
var getSpeechUrl = function(text, host, callback) {
googletts(text, language, 1).then(function (url) {
onDeviceUp(host, url, function(res){
callback(res)
});
}).catch(function (err) {
console.error(err.stack);
});
};
var getPlayUrl = function(url, host, callback) {
onDeviceUp(host, url, function(res){
callback(res)
});
};
var onDeviceUp = function(host, url, callback) {
var client = new Client();
client.connect(host, function() {
client.launch(DefaultMediaReceiver, function(err, player) {
callback({status:'launch', client:client, player:player});
var media = {
contentId: url,
contentType: 'audio/mp3',
streamType: 'BUFFERED' // or LIVE
};
player.load(media, { autoplay: true }, function(err, status) {
//client.close();
});
player.on('status', function (status) {
if (void 0 !== status.extendedStatus && 'LOADING' === status.extendedStatus.playerState) {
callback({status:'notified'});
} else if ('FINISHED' === status.idleReason) {
callback({status:'finish'});
client.close();
} else if ('ERROR' === status.idleReason) {
callback({status:'error'});
}
});
});
});
client.on('error', function(err) {
console.log('Error: %s', err.message);
client.close();
callback({status:'error'});
});
};
exports.ip = ip;
exports.device = device;
exports.accent = accent;
exports.notify = notify;
exports.play = play;
⑥custom-castv2-client/lib/controllers/media.jsを以下のように変更する
var util = require('util');
var debug = require('debug')('castv2-client');
var RequestResponseController = require('./request-response');
function MediaController(client, sourceId, destinationId) {
RequestResponseController.call(this, client, sourceId, destinationId, 'urn:x-cast:com.google.cast.media');
this.currentSession = null;
this.on('message', onmessage);
this.once('close', onclose);
var self = this;
function onmessage(data, broadcast) {
if(data.type === 'MEDIA_STATUS' && broadcast) {
var status = data.status[0];
// Sometimes an empty status array can come through; if so don't emit it
if (!status) return;
self.currentSession = status;
self.emit('status', status);
}
}
function onclose() {
self.removeListener('message', onmessage);
self.stop();
}
}
util.inherits(MediaController, RequestResponseController);
MediaController.prototype.getStatus = function(callback) {
var self = this;
this.request({ type: 'GET_STATUS' }, function(err, response) {
if(err) return callback(err);
var status = response.status[0];
self.currentSession = status;
callback(null, status);
});
};
MediaController.prototype.checkAlive = function(timeoutSec, callback) {
if (!timeoutSec || 0 >= timeoutSec) {
console.log('invalid timeout:' + timeoutSec);
callback({ status: 'ERROR' });
return;
}
var isAlive = false;
try {
this.request({ type: 'GET_STATUS' }, function(err, response) {
if(err) return callback({ status: 'ERROR' });
isAlive = true;
callback(null, { status:'ALIVE' });
});
setTimeout(function() {
if (!isAlive) {
callback(null, { status:'NOT_ALIVE' });
}
}, timeoutSec * 1000);
} catch(err2) {
callback({ status: 'ERROR' });
}
};
MediaController.prototype.load = function(media, options, callback) {
if(typeof options === 'function' || typeof options === 'undefined') {
callback = options;
options = {};
}
var data = { type: 'LOAD' };
data.autoplay = (typeof options.autoplay !== 'undefined')
? options.autoplay
: false;
data.currentTime = (typeof options.currentTime !== 'undefined')
? options.currentTime
: 0;
data.activeTrackIds = (typeof options.activeTrackIds !== 'undefined')
? options.activeTrackIds
: [];
data.repeatMode = (typeof options.repeatMode === "string" &&
typeof options.repeatMode !== 'undefined')
? options.repeatMode
: "REPEAT_OFF";
data.media = media;
this.request(data, function(err, response) {
if(err) return callback(err);
if(response.type === 'LOAD_FAILED') {
return callback(new Error('Load failed'));
}
if(response.type === 'LOAD_CANCELLED') {
return callback(new Error('Load cancelled'));
}
var status = response.status[0];
callback(null, status);
});
};
function noop() {}
MediaController.prototype.sessionRequest = function(data, callback) {
data.mediaSessionId = this.currentSession.mediaSessionId;
callback = callback || noop;
this.request(data, function(err, response) {
if(err) return callback(err);
var status = response.status[0];
callback(null, status);
});
};
MediaController.prototype.play = function(callback) {
this.sessionRequest({ type: 'PLAY' }, callback);
};
MediaController.prototype.pause = function(callback) {
this.sessionRequest({ type: 'PAUSE' }, callback);
};
MediaController.prototype.stop = function(callback) {
this.sessionRequest({ type: 'STOP' }, callback);
};
MediaController.prototype.seek = function(currentTime, callback) {
var data = {
type: 'SEEK',
currentTime: currentTime
};
this.sessionRequest(data, callback);
};
//Load a queue of items to play (playlist)
//See https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueLoadRequest
MediaController.prototype.queueLoad = function(items, options, callback) {
if(typeof options === 'function' || typeof options === 'undefined') {
callback = options;
options = {};
}
var data = { type: 'QUEUE_LOAD' };
//REPEAT_OFF, REPEAT_ALL, REPEAT_SINGLE, REPEAT_ALL_AND_SHUFFLE
data.repeatMode = (typeof options.repeatMode === "string" &&
typeof options.repeatMode !== 'undefined')
? options.repeatMode
: "REPEAT_OFF";
data.currentTime = (typeof options.currentTime !== 'undefined')
? options.currentTime
: 0;
data.startIndex = (typeof options.startIndex !== 'undefined')
? options.startIndex
: 0;
data.items = items;
this.request(data, function(err, response) {
if(err) return callback(err);
if(response.type === 'LOAD_FAILED') {
return callback(new Error('queueLoad failed'));
}
if(response.type === 'LOAD_CANCELLED') {
return callback(new Error('queueLoad cancelled'));
}
var status = response.status[0];
callback(null, status);
});
};
MediaController.prototype.queueInsert = function(items, options, callback) {
if(typeof options === 'function' || typeof options === 'undefined') {
callback = options;
options = {};
}
var data = {
type: 'QUEUE_INSERT',
currentItemId: options.currentItemId, //Item ID to play after this request or keep same item if undefined
currentItemIndex: options.currentItemIndex, //Item Index to play after this request or keep same item if undefined
currentTime: options.currentTime, //Seek in seconds for current stream
insertBefore: options.insertBefore, //ID or append if undefined
items: items
};
this.sessionRequest(data, callback);
};
MediaController.prototype.queueRemove = function(itemIds, options, callback) {
if(typeof options === 'function' || typeof options === 'undefined') {
callback = options;
options = {};
}
var data = {
type: 'QUEUE_REMOVE',
currentItemId: options.currentItemId,
currentTime: options.currentTime,
itemIds: itemIds
};
this.sessionRequest(data, callback);
};
MediaController.prototype.queueReorder = function(itemIds, options, callback) {
if(typeof options === 'function' || typeof options === 'undefined') {
callback = options;
options = {};
}
var data = {
type: 'QUEUE_REORDER',
currentItemId: options.currentItemId,
currentTime: options.currentTime,
insertBefore: options.insertBefore,
itemIds: itemIds
};
this.sessionRequest(data, callback);
};
MediaController.prototype.queueUpdate = function(items, options, callback) {
if(typeof options === 'function' || typeof options === 'undefined') {
callback = options;
options = {};
}
var data = {
type: 'QUEUE_UPDATE',
currentItemId: options.currentItemId,
currentTime: options.currentTime,
jump: options.jump, //Skip or go back (if negative) number of items
repeatMode: options.repeatMode,
items: items
};
this.sessionRequest(data, callback);
};
module.exports = MediaController;
⑦routes/index.jsを以下のように変更する
※サンプルのためmp3のURLは固定。
適宜リクエストのパラメータで受け付ける等にする。
var express = require('express');
var router = express.Router();
const googlehome = require('../custom-google-home-notifier');
const aa = require('aa');
const Channel = aa.Channel;
googlehome.device('Google Homeの名前', 'ja');
googlehome.ip('Google HomeのIPアドレス');
const audioURL = 'http://blankfield.but.jp/music/bow.mp3';
const checkIntervalSec = 5;
const timeoutSec = 10;
var client = undefined;
var player = undefined;
var aliveChecker = undefined;
const pause = 'pause';
const play = 'play';
const stop = 'stop';
const seek = 'seek';
const volume = 'volume';
const mute = 'mute';
function reset() {
if (client) {
try {
client.close();
} catch (err) {}
}
if (void 0 !== aliveChecker) {
clearInterval(aliveChecker);
}
client = undefined;
player = undefined;
aliveChecker = undefined;
}
function checkAlive(callback) {
if (!player) {
callback('not playing');
return;
}
player.media.checkAlive(timeoutSec, function(err, result) {
if (err) {
callback(err);
}
callback(result.status);
});
}
/* Load. */
router.get('/', function(req, res, next) {
reset();
var isSent = false;
googlehome.play(audioURL, function(callback) {
switch(callback.status) {
case 'launch':
client = callback.client;
player = callback.player;
break;
case 'finish':
case 'error':
reset();
case 'notified':
if (!isSent) {
res.json({status:callback.status, url:audioURL});
isSent = true;
}
break;
default:
break;
}
});
aliveChecker = setInterval(function() {
aa(function*() {
if (!player) {
clearInterval(aliveChecker);
return;
}
var channel = Channel();
checkAlive(channel);
var checkResult = yield channel;
if ('NOT_ALIVE' == checkResult && void 0 !== aliveChecker) {
clearInterval(aliveChecker);
reset();
console.log('player stopped');
}
});
}, checkIntervalSec * 1000);
});
/* Pause. */
router.get('/' + pause, function(req, res, next) {
aa(function*() {
var action = pause;
var result = 'failed';
if (!player) {
res.json({action:action, result:result});
return;
}
var channel = Channel();
checkAlive(channel);
var actionRes = yield channel;
if ('ALIVE' != actionRes) {
res.json({action:action, result:result, status:actionRes});
return;
}
player.media.pause(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
result = 'success';
res.json({action:action, result:result});
});
});
/* Play. */
router.get('/' + play, function(req, res, next) {
aa(function*() {
var action = play;
var result = 'failed';
if (!player) {
res.json({action:action, result:result});
return;
}
var channel = Channel();
checkAlive(channel);
var actionRes = yield channel;
if ('ALIVE' != actionRes) {
res.json({action:action, result:result, status:actionRes});
return;
}
player.media.play(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
result = 'success';
res.json({action:action, result:result});
});
});
/* Stop. */
router.get('/' + stop, function(req, res, next) {
aa(function*() {
var action = stop;
var result = 'failed';
if (!player) {
res.json({action:action, result:result});
return;
}
var channel = Channel();
checkAlive(channel);
var actionRes = yield channel;
if ('ALIVE' != actionRes) {
res.json({action:action, result:result, status:actionRes});
return;
}
player.media.stop(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
reset();
result = 'success';
res.json({action:action, result:result});
});
});
/* Seek. */
router.get('/' + seek + '/:degree', function(req, res, next) {
aa(function*() {
var action = seek;
var result = 'failed';
if (!player) {
res.json({action:seek, result:result});
return;
}
var degree = parseInt(req.params.degree) ? parseInt(req.params.degree) : undefined;
if (void 0 === degree) {
res.json({action:action, result:result, detail:'invalid degree'});
return;
}
var channel = Channel();
checkAlive(channel);
var actionRes = yield channel;
if ('ALIVE' != actionRes) {
res.json({action:action, result:result, status:actionRes});
return;
}
player.media.getStatus(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
var currentTime = actionRes.currentTime + degree;
player.media.seek(currentTime, function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
result = 'success';
res.json({action:action, result:result, currentTime:actionRes.currentTime});
});
});
/* Volume. */
router.get('/' + volume + '/:degree', function(req, res, next) {
aa(function*() {
var action = volume;
var result = 'failed';
if (!client) {
res.json({action:action, result:result});
return;
}
var degree = parseInt(req.params.degree) ? parseInt(req.params.degree) : undefined;
if (void 0 === degree) {
res.json({action:action, result:result, detail:'invalid degree'});
return;
}
var channel = Channel();
checkAlive(channel);
var actionRes = yield channel;
if ('ALIVE' != actionRes) {
res.json({action:action, result:result, status:actionRes});
return;
}
client.getVolume(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
var level = actionRes.level + degree / 100;
client.setVolume({level:level}, function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
result = 'success';
res.json({action:action, result:result, level:actionRes.level});
});
});
/* Mute. */
router.get('/' + mute, function(req, res, next) {
aa(function*() {
var action = mute;
var result = 'failed';
if (!client) {
res.json({action:action, result:result});
return;
}
var channel = Channel();
checkAlive(channel);
var actionRes = yield channel;
if ('ALIVE' != actionRes) {
res.json({action:action, result:result, status:actionRes});
return;
}
client.getVolume(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
client.setVolume({muted:!actionRes.muted}, function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
result = 'success';
res.json({action:action, result:result, muted:actionRes.muted});
});
});
module.exports = router;
⑧npm startでサーバを立ち上げる
⑨サーバのIPアドレス:3000にアクセスするとmp3の再生が始まる
⑩サーバのIPアドレス:3000/pauseで一時停止、/playで再開、/stopで停止、
/seek/{変化量} でシーク、/volume/{変化量} で音量変更、/muteでミュート切り替え
以下、コードの説明
・custom-google-home-notifier.js
onDeviceUp()で、クライアントとの接続完了後のlaunch()のコールバックで、
操作に必要なclient(PlatformSender)とplayer(MediaController)を
要求元に通知し、このコネクションに対して要求元から操作可能にする。
player.load()でclient.close()でコネクションを閉じてしまっているが、
これだと再生中の操作が不可能になってしまうのでコメントアウト
player.on()で、再生対象のURLがロードされれば、その後のon()で自動再生される
(load()のオプションでautoplayをtrueで指定しているため)ので、このタイミングで
URLが通知されたことを要求元に通知する(PLAYINGで通知してもよいが、
再開などでも呼ばれたり、何度か呼ばれることがあるようなので使いにくい)。
FINISHEDがコールバックされたときにはこの時点でコネクションをcloseする。
操作に必要なclient(PlatformSender)とplayer(MediaController)を
要求元に通知し、このコネクションに対して要求元から操作可能にする。
client.connect(host, function() {
client.launch(DefaultMediaReceiver, function(err, player) {
callback({status:'launch', client:client, player:player});
player.load()でclient.close()でコネクションを閉じてしまっているが、
これだと再生中の操作が不可能になってしまうのでコメントアウト
player.load(media, { autoplay: true }, function(err, status) {
//client.close();
});
player.on()で、再生対象のURLがロードされれば、その後のon()で自動再生される
(load()のオプションでautoplayをtrueで指定しているため)ので、このタイミングで
URLが通知されたことを要求元に通知する(PLAYINGで通知してもよいが、
再開などでも呼ばれたり、何度か呼ばれることがあるようなので使いにくい)。
player.on('status', function (status) {
if (void 0 !== status.extendedStatus && 'LOADING' === status.extendedStatus.playerState) {
callback({status:'notified'});
}
FINISHEDがコールバックされたときにはこの時点でコネクションをcloseする。
} else if ('FINISHED' === status.idleReason) {
callback({status:'finish'});
client.close();
} else if ('ERROR' === status.idleReason) {
callback({status:'error'});
}
・media.js
castv2-clientには、接続したコネクションから外的要因による
プレイヤーの終了を検知する仕組みが無いようであった。
これで何が困るかというと、再生中に別のアプリなどから割り込みが入り、
プレイヤーが強制的に中断されると、その後にMediaControllerで
操作要求をしても結果が戻ってこないという現象が発生し、
接続中のコネクションに対して今回の機能が使えなくなる。
詳しくは、更に下位モジュールのcastv2/lib/packet-stream-wrapper.jsを
みてもらえばわかるが、EventEmitterという機能を用いてソケット通信を
実現しているようである。
この中で、send()が要求処理でstream.write()によってデータを飛ばしている。
send()に対する要求結果はstream.on()のループで受け付けてコールバックしている。
castv2-clientではこれのラッパモジュールとしてrequest-response.jsという
モジュールがあり、requestIdというデータを付与してsend()し、
要求結果に同じrequestIdがあれば要求元にコールバックするようにしているようだが、
(おそらくChromecast等の動作仕様に従っていると思われる)上記のように
外的要因でプレイヤーが終了した場合はこのコールバックへ要求時のrequestIdと
同じrequestIdの要求結果が永遠にこないようであった。
ただし、Google CastのAPI仕様を見ると、タイムアウトのI/Fも用意されているようなので、
おそらくcastv2-clientとしては単にそれを実装していないだけと思われる。
そこで、MediaControllerで操作要求する前に、まずこの応答が正しく動作するかを
チェックするために、タイムアウトを用いたコネクションチェック関数を独自に用意する。
getStatus()を参考に、同じ要求を飛ばすcheckAlive()という関数を新規に用意する。
この関数では引数にタイムアウト(秒)を渡し、タイムアウト時間以内に
コールバックが飛んで来なかった場合はステータスNOT_ALIVEをコールバックし、
正常にコールバックが飛んできた場合はステータスALIVEをコールバックする。
プレイヤーの終了を検知する仕組みが無いようであった。
これで何が困るかというと、再生中に別のアプリなどから割り込みが入り、
プレイヤーが強制的に中断されると、その後にMediaControllerで
操作要求をしても結果が戻ってこないという現象が発生し、
接続中のコネクションに対して今回の機能が使えなくなる。
詳しくは、更に下位モジュールのcastv2/lib/packet-stream-wrapper.jsを
みてもらえばわかるが、EventEmitterという機能を用いてソケット通信を
実現しているようである。
この中で、send()が要求処理でstream.write()によってデータを飛ばしている。
send()に対する要求結果はstream.on()のループで受け付けてコールバックしている。
castv2-clientではこれのラッパモジュールとしてrequest-response.jsという
モジュールがあり、requestIdというデータを付与してsend()し、
要求結果に同じrequestIdがあれば要求元にコールバックするようにしているようだが、
(おそらくChromecast等の動作仕様に従っていると思われる)上記のように
外的要因でプレイヤーが終了した場合はこのコールバックへ要求時のrequestIdと
同じrequestIdの要求結果が永遠にこないようであった。
ただし、Google CastのAPI仕様を見ると、タイムアウトのI/Fも用意されているようなので、
おそらくcastv2-clientとしては単にそれを実装していないだけと思われる。
そこで、MediaControllerで操作要求する前に、まずこの応答が正しく動作するかを
チェックするために、タイムアウトを用いたコネクションチェック関数を独自に用意する。
getStatus()を参考に、同じ要求を飛ばすcheckAlive()という関数を新規に用意する。
この関数では引数にタイムアウト(秒)を渡し、タイムアウト時間以内に
コールバックが飛んで来なかった場合はステータスNOT_ALIVEをコールバックし、
正常にコールバックが飛んできた場合はステータスALIVEをコールバックする。
MediaController.prototype.checkAlive = function(timeoutSec, callback) {
if (!timeoutSec || 0 >= timeoutSec) {
console.log('invalid timeout:' + timeoutSec);
callback({ status: 'ERROR' });
return;
}
var isAlive = false;
try {
this.request({ type: 'GET_STATUS' }, function(err, response) {
if(err) return callback({ status: 'ERROR' });
isAlive = true;
callback(null, { status:'ALIVE' });
});
setTimeout(function() {
if (!isAlive) {
callback(null, { status:'NOT_ALIVE' });
}
}, timeoutSec * 1000);
} catch(err2) {
callback({ status: 'ERROR' });
}
};
・index.js
※各要求時の処理を追いやすくするために、極力関数化を避けているため
かなり冗長なコードとなっている。
/で再生し、コールバックでlaunchが来たときにclientとplayerを更新・保持する。
初回の通知時のときのみサーバのレスポンスを返す。
コネクション維持を定期巡回するために、一定間隔で処理を実行するチェッカを起動する。
タイム・アウトした場合はプレイヤーが停止したと判断し、リセットする。
/pauseでplayer.media.pause()を呼び出すことで一時停止が操作される。
/playや/stopも同様。
また、呼び出し時の処理前にコネクションのチェックを実行し、
ALIVEが返ってこなければ何もしないでレスポンスを返す(他操作でも同様)。
/seekでは、まず現在の再生時間を取得するためにplayer.media.getStatus()を呼ぶ。
コールバックのcurrentTimeがその時間になるので、パラメータdegreeを加算し、
player.media.seek()で再生時間を更新する(変化量を/seek/{数値}で指定する)。
0未満や曲の長さ以上の時間を指定した場合は最初/最後に移動するが、
seek()のコールバックのcurrentTimeが要求後の再生時間が設定される。
/volume、/muteでは、player.mediaではなくclientの方を用いているが、
音量の操作はMediaControllerではなくPlatformSenderに実装されているので
こちらを呼び出すようにしている。
音量は0.0~1.0の間で変更可能なので、/volume/{変化量}では百分率の値で指定し、
client.setVolume()ではdegreeを100で割った値を加算している。
要求するときには辞書形式で、音量変更の場合はlevelで指定、ミュート切り替えは
mutedで指定する。
かなり冗長なコードとなっている。
/で再生し、コールバックでlaunchが来たときにclientとplayerを更新・保持する。
初回の通知時のときのみサーバのレスポンスを返す。
/* Load. */
router.get('/', function(req, res, next) {
reset();
var isSent = false;
googlehome.play(audioURL, function(callback) {
switch(callback.status) {
case 'launch':
client = callback.client;
player = callback.player;
break;
case 'finish':
case 'error':
reset();
case 'notified':
if (!isSent) {
res.json({status:callback.status, url:audioURL});
isSent = true;
}
break;
default:
break;
}
});
コネクション維持を定期巡回するために、一定間隔で処理を実行するチェッカを起動する。
タイム・アウトした場合はプレイヤーが停止したと判断し、リセットする。
aliveChecker = setInterval(function() {
aa(function*() {
if (!player) {
clearInterval(aliveChecker);
return;
}
var channel = Channel();
checkAlive(channel);
var checkResult = yield channel;
if ('NOT_ALIVE' == checkResult && void 0 !== aliveChecker) {
clearInterval(aliveChecker);
reset();
console.log('player stopped');
}
});
}, checkIntervalSec * 1000);
/pauseでplayer.media.pause()を呼び出すことで一時停止が操作される。
/playや/stopも同様。
また、呼び出し時の処理前にコネクションのチェックを実行し、
ALIVEが返ってこなければ何もしないでレスポンスを返す(他操作でも同様)。
/* Pause. */
router.get('/' + pause, function(req, res, next) {
aa(function*() {
var action = pause;
var result = 'failed';
if (!player) {
res.json({action:action, result:result});
return;
}
var channel = Channel();
checkAlive(channel);
var actionRes = yield channel;
if ('ALIVE' != actionRes) {
res.json({action:action, result:result, status:actionRes});
return;
}
player.media.pause(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
/seekでは、まず現在の再生時間を取得するためにplayer.media.getStatus()を呼ぶ。
コールバックのcurrentTimeがその時間になるので、パラメータdegreeを加算し、
player.media.seek()で再生時間を更新する(変化量を/seek/{数値}で指定する)。
0未満や曲の長さ以上の時間を指定した場合は最初/最後に移動するが、
seek()のコールバックのcurrentTimeが要求後の再生時間が設定される。
/* Seek. */
router.get('/' + seek + '/:degree', function(req, res, next) {
aa(function*() {
中略
player.media.getStatus(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
var currentTime = actionRes.currentTime + degree;
player.media.seek(currentTime, function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
/volume、/muteでは、player.mediaではなくclientの方を用いているが、
音量の操作はMediaControllerではなくPlatformSenderに実装されているので
こちらを呼び出すようにしている。
音量は0.0~1.0の間で変更可能なので、/volume/{変化量}では百分率の値で指定し、
client.setVolume()ではdegreeを100で割った値を加算している。
/* Volume. */
router.get('/' + volume + '/:degree', function(req, res, next) {
aa(function*() {
中略
client.getVolume(function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
actionRes = yield channel;
if (null === actionRes) {
res.json({action:action, result:result});
return;
}
var level = actionRes.level + degree / 100;
client.setVolume({level:level}, function(err, callback) {
if (err) {
channel(null);
return;
}
channel(callback);
});
要求するときには辞書形式で、音量変更の場合はlevelで指定、ミュート切り替えは
mutedで指定する。
ちなみに、media.jsを見ればわかるが、実はcastv2-clientの標準機能を用いて
プレイリスト再生をすることも可能である。(queueLoad()など参照)。
これについてはまた別の記事として実現方法を紹介することにする。
→google-home-notifierで音楽のプレイリスト再生を実装する
参考にした記事
raspberry piでgoogle-homeを喋らせる │ 機械系エンジニア奮闘記
Google Homeに直接mp3ファイルを送って再生する方法 | 恵比寿ボイスプロダクション (Ebisu Voice Production)
Namespace: media | Cast | Google Developers
Namespace: cast | Cast | Google Developers
以上
プレイリスト再生をすることも可能である。(queueLoad()など参照)。
これについてはまた別の記事として実現方法を紹介することにする。
→google-home-notifierで音楽のプレイリスト再生を実装する
参考にした記事
raspberry piでgoogle-homeを喋らせる │ 機械系エンジニア奮闘記
Google Homeに直接mp3ファイルを送って再生する方法 | 恵比寿ボイスプロダクション (Ebisu Voice Production)
Namespace: media | Cast | Google Developers
Namespace: cast | Cast | Google Developers
以上
コメント 0