node-red-contrib-tts-ultimate 3.1.1 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +152 -68
- package/img/audio-file.svg +8 -0
- package/img/logo-v2.svg +39 -0
- package/package.json +7 -7
- package/scripts/discover-dlna.js +44 -0
- package/scripts/verify-googletranslate-split.js +1 -1
- package/ttsultimate/lib/dlna-discovery.js +98 -0
- package/ttsultimate/lib/dlna-player.js +163 -0
- package/ttsultimate/lib/googlecast-discovery.js +69 -0
- package/ttsultimate/lib/googletranslate.js +140 -0
- package/ttsultimate/ttsultimate-config copy.js +1 -1
- package/ttsultimate/ttsultimate-config.html +3 -3
- package/ttsultimate/ttsultimate-config.js +98 -28
- package/ttsultimate/ttsultimate.html +139 -19
- package/ttsultimate/ttsultimate.js +153 -11
|
@@ -129,9 +129,30 @@
|
|
|
129
129
|
<label for="node-input-playertype"><i class="fa fa-play"></i> Player</label>
|
|
130
130
|
<select id="node-input-playertype">
|
|
131
131
|
<option value="sonos">Sonos</option>
|
|
132
|
+
<option value="googlecast">Google Cast (Chromecast / Nest)</option>
|
|
133
|
+
<option value="dlna">DLNA / UPnP renderer</option>
|
|
132
134
|
<option value="noplayer">No player, only output file name.</option>
|
|
133
135
|
</select>
|
|
134
|
-
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div id="divCastDlna">
|
|
139
|
+
<div class="form-row">
|
|
140
|
+
<label for="node-input-playervolume"><i class="fa fa-volume-up"></i> Volume</label>
|
|
141
|
+
<input type="text" id="node-input-playervolume" style="width:150px" placeholder="0-100">
|
|
142
|
+
</div>
|
|
143
|
+
<div class="form-row" id="playerDiscoverRow">
|
|
144
|
+
<label for="playerDiscoverList"><i class="fa fa-search"></i> Discover</label>
|
|
145
|
+
<select id="playerDiscoverList" style="width:250px"><option value="">-- press Discover --</option></select>
|
|
146
|
+
<a id="playerDiscoverBtn" class="red-ui-button" style="width:auto;"><i class="fa fa-refresh"></i> Discover</a>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="form-row">
|
|
149
|
+
<label for="node-input-playeripaddress"><i class="fa fa-globe"></i> <span id="playeraddresslabel">Device</span></label>
|
|
150
|
+
<input type="text" id="node-input-playeripaddress" style="width:250px">
|
|
151
|
+
</div>
|
|
152
|
+
<div class="form-row">
|
|
153
|
+
<p id="playeraddresshint" style="margin-left:105px;color:#888;"></p>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
135
156
|
|
|
136
157
|
<div id="divSonos">
|
|
137
158
|
<div class="form-row">
|
|
@@ -150,7 +171,9 @@
|
|
|
150
171
|
<label for="node-input-sonosipaddress"><i class="fa fa-globe"></i> Main Sonos Player</label>
|
|
151
172
|
<label style="width:200px;" id="node-input-sonosipaddress">Discovering.... wait...</label>
|
|
152
173
|
</div>
|
|
153
|
-
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div id="divAdditionalPlayers">
|
|
154
177
|
<dt><i class="fa fa-code-fork"></i> Additional players</dt>
|
|
155
178
|
<div class="form-row node-input-rule-container-row">
|
|
156
179
|
<ol id="node-input-rule-container"></ol>
|
|
@@ -197,6 +220,17 @@
|
|
|
197
220
|
required: false,
|
|
198
221
|
type: "text"
|
|
199
222
|
},
|
|
223
|
+
playeripaddress:
|
|
224
|
+
{
|
|
225
|
+
value: "",
|
|
226
|
+
required: false
|
|
227
|
+
},
|
|
228
|
+
playervolume:
|
|
229
|
+
{
|
|
230
|
+
value: "50",
|
|
231
|
+
required: false,
|
|
232
|
+
type: "text"
|
|
233
|
+
},
|
|
200
234
|
sonoshailing:
|
|
201
235
|
{
|
|
202
236
|
value: "Hailing_Hailing.mp3",
|
|
@@ -302,18 +336,91 @@
|
|
|
302
336
|
node.playertype = "sonos";
|
|
303
337
|
$("#node-input-playertype").val("sonos");
|
|
304
338
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
339
|
+
var playerDiscovered = false; // discover only once per shown section, unless refreshed manually
|
|
340
|
+
|
|
341
|
+
// Discovery endpoint used by the per-row "Additional players" list (Sonos / Cast / DLNA).
|
|
342
|
+
function currentPlayerDiscoveryUrl() {
|
|
343
|
+
var pt = $("#node-input-playertype").val();
|
|
344
|
+
if (pt === "googlecast") return 'ttsultimateDiscoverCast';
|
|
345
|
+
if (pt === "dlna") return 'ttsultimateDiscoverDLNA';
|
|
346
|
+
return 'sonosgetAllGroups';
|
|
309
347
|
}
|
|
310
348
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
349
|
+
// Discovery for the MAIN player address field (Google Cast IP or DLNA description URL).
|
|
350
|
+
// All endpoints share the { name, host } shape, so a single code path works.
|
|
351
|
+
function runPlayerDiscovery() {
|
|
352
|
+
var pt = $("#node-input-playertype").val();
|
|
353
|
+
var noun = pt === "googlecast" ? "Cast devices" : "renderers";
|
|
354
|
+
$("#playerDiscoverList").html('<option value="">Discovering... please wait ~4s</option>');
|
|
355
|
+
$.getJSON(currentPlayerDiscoveryUrl(), function (data) {
|
|
356
|
+
$("#playerDiscoverList").empty();
|
|
357
|
+
if (typeof data === "string" || !data || data.length === 0) {
|
|
358
|
+
$("#playerDiscoverList").append($("<option></option>").attr("value", "").text("No " + noun + " found - type manually below"));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
$("#playerDiscoverList").append($("<option></option>").attr("value", "").text("-- select a discovered device --"));
|
|
362
|
+
data.forEach(function (dev) {
|
|
363
|
+
$("#playerDiscoverList").append($("<option></option>").attr("value", dev.host).text(dev.name + " (" + dev.host + ")"));
|
|
364
|
+
});
|
|
365
|
+
}).fail(function () {
|
|
366
|
+
$("#playerDiscoverList").html('<option value="">Discovery failed - type manually below</option>');
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Picking a discovered device fills the main address field.
|
|
371
|
+
$("#playerDiscoverList").change(function () {
|
|
372
|
+
var v = $(this).val();
|
|
373
|
+
if (v) $("#node-input-playeripaddress").val(v);
|
|
374
|
+
});
|
|
375
|
+
$("#playerDiscoverBtn").click(function (e) {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
runPlayerDiscovery();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Re-render the additional players rows so each row re-discovers using the
|
|
381
|
+
// current player type (Sonos groups vs Cast devices), preserving entered hosts.
|
|
382
|
+
function rebuildAdditionalPlayers() {
|
|
383
|
+
try {
|
|
384
|
+
var items = $("#node-input-rule-container").editableList('items');
|
|
385
|
+
var saved = [];
|
|
386
|
+
items.each(function () {
|
|
387
|
+
var r = $(this);
|
|
388
|
+
saved.push({ host: r.find(".rowRulePlayerHost").val(), hostVolumeAdjust: r.find(".rowRulePlayerHostAdjustVolume").val() });
|
|
389
|
+
});
|
|
390
|
+
$("#node-input-rule-container").editableList('empty');
|
|
391
|
+
saved.forEach(function (rule, idx) {
|
|
392
|
+
$("#node-input-rule-container").editableList('addItem', { r: rule, i: idx });
|
|
393
|
+
});
|
|
394
|
+
} catch (e) { }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function refreshPlayerSections() {
|
|
398
|
+
var pt = $("#node-input-playertype").val();
|
|
399
|
+
$("#divSonos").toggle(pt === "sonos");
|
|
400
|
+
$("#divCastDlna").toggle(pt === "googlecast" || pt === "dlna");
|
|
401
|
+
$("#playerDiscoverRow").toggle(pt === "googlecast" || pt === "dlna");
|
|
402
|
+
// Additional players are supported for grouped Sonos and multi-room Google Cast / DLNA.
|
|
403
|
+
$("#divAdditionalPlayers").toggle(pt === "sonos" || pt === "googlecast" || pt === "dlna");
|
|
404
|
+
if (pt === "googlecast") {
|
|
405
|
+
$("#playeraddresslabel").text("Main Chromecast / Nest IP");
|
|
406
|
+
$("#playeraddresshint").text("IP address of the main Google Cast device, e.g. 192.168.1.50");
|
|
407
|
+
} else if (pt === "dlna") {
|
|
408
|
+
$("#playeraddresslabel").text("Renderer description URL");
|
|
409
|
+
$("#playeraddresshint").text("UPnP device description XML URL, e.g. http://192.168.1.50:1400/desc.xml");
|
|
316
410
|
}
|
|
411
|
+
// Auto-discover the first time a discoverable section is shown.
|
|
412
|
+
if ((pt === "googlecast" || pt === "dlna") && !playerDiscovered) {
|
|
413
|
+
playerDiscovered = true;
|
|
414
|
+
runPlayerDiscovery();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
refreshPlayerSections();
|
|
418
|
+
|
|
419
|
+
$("#node-input-playertype").change(function () {
|
|
420
|
+
playerDiscovered = false; // force re-discovery for the newly selected type
|
|
421
|
+
$("#playerDiscoverList").html('<option value="">-- press Discover --</option>');
|
|
422
|
+
rebuildAdditionalPlayers();
|
|
423
|
+
refreshPlayerSections();
|
|
317
424
|
});
|
|
318
425
|
// ###########################
|
|
319
426
|
|
|
@@ -614,7 +721,7 @@
|
|
|
614
721
|
let oAdjustVolume = $('<select/>', { class: "rowRulePlayerHostAdjustVolume", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row);
|
|
615
722
|
for (let index = -100; index < 100; index += 5) {
|
|
616
723
|
let sTesto = "";
|
|
617
|
-
if (index === 0) sTesto = "Same volume as Main
|
|
724
|
+
if (index === 0) sTesto = "Same volume as Main Player";
|
|
618
725
|
if (index < 0) sTesto = "Decrease volume by " + Math.abs(index);
|
|
619
726
|
if (index > 0) sTesto = "Increase volume by " + index;
|
|
620
727
|
oAdjustVolume.append($("<option></option>")
|
|
@@ -648,8 +755,8 @@
|
|
|
648
755
|
resizeRule(container);
|
|
649
756
|
});
|
|
650
757
|
|
|
651
|
-
$.getJSON(
|
|
652
|
-
if (typeof data === "string" && data == "ERRORDISCOVERY") { // 10/04/2020 if error
|
|
758
|
+
$.getJSON(currentPlayerDiscoveryUrl(), (data) => {
|
|
759
|
+
if ((typeof data === "string" && data == "ERRORDISCOVERY") || !data || data.length === 0) { // 10/04/2020 if error/empty discovery, fallback to manual IP input
|
|
653
760
|
// Transform the dropdown to a simple input
|
|
654
761
|
oPlayer.remove();
|
|
655
762
|
oPlayer = $('<input/>', { class: "rowRulePlayerHost", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row);
|
|
@@ -719,7 +826,7 @@
|
|
|
719
826
|
</script>
|
|
720
827
|
|
|
721
828
|
<script type="text/markdown" data-help-name="ttsultimate">
|
|
722
|
-
<p>This node transforms a text into a speech audio that you can hear natively via
|
|
829
|
+
<p>This node transforms a text into a speech audio that you can hear natively via Sonos speakers, Google Cast devices (Chromecast / Google Nest), generic DLNA/UPnP renderers, or save it to a file.</p>
|
|
723
830
|
|
|
724
831
|
**General**
|
|
725
832
|
|Property|Description|
|
|
@@ -728,12 +835,14 @@
|
|
|
728
835
|
| Voice | Select your preferred voice. The list depends on the selected TTS engine (Google / ElevenLabs / Voice.ai). Google service without authentication has a limited set of voices. |
|
|
729
836
|
| Hailing | Before the first TTS message of the message queues, the node will play an "hailing" sound. You can select the hailing, upload your own, or totally disable it. |
|
|
730
837
|
| Upload hail | It allows you to upload your own hailing file. |
|
|
731
|
-
| Player | Select the player
|
|
838
|
+
| Player | Select the player: **Sonos**, **Google Cast** (Chromecast / Google Nest), **DLNA / UPnP renderer**, or **No player**. If you select No player, only output file name, the node will output a msg with an additional property filesArray, containing an array of all mp3 files ready to be played by third party nodes. Please see below the OUTPUT MESSAGES FROM THE NODE section. |
|
|
732
839
|
| Volume | Set the preferred TTS volume, from "0" to "100" (can be overridden by passing msg.volume = "40"; to the node). |
|
|
733
|
-
| Unmute | Unmute the main and the addotional players, then restore the previous mute state once finished. (Can be overridden by passing msg.unmute = true; to the node). |
|
|
734
|
-
| Resume | If music was playing prior to TTS messages, the node will try to resume it, but can fail in some cases. Enabla this option to avoid resuming music after TTS message. |
|
|
840
|
+
| Unmute | (Sonos only) Unmute the main and the addotional players, then restore the previous mute state once finished. (Can be overridden by passing msg.unmute = true; to the node). |
|
|
841
|
+
| Resume | (Sonos only) If music was playing prior to TTS messages, the node will try to resume it, but can fail in some cases. Enabla this option to avoid resuming music after TTS message. |
|
|
735
842
|
| Main Sonos Player | Select your Sonos primary player. (It's strongly suggested to set a fixed IP for this player; you can reserve an IP using the DHCP Reservation function of your router/firewall's DHCP Server). It's possibile to group players, so your announcement can be played on all selected players. For this to happen, you need to select your primary coordinator player. All other players will be then controlled by this coordinator. |
|
|
736
|
-
|
|
|
843
|
+
| Main Chromecast / Nest IP | (Google Cast) IP address of the main Google Cast device (e.g. 192.168.1.50). Press **Discover** to auto-detect the Cast devices on your network and pick one from the list. |
|
|
844
|
+
| Renderer description URL | (DLNA) The full UPnP device description XML URL of the renderer (e.g. http://192.168.1.50:49153/nmrDescription.xml), **not** just the IP. Press **Discover** to auto-detect the DLNA renderers on your network and pick one from the list. |
|
|
845
|
+
| Additional Players | (Sonos / Google Cast / DLNA) Add additional players so your announcement is played on all of them at once. For Sonos they are grouped to the Main Player coordinator; for Google Cast and DLNA the TTS is played on every device in parallel. Use the "ADD" button below the list; for each additional player you can adjust its volume relative to the main volume (-100/+100). |
|
|
737
846
|
|
|
738
847
|
**Google TTS Engines specific options**
|
|
739
848
|
|Property|Description|
|
|
@@ -754,6 +863,17 @@
|
|
|
754
863
|
|
|
755
864
|
<br/>
|
|
756
865
|
|
|
866
|
+
**Players**
|
|
867
|
+
|
|
868
|
+
| Player | Notes |
|
|
869
|
+
|--|--|
|
|
870
|
+
| Sonos | Native control: grouping, volume, mute/unmute and music resume after the announcement. |
|
|
871
|
+
| Google Cast | Chromecast and Google Nest devices, discovered via mDNS. Set the main device IP and, optionally, additional devices for synchronized multi-room playback. |
|
|
872
|
+
| DLNA / UPnP renderer | Generic UPnP MediaRenderers (smart TVs, AV receivers, etc.), discovered via SSDP. Works with renderers that nest a MediaRenderer sub-device too (e.g. Sonos). Requires the full device description XML URL. |
|
|
873
|
+
| No player | Only generates the mp3 file(s) and outputs their paths in filesArray, to be played by third party nodes. |
|
|
874
|
+
|
|
875
|
+
For Google Cast and DLNA the device fetches the generated mp3 over HTTP from Node-RED, so the Node-RED host/port (default 1980) must be reachable from the player and on the same subnet (mDNS/SSDP discovery does not cross subnets/VLANs).
|
|
876
|
+
|
|
757
877
|
### Inputs
|
|
758
878
|
|
|
759
879
|
: volume (string) : Set the volume (values between "0" and "100").
|
|
@@ -77,6 +77,8 @@ module.exports = function (RED) {
|
|
|
77
77
|
node.currentMSGbeingSpoken = {}; // Stores the current message being spoken
|
|
78
78
|
node.sonosCoordinatorPreviousVolumeSetByApp = 0; // 05/07/2021 stores the main payer volume set by the sonos app
|
|
79
79
|
node.playertype = config.playertype === undefined ? "sonos" : config.playertype; // 20/09/2021 Player type
|
|
80
|
+
node.playeripaddress = (config.playeripaddress || "").trim(); // 06/2026 Chromecast/Nest IP or DLNA/UPnP renderer description XML URL
|
|
81
|
+
node.playervolume = config.playervolume === undefined ? "50" : config.playervolume; // 06/2026 Volume (0-100) for Google Cast / DLNA players
|
|
80
82
|
node.speakingpitch = config.speakingpitch === undefined ? "0" : config.speakingpitch; // 21/09/2021 AudioConfig speakingpitch
|
|
81
83
|
node.speakingrate = config.speakingrate === undefined ? "1" : config.speakingrate; // 21/09/2021 AudioConfig speakingrate
|
|
82
84
|
node.unmuteIfMuted = config.unmuteIfMuted === undefined ? false : config.unmuteIfMuted; // 21/10/2021 Unmute if previiously muted.
|
|
@@ -422,7 +424,8 @@ module.exports = function (RED) {
|
|
|
422
424
|
}
|
|
423
425
|
// 27/11/2019 Start the connection healty check
|
|
424
426
|
node.oTimerSonosConnectionCheck = setTimeout(function () { node.CheckSonosConnection(); }, 5000);
|
|
425
|
-
} else
|
|
427
|
+
} else {
|
|
428
|
+
// 06/2026 noplayer, googlecast and dlna do not use the Sonos connection healthcheck.
|
|
426
429
|
node.msg.connectionerror = false;
|
|
427
430
|
}
|
|
428
431
|
|
|
@@ -530,7 +533,7 @@ module.exports = function (RED) {
|
|
|
530
533
|
node.server.whoIsUsingTheServer = node.id; // Signal to other ttsultimate node, that i'm using the Sonos device
|
|
531
534
|
try {
|
|
532
535
|
|
|
533
|
-
if (node.playertype
|
|
536
|
+
if (node.playertype === "sonos") {
|
|
534
537
|
// Get the current music queue, if one
|
|
535
538
|
var oCurTrack = null;
|
|
536
539
|
try {
|
|
@@ -909,6 +912,49 @@ module.exports = function (RED) {
|
|
|
909
912
|
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error ' + msg + " " + error.message });
|
|
910
913
|
}
|
|
911
914
|
|
|
915
|
+
} else if (node.playertype === "googlecast" || node.playertype === "dlna") {
|
|
916
|
+
|
|
917
|
+
// 06/2026 Google Cast (Chromecast/Nest) and generic DLNA/UPnP renderers.
|
|
918
|
+
// Both just need the public HTTP URL of the file (same conversion as Sonos).
|
|
919
|
+
if (!node.sFileToBePlayed.toLowerCase().startsWith("http://") && !node.sFileToBePlayed.toLowerCase().startsWith("https://")) {
|
|
920
|
+
node.sFileToBePlayed = node.sNoderedURL + "/tts/tts.mp3?f=" + encodeURIComponent(node.sFileToBePlayed);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Volume (0-100): a per-message volume overrides the configured one
|
|
924
|
+
let volTemp = node.currentMSGbeingSpoken.hasOwnProperty("volume") ? Number(node.currentMSGbeingSpoken.volume) : Number(node.playervolume);
|
|
925
|
+
if (isNaN(volTemp)) volTemp = 50;
|
|
926
|
+
if (volTemp < 0) volTemp = 0;
|
|
927
|
+
if (volTemp > 100) volTemp = 100;
|
|
928
|
+
|
|
929
|
+
node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + formatAudioSourceTag(audioSource) + msg });
|
|
930
|
+
try {
|
|
931
|
+
// Play on the main player + all additional players (reusing the Sonos additional
|
|
932
|
+
// players list), in parallel for multi-room playback. Works for Cast and DLNA.
|
|
933
|
+
const playFn = node.playertype === "googlecast" ? playGoogleCastSync : playDLNASync;
|
|
934
|
+
const targets = [{ host: node.playeripaddress, vol: volTemp }];
|
|
935
|
+
for (let ci = 0; ci < node.rules.length; ci++) {
|
|
936
|
+
const r = node.rules[ci];
|
|
937
|
+
if (r && r.host) {
|
|
938
|
+
let v = volTemp + Number(r.hostVolumeAdjust || 0);
|
|
939
|
+
if (isNaN(v)) v = volTemp;
|
|
940
|
+
if (v < 0) v = 0;
|
|
941
|
+
if (v > 100) v = 100;
|
|
942
|
+
targets.push({ host: r.host, vol: v });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const results = await Promise.allSettled(targets.map((t) => playFn(t.host, node.sFileToBePlayed, t.vol)));
|
|
946
|
+
const failures = results.filter((rr) => rr.status === "rejected");
|
|
947
|
+
if (failures.length === results.length) {
|
|
948
|
+
throw new Error("all " + node.playertype + " targets failed: " + failures.map((f) => f.reason && f.reason.message).join("; "));
|
|
949
|
+
} else if (failures.length > 0) {
|
|
950
|
+
RED.log.warn("ttsultimate: Some " + node.playertype + " targets failed: " + failures.map((f) => f.reason && f.reason.message).join("; "));
|
|
951
|
+
}
|
|
952
|
+
node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'End playing ' + msg });
|
|
953
|
+
} catch (error) {
|
|
954
|
+
RED.log.error("ttsultimate: Error playing on " + node.playertype + " for " + node.sFileToBePlayed + " " + error.message);
|
|
955
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error ' + msg + " " + error.message });
|
|
956
|
+
}
|
|
957
|
+
|
|
912
958
|
} else if (node.playertype === "noplayer") {
|
|
913
959
|
// Output only the filename
|
|
914
960
|
if (noPlayerFileArray === undefined || noPlayerFileArray === null) var noPlayerFileArray = [];
|
|
@@ -977,17 +1023,15 @@ module.exports = function (RED) {
|
|
|
977
1023
|
node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore
|
|
978
1024
|
}, 500)
|
|
979
1025
|
|
|
980
|
-
} else
|
|
981
|
-
// End task
|
|
982
|
-
//
|
|
1026
|
+
} else {
|
|
1027
|
+
// End task for noplayer, googlecast and dlna.
|
|
1028
|
+
// For "noplayer" also output the array of generated files (undefined for the other types).
|
|
983
1029
|
// Signal end playing
|
|
984
|
-
//let t = setTimeout(() => {
|
|
985
1030
|
node.msg.completed = true;
|
|
986
1031
|
node.currentMSGbeingSpoken = {};
|
|
987
1032
|
node.send([{ passThroughMessage: node.passThroughMessage, payload: node.msg.completed, filesArray: noPlayerFileArray }, null]);
|
|
988
1033
|
node.bBusyPlayingQueue = false
|
|
989
1034
|
node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore
|
|
990
|
-
//}, 1000)
|
|
991
1035
|
}
|
|
992
1036
|
|
|
993
1037
|
} catch (error) {
|
|
@@ -1055,9 +1099,11 @@ module.exports = function (RED) {
|
|
|
1055
1099
|
// 27/01/2021 Stop whatever in play.
|
|
1056
1100
|
if (msg.hasOwnProperty("stop") && msg.stop === true) {
|
|
1057
1101
|
node.flushQueue();
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1102
|
+
if (node.playertype === "sonos") {
|
|
1103
|
+
try {
|
|
1104
|
+
STOPSync().catch(() => { });
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
}
|
|
1061
1107
|
}
|
|
1062
1108
|
node.setNodeStatus({ fill: 'red', shape: 'ring', text: "Forced stop." });
|
|
1063
1109
|
return;
|
|
@@ -1133,7 +1179,7 @@ module.exports = function (RED) {
|
|
|
1133
1179
|
// There is already a priority message being spoken, do nothing
|
|
1134
1180
|
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'There is already a priority message being spoken...queuing' });
|
|
1135
1181
|
} else {
|
|
1136
|
-
if (node.playertype
|
|
1182
|
+
if (node.playertype === 'sonos') {
|
|
1137
1183
|
node.SonosClient.stop().then(result => {
|
|
1138
1184
|
node.bTimeOutPlay = true;
|
|
1139
1185
|
node.currentMSGbeingSpoken = msg; // Set immediately, otherwise if comes new flow messages, currentMSGbeingSpoken is too old.
|
|
@@ -1376,6 +1422,102 @@ module.exports = function (RED) {
|
|
|
1376
1422
|
}
|
|
1377
1423
|
}
|
|
1378
1424
|
|
|
1425
|
+
// 06/2026 Google Cast (Chromecast / Google Nest) playback.
|
|
1426
|
+
// Resolves when playback finishes (IDLE after PLAYING) or rejects on error/timeout.
|
|
1427
|
+
function playGoogleCastSync(host, url, volume0to100) {
|
|
1428
|
+
return new Promise((resolve, reject) => {
|
|
1429
|
+
if (!host) { reject(new Error("Google Cast: no device IP address configured")); return; }
|
|
1430
|
+
let CastClient, DefaultMediaReceiver;
|
|
1431
|
+
try {
|
|
1432
|
+
CastClient = require("castv2-client").Client;
|
|
1433
|
+
DefaultMediaReceiver = require("castv2-client").DefaultMediaReceiver;
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
reject(new Error("castv2-client module not available: " + error.message));
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const client = new CastClient();
|
|
1439
|
+
let settled = false;
|
|
1440
|
+
const cleanup = () => { try { client.close(); } catch (e) { } };
|
|
1441
|
+
const clearTimers = () => { clearTimeout(timeout); clearTimeout(startTimer); };
|
|
1442
|
+
const fail = (err) => { if (settled) return; settled = true; clearTimers(); cleanup(); reject(err); };
|
|
1443
|
+
const done = () => { if (settled) return; settled = true; clearTimers(); cleanup(); resolve(); };
|
|
1444
|
+
const timeout = setTimeout(() => { fail(new Error("Google Cast: playback timeout")); }, 60000 * 10); // 10 minutes, like Sonos
|
|
1445
|
+
// Fail fast if playback never starts (e.g. wrong IP on an unreachable/black-holed host).
|
|
1446
|
+
const startTimer = setTimeout(() => { fail(new Error("Google Cast: device did not start playing within 45s (check IP/network)")); }, 45000);
|
|
1447
|
+
|
|
1448
|
+
client.on("error", (err) => fail(err));
|
|
1449
|
+
client.connect(host, () => {
|
|
1450
|
+
// Volume is 0..1 on Cast
|
|
1451
|
+
try { client.setVolume({ level: Math.max(0, Math.min(1, volume0to100 / 100)) }, () => { }); } catch (e) { }
|
|
1452
|
+
client.launch(DefaultMediaReceiver, (err, player) => {
|
|
1453
|
+
if (err) { fail(err); return; }
|
|
1454
|
+
let started = false;
|
|
1455
|
+
player.on("status", (status) => {
|
|
1456
|
+
if (!status) return;
|
|
1457
|
+
if (status.playerState === "PLAYING") { started = true; clearTimeout(startTimer); }
|
|
1458
|
+
// IDLE after playback started means the track has finished.
|
|
1459
|
+
if (started && status.playerState === "IDLE") done();
|
|
1460
|
+
});
|
|
1461
|
+
const media = { contentId: url, contentType: "audio/mpeg", streamType: "BUFFERED" };
|
|
1462
|
+
player.load(media, { autoplay: true }, (err2) => {
|
|
1463
|
+
if (err2) fail(err2);
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// 06/2026 Generic DLNA / UPnP media renderer playback.
|
|
1471
|
+
// descriptionUrl is the renderer device description XML URL (e.g. http://192.168.1.50:1400/xml/device_description.xml).
|
|
1472
|
+
// Uses a native client that finds the AVTransport/RenderingControl services anywhere in the
|
|
1473
|
+
// device tree (so it also works with renderers that nest a MediaRenderer sub-device, e.g. Sonos).
|
|
1474
|
+
// Resolves when playback finishes (STOPPED after PLAYING) or rejects on error/timeout.
|
|
1475
|
+
function playDLNASync(descriptionUrl, url, volume0to100) {
|
|
1476
|
+
return new Promise((resolve, reject) => {
|
|
1477
|
+
if (!descriptionUrl) { reject(new Error("DLNA: no renderer description URL configured")); return; }
|
|
1478
|
+
let dlnaPlayer;
|
|
1479
|
+
try {
|
|
1480
|
+
dlnaPlayer = require("./lib/dlna-player");
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
reject(new Error("dlna-player module not available: " + error.message));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
let settled = false;
|
|
1486
|
+
const clearTimers = () => { clearTimeout(timeout); clearTimeout(startTimer); };
|
|
1487
|
+
const fail = (err) => { if (settled) return; settled = true; clearTimers(); reject(err); };
|
|
1488
|
+
const done = () => { if (settled) return; settled = true; clearTimers(); resolve(); };
|
|
1489
|
+
const timeout = setTimeout(() => { fail(new Error("DLNA: playback timeout")); }, 60000 * 10); // 10 minutes, like Sonos
|
|
1490
|
+
// Fail fast if playback never starts (e.g. wrong/unreachable renderer URL).
|
|
1491
|
+
const startTimer = setTimeout(() => { fail(new Error("DLNA: renderer did not start playing within 45s (check description URL/network)")); }, 45000);
|
|
1492
|
+
|
|
1493
|
+
const player = dlnaPlayer.createPlayer(descriptionUrl);
|
|
1494
|
+
|
|
1495
|
+
(async () => {
|
|
1496
|
+
try {
|
|
1497
|
+
// Best-effort volume first (ignored by renderers without RenderingControl).
|
|
1498
|
+
try { await player.setVolume(volume0to100); } catch (e) { }
|
|
1499
|
+
await player.setAVTransportURI(url, "audio/mpeg");
|
|
1500
|
+
await player.play();
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
fail(error);
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
// Poll the transport state (UPnP eventing is not implemented by all renderers).
|
|
1506
|
+
let started = false;
|
|
1507
|
+
const poll = async () => {
|
|
1508
|
+
if (settled) return;
|
|
1509
|
+
let state = "";
|
|
1510
|
+
try { state = await player.getTransportState(); } catch (e) { /* transient, keep polling */ }
|
|
1511
|
+
if (settled) return;
|
|
1512
|
+
if (state === "PLAYING" || state === "TRANSITIONING") { started = true; clearTimeout(startTimer); }
|
|
1513
|
+
if (started && (state === "STOPPED" || state === "NO_MEDIA_PRESENT" || state === "PAUSED_PLAYBACK")) { done(); return; }
|
|
1514
|
+
setTimeout(poll, 1000);
|
|
1515
|
+
};
|
|
1516
|
+
setTimeout(poll, 1000);
|
|
1517
|
+
})();
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1379
1521
|
// 04/01/2021 hashing filename to avoid issues with long filenames.
|
|
1380
1522
|
function getFilename(_text, _params) {
|
|
1381
1523
|
let sTextToBeHashed = _text.concat(JSON.stringify(_params));
|