@scrypted/chromecast
Advanced tools
Comparing version 0.1.14 to 0.1.15
@@ -8,3 +8,3 @@ { | ||
{ | ||
"name" : "Scrypted Debugger", | ||
"name" : "Scrypted Debugger (qjs)", | ||
"type" : "quickjs", | ||
@@ -25,3 +25,17 @@ "request" : "attach", | ||
}, | ||
{ | ||
"name": "Scrypted Debugger", | ||
"address": "${config:scrypted.debugHost}", | ||
"port": 9091, | ||
"request": "attach", | ||
"skipFiles": [ | ||
"<node_internals>/**" | ||
], | ||
"preLaunchTask": "scrypted: deploy+debug", | ||
"sourceMaps": true, | ||
"localRoot": "${workspaceFolder}/out", | ||
"remoteRoot": "/plugin/", | ||
"type": "pwa-node" | ||
} | ||
] | ||
} |
@@ -1,4 +0,3 @@ | ||
{ | ||
"scrypted.debugHost": "192.168.2.120", | ||
"scrypted.debugHost": "127.0.0.1", | ||
} |
@@ -20,2 +20,2 @@ { | ||
] | ||
} | ||
} |
{ | ||
"name": "@scrypted/chromecast", | ||
"version": "0.1.14", | ||
"version": "0.1.15", | ||
"description": "Send video, audio, and text to speech notifications to Chromecast and Google Home devices", | ||
@@ -30,12 +30,14 @@ "author": "Scrypted", | ||
}, | ||
"dependencies": { | ||
"@scrypted/sdk": "file:../../sdk", | ||
"castv2-promise": "^1.0.0", | ||
"mdns": "^2.7.2", | ||
"memoize-one": "^5.1.1", | ||
"mime": "^2.5.2", | ||
"query-string": "^7.0.0" | ||
}, | ||
"devDependencies": { | ||
"@scrypted/sdk": "0.0.59", | ||
"raw-loader": "^1.0.0", | ||
"webpack-merge": "^4.2.2" | ||
}, | ||
"dependencies": { | ||
"castv2": "^0.1.10", | ||
"castv2-client": "^1.2.0", | ||
"memoize-one": "^5.1.1" | ||
"@types/mime": "^2.0.3", | ||
"@types/node": "^14.14.37" | ||
} | ||
} |
343
src/main.ts
'use strict'; | ||
const fs = require('fs'); | ||
const util = require('util'); | ||
import sdk, { Device, DeviceProvider, MediaPlayer, MediaPlayerState, MediaStatus, Notifier, Refresh, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface } from '@scrypted/sdk'; | ||
import {EventEmitter} from 'events'; | ||
import memoizeOne from 'memoize-one'; | ||
import util from 'util'; | ||
import sdk, { Device, DeviceProvider, EngineIOHandler, HttpRequest, MediaObject, MediaPlayer, MediaPlayerOptions, MediaPlayerState, MediaStatus, Refresh, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes } from '@scrypted/sdk'; | ||
import { EventEmitter } from 'events'; | ||
import mdns from 'mdns'; | ||
import mime from 'mime'; | ||
const { mediaManager, deviceManager, log } = sdk; | ||
// fs.registerFile(resolve('../node_modules/castv2/lib/cast_channel.proto'), require('raw-loader!../node_modules/castv2/lib/cast_channel.proto')) | ||
// fs.registerFile(resolve('../node_modules/castv2/lib/cast_channel.proto'), require('raw-loader!../node_modules/castv2/lib/cast_channel.proto')) | ||
const mdns = require('mdns'); | ||
const { mediaManager, systemManager, endpointManager, deviceManager, log } = sdk; | ||
const { DefaultMediaReceiver } = require('castv2-client'); | ||
const Client = require('castv2-client').Client; | ||
const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver; | ||
function ScryptedMediaReceiver() { | ||
DefaultMediaReceiver.apply(this, arguments); | ||
} | ||
ScryptedMediaReceiver.APP_ID = '9E3714BD'; | ||
ScryptedMediaReceiver.APP_ID = '00F7C5DD'; | ||
util.inherits(ScryptedMediaReceiver, DefaultMediaReceiver); | ||
const audioFetch = (body) => { | ||
var buf = Buffer.from(body); | ||
var mo = mediaManager.createMediaObject(buf, 'text/plain'); | ||
return mediaManager.convertMediaObjectToLocalUrl(mo, 'audio/*'); | ||
} | ||
// memoize this text conversion, as announcements going to multiple speakers will | ||
// trigger multiple text to speech conversions. | ||
// this is a simple way to prevent thrashing by waiting for the single promise. | ||
var memoizeAudioFetch = memoizeOne(audioFetch); | ||
// castv2 makes the the assumption that protobufjs returns Buffers, which is does not. It returns ArrayBuffers | ||
@@ -39,4 +23,4 @@ // in the quickjs environment. | ||
if (buffer && (buffer.constructor.name === ArrayBuffer.name || buffer.constructor.name === Uint8Array.name)) { | ||
var ret = Buffer.from(buffer); | ||
return ret; | ||
var ret = Buffer.from(buffer); | ||
return ret; | ||
} | ||
@@ -46,3 +30,3 @@ return buffer; | ||
const BufferConcat = Buffer.concat; | ||
Buffer.concat = function(bufs) { | ||
Buffer.concat = function (bufs) { | ||
var copy = []; | ||
@@ -55,22 +39,7 @@ for (var buf of bufs) { | ||
// const Buffer = require('buffer'); | ||
// const BufferConcat = Buffer.concat; | ||
// Buffer.concat = function(buffers) { | ||
// var fixed = []; | ||
// for (let buffer of buffers) { | ||
// if (buffer.constructor.name !== 'Buffer') { | ||
// fixed.push(Buffer.from(buffer)); | ||
// } | ||
// else { | ||
// fixed.push(buffer); | ||
// } | ||
// } | ||
// return BufferConcat(fixed); | ||
// } | ||
class CastDevice extends ScryptedDeviceBase implements Notifier, MediaPlayer, Refresh { | ||
class CastDevice extends ScryptedDeviceBase implements MediaPlayer, Refresh, EngineIOHandler { | ||
provider: CastDeviceProvider; | ||
host: any; | ||
device: Device; | ||
port: number; | ||
@@ -82,7 +51,7 @@ constructor(provider: CastDeviceProvider, nativeId: string) { | ||
currentApp: string; | ||
currentApp: any; | ||
playerPromise: Promise<any>; | ||
connectPlayer(app: string): Promise<any> { | ||
connectPlayer(app: any): Promise<any> { | ||
if (this.playerPromise) { | ||
if (this.currentApp === app) { | ||
if (this.currentApp === app && this.clientPromise) { | ||
return this.playerPromise; | ||
@@ -93,3 +62,7 @@ } | ||
player.removeAllListeners(); | ||
player.close(); | ||
try { | ||
player.close(); | ||
} | ||
catch (e) { | ||
} | ||
}); | ||
@@ -134,42 +107,37 @@ this.playerPromise = undefined; | ||
var promise; | ||
var resolved = false; | ||
return this.clientPromise = promise = new Promise((resolve, reject) => { | ||
var client = new Client(); | ||
client.on('error', err => { | ||
this.log.i(`Client error: ${err.message}`); | ||
const cleanup = () => { | ||
client.removeAllListeners(); | ||
client.close(); | ||
if (this.clientPromise === promise) { | ||
this.clientPromise = undefined; | ||
} | ||
if (!resolved) { | ||
resolved = true; | ||
reject(err); | ||
} | ||
} | ||
client.on('close', cleanup); | ||
client.on('error', err => { | ||
this.log.i(`Client error: ${err.message}`); | ||
cleanup(); | ||
reject(err); | ||
}); | ||
client.on('status', status => { | ||
client.on('status', async status => { | ||
this.log.i(JSON.stringify(status)); | ||
this.joinPlayer() | ||
.catch(() => { }); | ||
try { | ||
await this.joinPlayer(); | ||
} | ||
catch (e) { | ||
} | ||
}) | ||
client.connect(this.host, () => { | ||
this.log.i(`client connected.`); | ||
resolved = true; | ||
resolve(client); | ||
}); | ||
}) | ||
.catch(err => { | ||
this.log.i(`client connect error: ${err.message}`); | ||
this.clientPromise = undefined; | ||
throw err; | ||
}) | ||
} | ||
sendMediaToClient(title, mediaUrl, mimeType, opts?) { | ||
var media = { | ||
tokens = new Map<string, MediaObject>(); | ||
async sendMediaToClient(title: string, mediaUrl: string, mimeType: string, opts?: any) { | ||
var media: any = { | ||
// Here you can plug an URL to any mp4, webm, mp3 or jpg file with the proper contentType. | ||
@@ -189,46 +157,108 @@ contentId: mediaUrl, | ||
customData: { | ||
senderId: pushManager.getSenderId(), | ||
registrationId: pushManager.getRegistrationId(), | ||
} | ||
}; | ||
var app; | ||
if (!mediaUrl.startsWith('http')) { | ||
app = ScryptedMediaReceiver; | ||
opts = opts || { | ||
autoplay: true, | ||
} | ||
else { | ||
app = DefaultMediaReceiver; | ||
const player = await this.connectPlayer(DefaultMediaReceiver) | ||
player.load(media, opts, (err, status) => { | ||
if (err) { | ||
this.log.e(`load error: ${err}`); | ||
return; | ||
} | ||
this.log.i(`media loaded playerState=${status.playerState}`); | ||
}); | ||
} | ||
async load(media: string|MediaObject, options: MediaPlayerOptions) { | ||
// check to see if this is url friendly media. | ||
if (typeof media === 'string') | ||
media = mediaManager.createMediaObject(media, ScryptedMimeTypes.Url); | ||
if (media.mimeType === ScryptedMimeTypes.LocalUrl || | ||
media.mimeType === ScryptedMimeTypes.InsecureLocalUrl || | ||
media.mimeType === ScryptedMimeTypes.Url || | ||
media.mimeType.startsWith('image/') || | ||
media.mimeType.startsWith('video/')) { | ||
// chromecast can handle insecure local urls, but not self signed secure urls. | ||
const url = media.mimeType === ScryptedMimeTypes.InsecureLocalUrl || media.mimeType === ScryptedMimeTypes.LocalUrl | ||
? await mediaManager.convertMediaObjectToInsecureLocalUrl(media, media.mimeType) | ||
: await mediaManager.convertMediaObjectToUrl(media, media.mimeType); | ||
this.sendMediaToClient(options && (options as any).title, | ||
url, | ||
// prefer the provided mime type hint, otherwise infer from url. | ||
options.mimeType || mime.getType(url)); | ||
return; | ||
} | ||
opts = opts || { | ||
// this media object is something weird that can't be handled by a straightforward url. | ||
// try to make a webrtc a/v session to handle it. | ||
const engineio = await endpointManager.getPublicLocalEndpoint(this.nativeId) + 'engine.io/'; | ||
const mo = mediaManager.createMediaObject(engineio, ScryptedMimeTypes.LocalUrl); | ||
const cameraStreamAuthToken = await mediaManager.convertMediaObjectToUrl(mo, ScryptedMimeTypes.LocalUrl); | ||
const token = Math.random().toString(); | ||
this.tokens.set(token, media); | ||
var castMedia: any = { | ||
contentId: cameraStreamAuthToken, | ||
contentType: ScryptedMimeTypes.LocalUrl, | ||
streamType: 'LIVE', | ||
// Title and cover displayed while buffering | ||
metadata: { | ||
type: 0, | ||
metadataType: 0, | ||
title: options?.title || 'Scrypted', | ||
}, | ||
customData: { | ||
token, | ||
} | ||
}; | ||
const opts = { | ||
autoplay: true, | ||
} | ||
this.connectPlayer(app) | ||
.then(player => { | ||
player.load(media, opts, (err, status) => { | ||
if (err) { | ||
this.log.e(`load error: ${err}`); | ||
return; | ||
} | ||
this.log.i(`media loaded playerState=${status.playerState}`); | ||
}); | ||
}) | ||
.catch(err => { | ||
this.log.e(`connect error: ${err}`); | ||
}); | ||
const player = await this.connectPlayer(ScryptedMediaReceiver as any) | ||
player.load(castMedia, opts, (err, status) => { | ||
if (err) { | ||
this.log.e(`load error: ${err}`); | ||
return; | ||
} | ||
this.log.i(`media loaded playerState=${status.playerState}`); | ||
}); | ||
} | ||
load(media, options) { | ||
// the mediaManager is provided by Scrypted and can be used to convert | ||
// MediaObjects into other objects. | ||
// For example, a MediaObject from a RTSP camera can be converted to an externally | ||
// accessible Uri png image using mediaManager.convert. | ||
mediaManager.convertMediaObjectToUrl(media, null) | ||
.then(result => { | ||
this.sendMediaToClient(options && options.title, result, media.mimeType); | ||
}); | ||
async onConnection(request: HttpRequest, webSocketUrl: string) { | ||
const ws = new WebSocket(webSocketUrl); | ||
ws.onmessage = async (message) => { | ||
const token = message.data as string; | ||
const media = this.tokens.get(token); | ||
if (!media) { | ||
ws.close(); | ||
return; | ||
} | ||
const offer = await mediaManager.convertMediaObjectToBuffer( | ||
media, | ||
ScryptedMimeTypes.RTCAVOffer | ||
); | ||
ws.send(offer.toString()); | ||
const answer = await new Promise(resolve => ws.onmessage = (message) => resolve(message.data)); | ||
const mo = mediaManager.createMediaObject(Buffer.from(answer as string), ScryptedMimeTypes.RTCAVAnswer); | ||
mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.RTCAVOffer); | ||
} | ||
} | ||
static CastInactive = new Error('Media player is inactive.'); | ||
mediaPlayerPromise: Promise<any>; | ||
@@ -255,3 +285,3 @@ mediaPlayerStatus: any; | ||
this.updateState(); | ||
reject(CastDevice.CastInactive); | ||
reject(new Error('Media player is inactive.')); | ||
return; | ||
@@ -303,9 +333,9 @@ } | ||
start() { | ||
this.joinPlayer() | ||
.then(player => player.play()); | ||
async start() { | ||
const player = await this.joinPlayer(); | ||
player.start(); | ||
} | ||
pause() { | ||
this.joinPlayer() | ||
.then(player => player.pause()); | ||
async pause() { | ||
const player = await this.joinPlayer(); | ||
player.pause(); | ||
} | ||
@@ -331,3 +361,3 @@ parseState(): MediaPlayerState { | ||
this.stateTimestamp = Date.now(); | ||
const mediaPlayerStatus = this.getMediaStatus(); | ||
const mediaPlayerStatus = this.getMediaStatusInternal(); | ||
switch (mediaPlayerStatus.mediaPlayerState) { | ||
@@ -347,3 +377,6 @@ case MediaPlayerState.Idle: | ||
} | ||
getMediaStatus(): MediaStatus { | ||
async getMediaStatus() { | ||
return this.getMediaStatusInternal(); | ||
} | ||
getMediaStatusInternal(): MediaStatus { | ||
var mediaPlayerState: MediaPlayerState = this.parseState(); | ||
@@ -363,61 +396,33 @@ const media = this.mediaPlayerStatus && this.mediaPlayerStatus.media; | ||
} | ||
seek(milliseconds: number): void { | ||
this.joinPlayer() | ||
.then(player => player.seek(milliseconds)); | ||
async seek(milliseconds: number) { | ||
const player = await this.joinPlayer(); | ||
player.seek(milliseconds); | ||
} | ||
resume(): void { | ||
this.joinPlayer() | ||
.then(player => player.play()); | ||
async resume() { | ||
const player = await this.joinPlayer(); | ||
player.play(); | ||
} | ||
stop() { | ||
this.joinPlayer() | ||
.then(player => player.stop()); | ||
async stop() { | ||
const player = await this.joinPlayer(); | ||
// this would disconnect and leave it in a launched but idle state | ||
// player.stop(); | ||
// this returns to the homescreen | ||
const client = await this.clientPromise; | ||
client.stop(player, () => console.log('stpoped')); | ||
this.clientPromise = null; | ||
} | ||
skipNext(): void { | ||
this.joinPlayer() | ||
.then(player => player.media.sessionRequest({ type: 'QUEUE_NEXT' })); | ||
async skipNext() { | ||
const player = await this.joinPlayer(); | ||
player.media.sessionRequest({ type: 'QUEUE_NEXT' }); | ||
} | ||
skipPrevious(): void { | ||
this.joinPlayer() | ||
.then(player => player.media.sessionRequest({ type: 'QUEUE_PREV' })); | ||
async skipPrevious() { | ||
const player = await this.joinPlayer(); | ||
player.media.sessionRequest({ type: 'QUEUE_PREV' }); | ||
} | ||
sendNotificationToHost(title, body, media, mimeType) { | ||
if (!media || this.type == 'Speaker') { | ||
log.i('fetching audio: ' + body); | ||
memoizeAudioFetch(body) | ||
.then(result => { | ||
const insecure = result.toString().replace('https://', 'http://').replace(':9443', ':10080'); | ||
this.log.i(`sending audio ${insecure}`); | ||
this.sendMediaToClient(title, insecure, 'audio/*'); | ||
}) | ||
.catch(e => { | ||
this.log.e(`error memoizing audio ${e}`); | ||
// do not cache errors. | ||
memoizeAudioFetch = memoizeOne(audioFetch); | ||
}); | ||
return; | ||
} | ||
mediaManager.convertMediaObjectToUrl(media, null) | ||
.then(result => { | ||
this.log.i(`url: ${result}`); | ||
this.sendMediaToClient(title, result, mimeType); | ||
}); | ||
} | ||
sendNotification(title, body, media, mimeType) { | ||
if (!this.device) { | ||
this.provider.search.removeAllListeners(this.id); | ||
this.provider.search.once(this.id, () => this.sendNotificationToHost(title, body, media, mimeType)); | ||
this.provider.discoverDevices(30000); | ||
return; | ||
} | ||
setImmediate(() => this.sendNotificationToHost(title, body, media, mimeType)); | ||
} | ||
getRefreshFrequency(): number { | ||
async getRefreshFrequency(): Promise<number> { | ||
return 60; | ||
} | ||
refresh(refreshInterface: string, userInitiated: boolean): void { | ||
async refresh(refreshInterface: string, userInitiated: boolean) { | ||
this.joinPlayer() | ||
@@ -448,3 +453,10 @@ .catch(() => { }); | ||
var interfaces = ['Notifier', 'MediaPlayer', 'Refresh']; | ||
var interfaces = [ | ||
ScryptedInterface.MediaPlayer, | ||
ScryptedInterface.Refresh, | ||
ScryptedInterface.StartStop, | ||
ScryptedInterface.Pause, | ||
ScryptedInterface.EngineIOHandler, | ||
ScryptedInterface.HttpRequestHandler, | ||
]; | ||
@@ -463,4 +475,6 @@ var device: Device = { | ||
var host = service.addresses[0]; | ||
const host = service.addresses[0]; | ||
const port = service.port; | ||
this.log.i(`found cast device: ${name}`); | ||
@@ -471,2 +485,3 @@ | ||
castDevice.host = host; | ||
castDevice.port = port; | ||
@@ -484,3 +499,3 @@ this.search.emit(id); | ||
discoverDevices(duration: number) { | ||
async discoverDevices(duration: number) { | ||
if (this.searching) { | ||
@@ -487,0 +502,0 @@ return; |
Sorry, the diff of this file is not supported yet
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
2
493
15
5
233406
6
+ Added@scrypted/sdk@file:../../sdk
+ Addedcastv2-promise@^1.0.0
+ Addedmdns@^2.7.2
+ Addedmime@^2.5.2
+ Addedquery-string@^7.0.0
+ Addedbindings@1.2.1(transitive)
+ Addedcastv2-promise@1.0.1(transitive)
+ Addeddecode-uri-component@0.2.2(transitive)
+ Addedfilter-obj@1.1.0(transitive)
+ Addedmdns@2.7.2(transitive)
+ Addedmime@2.6.0(transitive)
+ Addednan@2.22.0(transitive)
+ Addednode-dns-sd@0.4.2(transitive)
+ Addedquery-string@7.1.3(transitive)
+ Addedsplit-on-first@1.1.0(transitive)
+ Addedstrict-uri-encode@2.0.0(transitive)
- Removedcastv2@^0.1.10
- Removedcastv2-client@^1.2.0