homebridge-sonos-scenes 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/assets/homebridge-sonos-scenes-icon-512x512.png" alt="homebridge-sonos-scenes icon" width="164">
2
+ <img src="docs/assets/icon-512.png" alt="homebridge-sonos-scenes icon" width="164">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">homebridge-sonos-scenes</h1>
@@ -8,6 +8,9 @@
8
8
 
9
9
  `homebridge-sonos-scenes` is a Homebridge plugin scaffold for Sonos workflow scenes.
10
10
 
11
+ > [!IMPORTANT]
12
+ > This project is in an active early-testing phase. The local-first scene workflow is usable and published to npm, but cloud-backed Sonos playback is still planned work and some edge cases are still being hardened. If you try the plugin, please share bugs, UI feedback, and Sonos compatibility notes in [GitHub Issues](https://github.com/applemanj/homebridge-sonos-scenes/issues). Real-world feedback is especially helpful right now.
13
+
11
14
  The goal is not general Sonos control. The goal is a clean way to trigger multi-step Sonos workflows from Apple Home, such as:
12
15
 
13
16
  - grouping rooms around a coordinator,
Binary file
@@ -307,12 +307,22 @@
307
307
  display: block;
308
308
  }
309
309
 
310
+ .scene-member-pill.selected {
311
+ border-color: rgba(10, 132, 255, 0.34);
312
+ background: rgba(10, 132, 255, 0.07);
313
+ box-shadow: inset 0 0 0 1px rgba(10, 132, 255, 0.08);
314
+ }
315
+
310
316
  .scene-member-row {
311
317
  display: flex;
312
318
  align-items: flex-start;
313
319
  gap: 0.65rem;
314
320
  }
315
321
 
322
+ .scene-member-toggle {
323
+ cursor: pointer;
324
+ }
325
+
316
326
  .scene-member-pill .form-check-input {
317
327
  margin-top: 0.2rem;
318
328
  flex: 0 0 auto;
@@ -336,6 +346,52 @@
336
346
  line-height: 1.35;
337
347
  }
338
348
 
349
+ .scene-member-volume {
350
+ display: grid;
351
+ gap: 0.35rem;
352
+ margin-top: 0.65rem;
353
+ padding-top: 0.65rem;
354
+ border-top: 1px solid rgba(22, 32, 51, 0.08);
355
+ }
356
+
357
+ .scene-member-volume-label {
358
+ color: #556579;
359
+ font-size: 0.78rem;
360
+ font-weight: 600;
361
+ line-height: 1.3;
362
+ }
363
+
364
+ .scene-member-volume-input {
365
+ max-width: 132px;
366
+ }
367
+
368
+ .scene-member-flags {
369
+ display: flex;
370
+ flex-wrap: wrap;
371
+ gap: 0.35rem;
372
+ margin-top: 0.2rem;
373
+ }
374
+
375
+ .scene-member-flag {
376
+ display: inline-flex;
377
+ align-items: center;
378
+ padding: 0.15rem 0.45rem;
379
+ border-radius: 999px;
380
+ font-size: 0.72rem;
381
+ font-weight: 600;
382
+ line-height: 1.2;
383
+ }
384
+
385
+ .scene-member-flag.source {
386
+ background: rgba(10, 132, 255, 0.12);
387
+ color: #0a84ff;
388
+ }
389
+
390
+ .scene-member-flag.primary {
391
+ background: rgba(168, 85, 247, 0.16);
392
+ color: #7c3aed;
393
+ }
394
+
339
395
  .scene-log {
340
396
  max-height: 280px;
341
397
  overflow: auto;
@@ -602,7 +658,7 @@
602
658
  <input class="form-control" id="scene-name" type="text">
603
659
  </div>
604
660
  <input id="scene-id" type="hidden">
605
- <div class="col-md-6">
661
+ <div class="col-12">
606
662
  <label class="form-label" for="household-select">
607
663
  <span class="scene-label-row">
608
664
  <span>Household</span>
@@ -614,25 +670,13 @@
614
670
  </label>
615
671
  <select class="form-select" id="household-select"></select>
616
672
  </div>
617
- <div class="col-md-6">
618
- <label class="form-label" for="coordinator-select">
619
- <span class="scene-label-row">
620
- <span>Coordinator Room</span>
621
- <span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Coordinator Room">
622
- ?
623
- <span class="scene-tooltip">The main room that anchors the group and receives the source change first. Choose the room that should lead the scene.</span>
624
- </span>
625
- </span>
626
- </label>
627
- <select class="form-select" id="coordinator-select"></select>
628
- </div>
629
673
  <div class="col-12">
630
674
  <label class="form-label">
631
675
  <span class="scene-label-row">
632
- <span>Group Members</span>
676
+ <span>Scene Rooms</span>
633
677
  <span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Group Members">
634
678
  ?
635
- <span class="scene-tooltip">Additional rooms to join the coordinator when the scene runs. Leave all unchecked for a single-room scene.</span>
679
+ <span class="scene-tooltip">Pick the rooms that should be part of this scene. For line-in and TV scenes, the source room is included automatically. One selected room becomes the internal lead room behind the scenes.</span>
636
680
  </span>
637
681
  </span>
638
682
  </label>
@@ -644,7 +688,7 @@
644
688
  <span>Source Kind</span>
645
689
  <span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Source Kind">
646
690
  ?
647
- <span class="scene-tooltip">The type of thing this scene should load. The list changes based on what the coordinator room supports, such as favorites, line-in, or TV.</span>
691
+ <span class="scene-tooltip">The type of thing this scene should load. The list is based on what is available in this household, such as favorites, line-in, or TV.</span>
648
692
  </span>
649
693
  </span>
650
694
  </label>
@@ -665,10 +709,10 @@
665
709
  <div class="col-md-3">
666
710
  <label class="form-label" for="coordinator-volume">
667
711
  <span class="scene-label-row">
668
- <span>Coordinator Volume</span>
712
+ <span>Lead Room Volume</span>
669
713
  <span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Coordinator Volume">
670
714
  ?
671
- <span class="scene-tooltip">Optional starting volume for the coordinator room. Leave this blank to keep whatever volume the room already has.</span>
715
+ <span class="scene-tooltip">Optional starting volume for the scene's lead room. Leave this blank to keep whatever volume that room already has.</span>
672
716
  </span>
673
717
  </span>
674
718
  </label>
@@ -737,18 +781,6 @@
737
781
  <option value="ungroup">Ungroup</option>
738
782
  </select>
739
783
  </div>
740
- <div class="col-12">
741
- <label class="form-label">
742
- <span class="scene-label-row">
743
- <span>Per-room Volumes</span>
744
- <span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Per-room Volumes">
745
- ?
746
- <span class="scene-tooltip">Optional volume overrides for specific member rooms. Leave a room blank here to keep its current volume or let the group volume behavior apply.</span>
747
- </span>
748
- </span>
749
- </label>
750
- <div id="volume-list" class="scene-member-grid"></div>
751
- </div>
752
784
  </div>
753
785
  </div>
754
786
  </section>
@@ -794,7 +826,6 @@
794
826
  sceneName: document.getElementById("scene-name"),
795
827
  sceneId: document.getElementById("scene-id"),
796
828
  householdSelect: document.getElementById("household-select"),
797
- coordinatorSelect: document.getElementById("coordinator-select"),
798
829
  memberList: document.getElementById("member-list"),
799
830
  sourceKind: document.getElementById("source-kind"),
800
831
  sourceTarget: document.getElementById("source-target"),
@@ -804,7 +835,6 @@
804
835
  retryDelayMs: document.getElementById("retry-delay-ms"),
805
836
  autoResetMs: document.getElementById("auto-reset-ms"),
806
837
  offBehavior: document.getElementById("off-behavior"),
807
- volumeList: document.getElementById("volume-list"),
808
838
  validationOutput: document.getElementById("validation-output"),
809
839
  logOutput: document.getElementById("log-output"),
810
840
  discoverySummary: document.getElementById("discovery-summary"),
@@ -942,22 +972,52 @@
942
972
  return (household?.favorites || []).filter((favorite) => favorite.playable !== false);
943
973
  }
944
974
 
975
+ function getSelectedSceneRoomIdsFromDraft() {
976
+ const selected = Array.from(new Set([state.draft.coordinatorPlayerId, ...(state.draft.memberPlayerIds || [])].filter(Boolean)));
977
+ const source = state.draft.source;
978
+ if ((source?.kind === "line_in" || source?.kind === "tv") && source.deviceId) {
979
+ if (!selected.includes(source.deviceId)) {
980
+ selected.unshift(source.deviceId);
981
+ }
982
+ }
983
+ return selected;
984
+ }
985
+
986
+ function resolveCoordinatorPlayerId(selectedRoomIds, source) {
987
+ const uniqueRooms = Array.from(new Set((selectedRoomIds || []).filter(Boolean)));
988
+
989
+ if ((source?.kind === "line_in" || source?.kind === "tv") && source.deviceId) {
990
+ return source.deviceId;
991
+ }
992
+
993
+ if (state.draft?.coordinatorPlayerId && uniqueRooms.includes(state.draft.coordinatorPlayerId)) {
994
+ return state.draft.coordinatorPlayerId;
995
+ }
996
+
997
+ return uniqueRooms[0] || "";
998
+ }
999
+
945
1000
  function serializeDraft() {
946
1001
  serializeCloudConfig();
947
1002
  const draft = clone(state.draft);
948
1003
  draft.name = elements.sceneName.value.trim() || "New Scene";
949
1004
  draft.householdId = elements.householdSelect.value;
950
- draft.coordinatorPlayerId = elements.coordinatorSelect.value;
951
- draft.memberPlayerIds = Array.from(elements.memberList.querySelectorAll("input[type='checkbox']:checked")).map((checkbox) => checkbox.value);
952
1005
  draft.source = buildSourcePayload();
1006
+ const selectedRoomIds = Array.from(elements.memberList.querySelectorAll("input[type='checkbox']:checked")).map((checkbox) => checkbox.value);
1007
+ draft.coordinatorPlayerId = resolveCoordinatorPlayerId(selectedRoomIds, draft.source);
1008
+ if ((draft.source?.kind === "line_in" || draft.source?.kind === "tv") && draft.source.deviceId && !selectedRoomIds.includes(draft.source.deviceId)) {
1009
+ selectedRoomIds.unshift(draft.source.deviceId);
1010
+ }
1011
+ draft.memberPlayerIds = selectedRoomIds.filter((playerId) => playerId !== draft.coordinatorPlayerId);
953
1012
  draft.coordinatorVolume = elements.coordinatorVolume.value === "" ? undefined : Number(elements.coordinatorVolume.value);
954
1013
  draft.settleMs = Number(elements.settleMs.value || 0);
955
1014
  draft.retryCount = Number(elements.retryCount.value || 0);
956
1015
  draft.retryDelayMs = Number(elements.retryDelayMs.value || 0);
957
1016
  draft.autoResetMs = Number(elements.autoResetMs.value || 0);
958
1017
  draft.offBehavior = { kind: elements.offBehavior.value };
959
- draft.playerVolumes = Array.from(elements.volumeList.querySelectorAll("input[data-player-id]"))
1018
+ draft.playerVolumes = Array.from(elements.memberList.querySelectorAll("input[data-player-id]"))
960
1019
  .filter((input) => input.value !== "")
1020
+ .filter((input) => selectedRoomIds.includes(input.dataset.playerId) && input.dataset.playerId !== draft.coordinatorPlayerId)
961
1021
  .map((input) => ({
962
1022
  playerId: input.dataset.playerId,
963
1023
  volume: Number(input.value),
@@ -1111,46 +1171,64 @@
1111
1171
  .join("");
1112
1172
  }
1113
1173
 
1114
- function renderCoordinatorOptions() {
1115
- const household = getActiveHousehold();
1116
- const players = household?.players || [];
1117
- const currentCoordinator = players.some((player) => player.id === state.draft.coordinatorPlayerId)
1118
- ? state.draft.coordinatorPlayerId
1119
- : players[0]?.id || "";
1120
- state.draft.coordinatorPlayerId = currentCoordinator;
1121
- elements.coordinatorSelect.innerHTML = players
1122
- .map((player) => `<option value="${player.id}" ${player.id === currentCoordinator ? "selected" : ""}>${player.name}</option>`)
1123
- .join("");
1124
- }
1125
-
1126
1174
  function renderMemberOptions() {
1127
1175
  const household = getActiveHousehold();
1128
- const coordinatorId = state.draft.coordinatorPlayerId;
1129
- const selected = new Set(state.draft.memberPlayerIds.filter((playerId) => playerId !== coordinatorId));
1130
- const players = (household?.players || []).filter((player) => player.id !== coordinatorId);
1176
+ const selectedRoomIds = getSelectedSceneRoomIdsFromDraft();
1177
+ const selected = new Set(selectedRoomIds);
1178
+ const coordinatorId = resolveCoordinatorPlayerId(selectedRoomIds, state.draft.source);
1179
+ state.draft.coordinatorPlayerId = coordinatorId;
1180
+ state.draft.memberPlayerIds = selectedRoomIds.filter((playerId) => playerId !== coordinatorId);
1181
+ const values = new Map((state.draft.playerVolumes || []).map((entry) => [entry.playerId, entry.volume]));
1182
+ const forcedSourceId = (state.draft.source?.kind === "line_in" || state.draft.source?.kind === "tv")
1183
+ ? state.draft.source.deviceId
1184
+ : "";
1185
+ const players = household?.players || [];
1131
1186
  elements.memberList.innerHTML = players.length === 0
1132
- ? `<div class="scene-help">No additional players are available for grouping.</div>`
1187
+ ? `<div class="scene-help">No rooms are available in this household.</div>`
1133
1188
  : players
1134
1189
  .map(
1135
- (player) => `
1136
- <label class="scene-member-pill">
1137
- <span class="scene-member-row">
1138
- <input class="form-check-input" type="checkbox" value="${player.id}" ${selected.has(player.id) ? "checked" : ""}>
1190
+ (player) => {
1191
+ const isForcedSource = forcedSourceId === player.id;
1192
+ const isSelected = isForcedSource || selected.has(player.id);
1193
+ const flags = [
1194
+ isForcedSource ? `<span class="scene-member-flag source">Source Room</span>` : "",
1195
+ isSelected && coordinatorId === player.id ? `<span class="scene-member-flag primary">Lead Room</span>` : "",
1196
+ ].filter(Boolean).join("");
1197
+ return `
1198
+ <div class="scene-member-pill ${isSelected ? "selected" : ""}">
1199
+ <label class="scene-member-row scene-member-toggle">
1200
+ <input class="form-check-input" data-member-checkbox type="checkbox" value="${player.id}" ${isSelected ? "checked" : ""} ${isForcedSource ? "disabled" : ""}>
1139
1201
  <span class="scene-member-copy">
1140
1202
  <span class="scene-member-title">${player.name}</span>
1141
1203
  <span class="scene-member-meta">${player.model || "Unknown model"} - ${((player.sourceOptions || []).join(", ") || "favorite").replaceAll("_", " ")}</span>
1204
+ ${flags ? `<span class="scene-member-flags">${flags}</span>` : ""}
1142
1205
  </span>
1143
- </span>
1144
- </label>
1145
- `,
1206
+ </label>
1207
+ ${isSelected && coordinatorId !== player.id ? `
1208
+ <div class="scene-member-volume">
1209
+ <label class="scene-member-volume-label" for="member-volume-${player.id}">Volume Override</label>
1210
+ <input
1211
+ class="form-control form-control-sm scene-member-volume-input"
1212
+ id="member-volume-${player.id}"
1213
+ data-player-id="${player.id}"
1214
+ type="number"
1215
+ min="0"
1216
+ max="100"
1217
+ placeholder="Keep current"
1218
+ value="${values.get(player.id) ?? ""}">
1219
+ </div>
1220
+ ` : ""}
1221
+ </div>
1222
+ `;
1223
+ },
1146
1224
  )
1147
1225
  .join("");
1148
1226
  }
1149
1227
 
1150
1228
  function renderSourceControls() {
1151
1229
  const household = getActiveHousehold();
1152
- const coordinator = household?.players.find((player) => player.id === state.draft.coordinatorPlayerId);
1153
- const supportedKinds = ["favorite", ...new Set((coordinator?.sourceOptions || []).filter((kind) => kind !== "favorite"))];
1230
+ const players = household?.players || [];
1231
+ const supportedKinds = ["favorite", ...new Set(players.flatMap((player) => player.sourceOptions || []).filter((kind) => kind !== "favorite"))];
1154
1232
  const sourceKind = supportedKinds.includes(state.draft.source?.kind) ? state.draft.source.kind : supportedKinds[0] || "favorite";
1155
1233
  state.draft.source = state.draft.source || { kind: sourceKind, favoriteId: "" };
1156
1234
  state.draft.source.kind = sourceKind;
@@ -1201,22 +1279,6 @@
1201
1279
  .join("");
1202
1280
  }
1203
1281
 
1204
- function renderVolumeControls() {
1205
- const household = getActiveHousehold();
1206
- const selectedMembers = state.draft.memberPlayerIds || [];
1207
- const values = new Map((state.draft.playerVolumes || []).map((entry) => [entry.playerId, entry.volume]));
1208
- elements.volumeList.innerHTML = selectedMembers.length === 0
1209
- ? `<div class="scene-help">Select one or more member rooms to configure per-room volume overrides.</div>`
1210
- : selectedMembers
1211
- .map((playerId) => `
1212
- <label class="scene-member-pill">
1213
- <span class="scene-member-title d-block mb-1">${getPlayerName(playerId)}</span>
1214
- <input class="form-control" data-player-id="${playerId}" type="number" min="0" max="100" value="${values.get(playerId) ?? ""}">
1215
- </label>
1216
- `)
1217
- .join("");
1218
- }
1219
-
1220
1282
  function renderValidation() {
1221
1283
  if (!state.validation && !state.lastRun) {
1222
1284
  elements.validationOutput.innerHTML = `<div class="scene-help">Validation messages and test results will appear here.</div>`;
@@ -1333,16 +1395,14 @@
1333
1395
  elements.sceneName.value = state.draft.name || "";
1334
1396
  elements.sceneId.value = state.draft.id || "";
1335
1397
  renderHouseholdOptions();
1336
- renderCoordinatorOptions();
1337
- renderMemberOptions();
1338
1398
  renderSourceControls();
1399
+ renderMemberOptions();
1339
1400
  elements.coordinatorVolume.value = state.draft.coordinatorVolume === "" ? "" : state.draft.coordinatorVolume ?? "";
1340
1401
  elements.settleMs.value = state.draft.settleMs ?? 750;
1341
1402
  elements.retryCount.value = state.draft.retryCount ?? 3;
1342
1403
  elements.retryDelayMs.value = state.draft.retryDelayMs ?? 750;
1343
1404
  elements.autoResetMs.value = state.draft.autoResetMs ?? 1000;
1344
1405
  elements.offBehavior.value = state.draft.offBehavior?.kind || "none";
1345
- renderVolumeControls();
1346
1406
  }
1347
1407
 
1348
1408
  function render() {
@@ -1510,6 +1570,23 @@
1510
1570
  });
1511
1571
  });
1512
1572
 
1573
+ elements.memberList.addEventListener("change", (event) => {
1574
+ const target = event.target;
1575
+ if (!(target instanceof HTMLInputElement)) {
1576
+ return;
1577
+ }
1578
+
1579
+ if (target.matches("input[data-member-checkbox]")) {
1580
+ serializeDraft();
1581
+ renderMemberOptions();
1582
+ return;
1583
+ }
1584
+
1585
+ if (target.matches("input[data-player-id]")) {
1586
+ serializeDraft();
1587
+ }
1588
+ });
1589
+
1513
1590
  elements.householdSelect.addEventListener("change", () => {
1514
1591
  serializeDraft();
1515
1592
  state.draft.coordinatorPlayerId = "";
@@ -1518,12 +1595,6 @@
1518
1595
  render();
1519
1596
  });
1520
1597
 
1521
- elements.coordinatorSelect.addEventListener("change", () => {
1522
- serializeDraft();
1523
- state.draft.memberPlayerIds = state.draft.memberPlayerIds.filter((playerId) => playerId !== state.draft.coordinatorPlayerId);
1524
- render();
1525
- });
1526
-
1527
1598
  elements.sourceKind.addEventListener("change", () => {
1528
1599
  serializeDraft();
1529
1600
  state.draft.source = elements.sourceKind.value === "favorite"
@@ -1532,6 +1603,11 @@
1532
1603
  render();
1533
1604
  });
1534
1605
 
1606
+ elements.sourceTarget.addEventListener("change", () => {
1607
+ serializeDraft();
1608
+ render();
1609
+ });
1610
+
1535
1611
  homebridge.addEventListener("scene-test-result", (event) => {
1536
1612
  state.lastRun = event.data;
1537
1613
  render();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-sonos-scenes",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Homebridge plugin for Sonos workflow scenes and orchestration.",
5
5
  "author": "applemanj",
6
6
  "homepage": "https://github.com/applemanj/homebridge-sonos-scenes#readme",