homebridge-sonos-scenes 0.1.5 → 0.1.7

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,7 +1,16 @@
1
- # homebridge-sonos-scenes
1
+ <p align="center">
2
+ <img src="docs/assets/icon-512.png" alt="homebridge-sonos-scenes icon" width="164">
3
+ </p>
4
+
5
+ <h1 align="center">homebridge-sonos-scenes</h1>
6
+
7
+ <p align="center">Homebridge plugin for Sonos workflow scenes and orchestration.</p>
2
8
 
3
9
  `homebridge-sonos-scenes` is a Homebridge plugin scaffold for Sonos workflow scenes.
4
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
+
5
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:
6
15
 
7
16
  - grouping rooms around a coordinator,
Binary file
Binary file
Binary file
@@ -37,7 +37,7 @@
37
37
  border: 1px solid rgba(22, 32, 51, 0.08);
38
38
  box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
39
39
  background: white;
40
- overflow: hidden;
40
+ overflow: visible;
41
41
  }
42
42
 
43
43
  .scene-mode-panel .card-header {
@@ -45,6 +45,34 @@
45
45
  background: rgba(255, 255, 255, 0.65);
46
46
  }
47
47
 
48
+ .scene-section-header {
49
+ display: flex;
50
+ flex-wrap: wrap;
51
+ align-items: flex-start;
52
+ justify-content: space-between;
53
+ gap: 0.85rem 1rem;
54
+ padding: 1rem 1.15rem;
55
+ }
56
+
57
+ .scene-section-header-copy {
58
+ display: grid;
59
+ gap: 0.35rem;
60
+ min-width: 0;
61
+ flex: 1 1 260px;
62
+ }
63
+
64
+ .scene-section-actions {
65
+ display: flex;
66
+ flex-wrap: wrap;
67
+ gap: 0.65rem;
68
+ align-items: center;
69
+ justify-content: flex-end;
70
+ }
71
+
72
+ .scene-section-actions .btn {
73
+ white-space: nowrap;
74
+ }
75
+
48
76
  .scene-mode-panel .card-body {
49
77
  display: grid;
50
78
  gap: 1rem;
@@ -66,6 +94,8 @@
66
94
 
67
95
  .scene-mode-choice input {
68
96
  margin-right: 0.65rem;
97
+ accent-color: var(--scene-accent);
98
+ transform: scale(1.08);
69
99
  }
70
100
 
71
101
  .scene-mode-choice-title {
@@ -148,7 +178,7 @@
148
178
 
149
179
  .scene-card .scene-help,
150
180
  .scene-mode-panel .scene-help {
151
- color: #667085;
181
+ color: #556579;
152
182
  }
153
183
 
154
184
  .scene-list button {
@@ -277,12 +307,22 @@
277
307
  display: block;
278
308
  }
279
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
+
280
316
  .scene-member-row {
281
317
  display: flex;
282
318
  align-items: flex-start;
283
319
  gap: 0.65rem;
284
320
  }
285
321
 
322
+ .scene-member-toggle {
323
+ cursor: pointer;
324
+ }
325
+
286
326
  .scene-member-pill .form-check-input {
287
327
  margin-top: 0.2rem;
288
328
  flex: 0 0 auto;
@@ -306,6 +346,25 @@
306
346
  line-height: 1.35;
307
347
  }
308
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
+
309
368
  .scene-log {
310
369
  max-height: 280px;
311
370
  overflow: auto;
@@ -334,7 +393,7 @@
334
393
  }
335
394
 
336
395
  .scene-inline-note {
337
- color: #667085;
396
+ color: #5f6f84;
338
397
  font-size: 0.88rem;
339
398
  line-height: 1.45;
340
399
  }
@@ -344,10 +403,10 @@
344
403
  align-items: center;
345
404
  gap: 0.4rem;
346
405
  flex-wrap: wrap;
406
+ position: relative;
347
407
  }
348
408
 
349
409
  .scene-info-tag {
350
- position: relative;
351
410
  display: inline-flex;
352
411
  align-items: center;
353
412
  justify-content: center;
@@ -370,10 +429,11 @@
370
429
 
371
430
  .scene-tooltip {
372
431
  position: absolute;
373
- top: 50%;
374
- left: calc(100% + 0.55rem);
375
- transform: translateY(-50%);
376
- width: min(320px, 70vw);
432
+ top: calc(100% + 0.45rem);
433
+ left: 0;
434
+ transform: none;
435
+ width: min(320px, calc(100vw - 4rem));
436
+ max-width: 100%;
377
437
  padding: 0.65rem 0.75rem;
378
438
  border-radius: 0.7rem;
379
439
  border: 1px solid rgba(170, 198, 255, 0.28);
@@ -400,15 +460,6 @@
400
460
  grid-template-columns: 1fr;
401
461
  }
402
462
  }
403
-
404
- @media (max-width: 720px) {
405
- .scene-tooltip {
406
- top: calc(100% + 0.45rem);
407
- left: 0;
408
- transform: none;
409
- width: min(280px, 78vw);
410
- }
411
- }
412
463
  </style>
413
464
 
414
465
  <div class="scene-shell">
@@ -444,12 +495,11 @@
444
495
 
445
496
  <section class="scene-mode-grid">
446
497
  <section class="scene-mode-panel card scene-card">
447
- <div class="card-header d-flex justify-content-between align-items-center">
448
- <div>
498
+ <div class="card-header scene-section-header">
499
+ <div class="scene-section-header-copy">
449
500
  <strong>Execution Mode</strong>
450
501
  <div class="scene-help">Choose whether scenes stay fully local or can use a self-hosted Sonos cloud broker later.</div>
451
502
  </div>
452
- <div id="cloud-mode-pill" class="scene-status-pill local">Local Only</div>
453
503
  </div>
454
504
  <div class="card-body d-grid gap-3">
455
505
  <label class="scene-mode-choice" data-mode-choice="local_only">
@@ -539,13 +589,15 @@
539
589
 
540
590
  <div class="scene-grid">
541
591
  <section class="card scene-card">
542
- <div class="card-header d-flex justify-content-between align-items-center">
543
- <div>
592
+ <div class="card-header scene-section-header">
593
+ <div class="scene-section-header-copy">
544
594
  <strong>Scenes</strong>
545
595
  <div class="scene-help">Each saved scene becomes a HomeKit switch plus a companion volume speaker.</div>
546
596
  <div class="scene-inline-note mt-2">Save scene changes in the editor, then use Homebridge&apos;s footer Save button when you&apos;re ready to write everything to `config.json`.</div>
547
597
  </div>
548
- <button class="btn btn-sm btn-outline-primary" id="new-scene-button" type="button">New Scene</button>
598
+ <div class="scene-section-actions">
599
+ <button class="btn btn-sm btn-outline-primary" id="new-scene-button" type="button">New Scene</button>
600
+ </div>
549
601
  </div>
550
602
  <div class="card-body">
551
603
  <div id="scene-list" class="scene-list"></div>
@@ -553,13 +605,13 @@
553
605
  </section>
554
606
 
555
607
  <section class="card scene-card">
556
- <div class="card-header d-flex justify-content-between align-items-center">
557
- <div>
608
+ <div class="card-header scene-section-header">
609
+ <div class="scene-section-header-copy">
558
610
  <strong>Scene Editor</strong>
559
611
  <div class="scene-help">Stable Sonos IDs are stored under the hood; the UI keeps room selection friendly.</div>
560
612
  <div class="scene-inline-note mt-2">Save Scene Changes updates this scene in the list above. Homebridge&apos;s footer Save button writes the full plugin config to disk.</div>
561
613
  </div>
562
- <div class="d-flex gap-2">
614
+ <div class="scene-section-actions">
563
615
  <button class="btn btn-sm btn-outline-danger" id="delete-scene-button" type="button">Delete</button>
564
616
  <button class="btn btn-sm btn-primary" id="save-scene-button" type="button">Save Scene Changes</button>
565
617
  </div>
@@ -609,7 +661,7 @@
609
661
  <span>Group Members</span>
610
662
  <span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Group Members">
611
663
  ?
612
- <span class="scene-tooltip">Additional rooms to join the coordinator when the scene runs. Leave all unchecked for a single-room scene.</span>
664
+ <span class="scene-tooltip">Additional rooms to join the coordinator when the scene runs. Leave all unchecked for a single-room scene. Selected member tiles can also carry their own optional volume override.</span>
613
665
  </span>
614
666
  </span>
615
667
  </label>
@@ -714,18 +766,6 @@
714
766
  <option value="ungroup">Ungroup</option>
715
767
  </select>
716
768
  </div>
717
- <div class="col-12">
718
- <label class="form-label">
719
- <span class="scene-label-row">
720
- <span>Per-room Volumes</span>
721
- <span class="scene-info-tag" tabindex="0" role="button" aria-label="Explain Per-room Volumes">
722
- ?
723
- <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>
724
- </span>
725
- </span>
726
- </label>
727
- <div id="volume-list" class="scene-member-grid"></div>
728
- </div>
729
769
  </div>
730
770
  </div>
731
771
  </section>
@@ -781,7 +821,6 @@
781
821
  retryDelayMs: document.getElementById("retry-delay-ms"),
782
822
  autoResetMs: document.getElementById("auto-reset-ms"),
783
823
  offBehavior: document.getElementById("off-behavior"),
784
- volumeList: document.getElementById("volume-list"),
785
824
  validationOutput: document.getElementById("validation-output"),
786
825
  logOutput: document.getElementById("log-output"),
787
826
  discoverySummary: document.getElementById("discovery-summary"),
@@ -791,7 +830,6 @@
791
830
  testButton: document.getElementById("test-button"),
792
831
  cloudModeInputs: Array.from(document.querySelectorAll("input[name='cloud-mode']")),
793
832
  cloudModeChoices: Array.from(document.querySelectorAll("[data-mode-choice]")),
794
- cloudModePill: document.getElementById("cloud-mode-pill"),
795
833
  cloudBrokerPanel: document.getElementById("cloud-broker-panel"),
796
834
  cloudBrokerCopy: document.getElementById("cloud-broker-copy"),
797
835
  cloudBrokerFields: document.getElementById("cloud-broker-fields"),
@@ -934,7 +972,7 @@
934
972
  draft.retryDelayMs = Number(elements.retryDelayMs.value || 0);
935
973
  draft.autoResetMs = Number(elements.autoResetMs.value || 0);
936
974
  draft.offBehavior = { kind: elements.offBehavior.value };
937
- draft.playerVolumes = Array.from(elements.volumeList.querySelectorAll("input[data-player-id]"))
975
+ draft.playerVolumes = Array.from(elements.memberList.querySelectorAll("input[data-player-id]"))
938
976
  .filter((input) => input.value !== "")
939
977
  .map((input) => ({
940
978
  playerId: input.dataset.playerId,
@@ -1021,8 +1059,6 @@
1021
1059
  });
1022
1060
 
1023
1061
  const isHybrid = cloud.mode === "local_plus_cloud";
1024
- elements.cloudModePill.textContent = isHybrid ? "Local + Cloud" : "Local Only";
1025
- elements.cloudModePill.className = `scene-status-pill ${isHybrid ? "pending" : "local"}`;
1026
1062
  elements.cloudBrokerPanel.classList.toggle("scene-cloud-hidden", !isHybrid);
1027
1063
  elements.cloudBrokerCopy.textContent = isHybrid
1028
1064
  ? "Only needed if you run your own broker. The settings below stay with this plugin config and are ignored whenever Local Only is active."
@@ -1107,22 +1143,40 @@
1107
1143
  const household = getActiveHousehold();
1108
1144
  const coordinatorId = state.draft.coordinatorPlayerId;
1109
1145
  const selected = new Set(state.draft.memberPlayerIds.filter((playerId) => playerId !== coordinatorId));
1146
+ const values = new Map((state.draft.playerVolumes || []).map((entry) => [entry.playerId, entry.volume]));
1110
1147
  const players = (household?.players || []).filter((player) => player.id !== coordinatorId);
1111
1148
  elements.memberList.innerHTML = players.length === 0
1112
1149
  ? `<div class="scene-help">No additional players are available for grouping.</div>`
1113
1150
  : players
1114
1151
  .map(
1115
- (player) => `
1116
- <label class="scene-member-pill">
1117
- <span class="scene-member-row">
1118
- <input class="form-check-input" type="checkbox" value="${player.id}" ${selected.has(player.id) ? "checked" : ""}>
1152
+ (player) => {
1153
+ const isSelected = selected.has(player.id);
1154
+ return `
1155
+ <div class="scene-member-pill ${isSelected ? "selected" : ""}">
1156
+ <label class="scene-member-row scene-member-toggle">
1157
+ <input class="form-check-input" data-member-checkbox type="checkbox" value="${player.id}" ${isSelected ? "checked" : ""}>
1119
1158
  <span class="scene-member-copy">
1120
1159
  <span class="scene-member-title">${player.name}</span>
1121
1160
  <span class="scene-member-meta">${player.model || "Unknown model"} - ${((player.sourceOptions || []).join(", ") || "favorite").replaceAll("_", " ")}</span>
1122
1161
  </span>
1123
- </span>
1124
- </label>
1125
- `,
1162
+ </label>
1163
+ ${isSelected ? `
1164
+ <div class="scene-member-volume">
1165
+ <label class="scene-member-volume-label" for="member-volume-${player.id}">Volume Override</label>
1166
+ <input
1167
+ class="form-control form-control-sm scene-member-volume-input"
1168
+ id="member-volume-${player.id}"
1169
+ data-player-id="${player.id}"
1170
+ type="number"
1171
+ min="0"
1172
+ max="100"
1173
+ placeholder="Keep current"
1174
+ value="${values.get(player.id) ?? ""}">
1175
+ </div>
1176
+ ` : ""}
1177
+ </div>
1178
+ `;
1179
+ },
1126
1180
  )
1127
1181
  .join("");
1128
1182
  }
@@ -1181,22 +1235,6 @@
1181
1235
  .join("");
1182
1236
  }
1183
1237
 
1184
- function renderVolumeControls() {
1185
- const household = getActiveHousehold();
1186
- const selectedMembers = state.draft.memberPlayerIds || [];
1187
- const values = new Map((state.draft.playerVolumes || []).map((entry) => [entry.playerId, entry.volume]));
1188
- elements.volumeList.innerHTML = selectedMembers.length === 0
1189
- ? `<div class="scene-help">Select one or more member rooms to configure per-room volume overrides.</div>`
1190
- : selectedMembers
1191
- .map((playerId) => `
1192
- <label class="scene-member-pill">
1193
- <span class="scene-member-title d-block mb-1">${getPlayerName(playerId)}</span>
1194
- <input class="form-control" data-player-id="${playerId}" type="number" min="0" max="100" value="${values.get(playerId) ?? ""}">
1195
- </label>
1196
- `)
1197
- .join("");
1198
- }
1199
-
1200
1238
  function renderValidation() {
1201
1239
  if (!state.validation && !state.lastRun) {
1202
1240
  elements.validationOutput.innerHTML = `<div class="scene-help">Validation messages and test results will appear here.</div>`;
@@ -1322,7 +1360,6 @@
1322
1360
  elements.retryDelayMs.value = state.draft.retryDelayMs ?? 750;
1323
1361
  elements.autoResetMs.value = state.draft.autoResetMs ?? 1000;
1324
1362
  elements.offBehavior.value = state.draft.offBehavior?.kind || "none";
1325
- renderVolumeControls();
1326
1363
  }
1327
1364
 
1328
1365
  function render() {
@@ -1490,6 +1527,23 @@
1490
1527
  });
1491
1528
  });
1492
1529
 
1530
+ elements.memberList.addEventListener("change", (event) => {
1531
+ const target = event.target;
1532
+ if (!(target instanceof HTMLInputElement)) {
1533
+ return;
1534
+ }
1535
+
1536
+ if (target.matches("input[data-member-checkbox]")) {
1537
+ serializeDraft();
1538
+ renderMemberOptions();
1539
+ return;
1540
+ }
1541
+
1542
+ if (target.matches("input[data-player-id]")) {
1543
+ serializeDraft();
1544
+ }
1545
+ });
1546
+
1493
1547
  elements.householdSelect.addEventListener("change", () => {
1494
1548
  serializeDraft();
1495
1549
  state.draft.coordinatorPlayerId = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-sonos-scenes",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",