Skip to content

Commit a9654ab

Browse files
committed
Control macOS Background Sounds
1 parent 3155178 commit a9654ab

8 files changed

Lines changed: 307 additions & 22 deletions

File tree

src/background-sounds.jxa

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function discoverSounds(settings, bundle) {
4343
}
4444
const seen = {};
4545
const sounds = [];
46+
const objects = {};
4647
for (const sound of values) {
4748
const path = stringValue(sound.path);
4849
const usable = Boolean(path) && Boolean(fileManager.fileExistsAtPath(path));
@@ -51,14 +52,16 @@ function discoverSounds(settings, bundle) {
5152
if (!seen[option.id]) {
5253
seen[option.id] = true;
5354
sounds.push(option);
55+
objects[option.id] = sound;
5456
}
5557
}
56-
return sounds;
58+
return { sounds: sounds, objects: objects };
5759
}
5860

5961
function readSnapshot(settings, bundle) {
6062
const selected = soundOption(settings.selectedComfortSound);
61-
const sounds = discoverSounds(settings, bundle);
63+
const discovered = discoverSounds(settings, bundle);
64+
const sounds = discovered.sounds;
6265
if (!sounds.some(function (sound) { return sound.id === selected.id; })) {
6366
throw new Error("The selected sound is not immediately usable");
6467
}
@@ -74,7 +77,7 @@ function readSnapshot(settings, bundle) {
7477

7578
function run(argv) {
7679
const command = argv[0];
77-
if (command !== "probe" && command !== "read") return failure("contract-mismatch", "Unsupported fixed command");
80+
if (["probe", "read", "set-enabled", "set-sound", "set-volume"].indexOf(command) < 0) return failure("contract-mismatch", "Unsupported fixed command");
7881
try {
7982
const bundle = $.NSBundle.bundleWithPath(FRAMEWORK);
8083
if (!bundle || !bundle.load) return failure("framework-load", "HearingUtilities.framework could not be loaded");
@@ -89,6 +92,21 @@ function run(argv) {
8992
if (!responds(settings, selector)) return failure("contract-mismatch", "Required Background Sounds contract is unavailable");
9093
}
9194
if (!Boolean(settings.comfortSoundsAvailable)) return failure("unavailable", "Enable Background Sounds availability in macOS System Settings");
95+
if (command === "set-enabled") {
96+
if (argv[1] !== "true" && argv[1] !== "false") return failure("contract-mismatch", "Invalid enabled value");
97+
settings.setComfortSoundsEnabled(argv[1] === "true");
98+
} else if (command === "set-volume") {
99+
const percent = Number(argv[1]);
100+
if (!Number.isInteger(percent) || percent < 0 || percent > 100) return failure("contract-mismatch", "Invalid volume value");
101+
settings.setRelativeVolume(percent / 100);
102+
} else if (command === "set-sound") {
103+
let id;
104+
try { id = JSON.parse(argv[1]); } catch (_) { return failure("contract-mismatch", "Invalid sound id"); }
105+
if (typeof id !== "string" || !id) return failure("contract-mismatch", "Invalid sound id");
106+
const discovered = discoverSounds(settings, bundle);
107+
if (!discovered.objects[id]) return failure("unavailable", "Open System Settings to download this Background Sound, then refresh.");
108+
settings.setSelectedComfortSound(discovered.objects[id]);
109+
}
92110
return envelope(true, { snapshot: readSnapshot(settings, bundle) });
93111
} catch (error) {
94112
return failure("contract-mismatch", error && error.message ? error.message : error);

src/background-sounds.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export type BackgroundSoundsFailureCode = typeof backgroundSoundsFailureCodes[nu
1919
export interface BackgroundSoundsControl {
2020
probe(signal?: AbortSignal): Promise<BackgroundSoundsSnapshot>;
2121
read(signal?: AbortSignal): Promise<BackgroundSoundsSnapshot>;
22+
setEnabled(value: boolean, signal?: AbortSignal): Promise<BackgroundSoundsSnapshot>;
23+
setSound(id: string, signal?: AbortSignal): Promise<BackgroundSoundsSnapshot>;
24+
setVolume(percent: number, signal?: AbortSignal): Promise<BackgroundSoundsSnapshot>;
2225
}
2326

2427
export class BackgroundSoundsError extends Error {
@@ -66,15 +69,36 @@ export class JxaBackgroundSoundsControl implements BackgroundSoundsControl {
6669

6770
probe(signal?: AbortSignal): Promise<BackgroundSoundsSnapshot> { return this.invoke("probe", signal); }
6871
read(signal?: AbortSignal): Promise<BackgroundSoundsSnapshot> { return this.invoke("read", signal); }
72+
async setEnabled(value: boolean, signal?: AbortSignal): Promise<BackgroundSoundsSnapshot> {
73+
const snapshot = await this.invoke("set-enabled", signal, value ? "true" : "false");
74+
return this.confirm(snapshot.enabled === value, snapshot);
75+
}
76+
async setSound(id: string, signal?: AbortSignal): Promise<BackgroundSoundsSnapshot> {
77+
if (!id || id.length > 200) throw new BackgroundSoundsError("invalid-snapshot", "The selected Background Sound is invalid.");
78+
const snapshot = await this.invoke("set-sound", signal, JSON.stringify(id));
79+
return this.confirm(snapshot.sound.id === id, snapshot);
80+
}
81+
async setVolume(percent: number, signal?: AbortSignal): Promise<BackgroundSoundsSnapshot> {
82+
if (!Number.isInteger(percent) || percent < 0 || percent > 100) {
83+
throw new BackgroundSoundsError("invalid-snapshot", "Background Sound volume must be an integer from 0 to 100.");
84+
}
85+
const snapshot = await this.invoke("set-volume", signal, String(percent));
86+
return this.confirm(snapshot.volumePercent === percent, snapshot);
87+
}
88+
89+
private confirm(matches: boolean, snapshot: BackgroundSoundsSnapshot): BackgroundSoundsSnapshot {
90+
if (!matches) throw new BackgroundSoundsError("apply-mismatch", "macOS did not confirm the requested Background Sounds change.");
91+
return snapshot;
92+
}
6993

70-
private async invoke(command: "probe" | "read", signal?: AbortSignal): Promise<BackgroundSoundsSnapshot> {
94+
private async invoke(command: "probe" | "read" | "set-enabled" | "set-sound" | "set-volume", signal?: AbortSignal, value?: string): Promise<BackgroundSoundsSnapshot> {
7195
if (!existsSync(this.helperPath) && this.run === nodeExecFile) {
7296
throw new BackgroundSoundsError("helper-missing", "The bundled Background Sounds helper is missing. Reinstall TMU and retry.");
7397
}
7498
try {
7599
const result = await this.run(
76100
"/usr/bin/osascript",
77-
["-l", "JavaScript", this.helperPath, command],
101+
["-l", "JavaScript", this.helperPath, command, ...(value === undefined ? [] : [value])],
78102
{ timeout: this.timeoutMs, maxBuffer: this.maxBufferBytes, shell: false, ...(signal ? { signal } : {}) },
79103
);
80104
return parseEnvelope(result.stdout);
@@ -137,4 +161,7 @@ export class UnavailableBackgroundSoundsControl implements BackgroundSoundsContr
137161
private fail(): never { throw new BackgroundSoundsError("unsupported-platform", "Background Sounds requires macOS 26.5 or newer."); }
138162
async probe(): Promise<BackgroundSoundsSnapshot> { return this.fail(); }
139163
async read(): Promise<BackgroundSoundsSnapshot> { return this.fail(); }
164+
async setEnabled(): Promise<BackgroundSoundsSnapshot> { return this.fail(); }
165+
async setSound(): Promise<BackgroundSoundsSnapshot> { return this.fail(); }
166+
async setVolume(): Promise<BackgroundSoundsSnapshot> { return this.fail(); }
140167
}

src/coordinator.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ import {
5454
import { UiStateStore, type UiStateAction } from "./ui-state";
5555
import { BackgroundSoundsError, type BackgroundSoundsControl } from "./background-sounds";
5656

57+
type BackgroundSoundsOperation =
58+
| { type: "probe" }
59+
| { type: "read" }
60+
| { type: "setEnabled"; value: boolean }
61+
| { type: "setSound"; value: string }
62+
| { type: "setVolume"; value: number };
63+
5764
export type DependencyHealthRefresh = (
5865
helper: HelperName,
5966
currentHealth: DependencyHealthState,
@@ -113,6 +120,7 @@ export class AppCoordinator {
113120
private readonly backgroundSoundsControl?: BackgroundSoundsControl;
114121
private backgroundSoundsTail: Promise<void> = Promise.resolve();
115122
private backgroundSoundsAbort: AbortController | null = null;
123+
private backgroundVolumeTimer: ReturnType<typeof setTimeout> | null = null;
116124

117125
constructor(options: AppCoordinatorOptions) {
118126
this.appState = options.appState;
@@ -165,18 +173,47 @@ export class AppCoordinator {
165173
async enterBackgroundSounds(): Promise<void> {
166174
const shouldProbe = this.appState.backgroundSounds.status === "candidate"
167175
|| this.appState.backgroundSounds.status === "unavailable";
168-
await this.runBackgroundSounds(shouldProbe ? "probe" : "read");
176+
await this.runBackgroundSounds({ type: shouldProbe ? "probe" : "read" });
169177
}
170178

171179
async refreshBackgroundSounds(): Promise<void> {
172-
await this.runBackgroundSounds(this.appState.backgroundSounds.status === "candidate" ? "probe" : "read");
180+
await this.runBackgroundSounds({ type: this.appState.backgroundSounds.status === "candidate" ? "probe" : "read" });
173181
}
174182

175183
async retryBackgroundSounds(): Promise<void> {
176-
await this.runBackgroundSounds(this.appState.backgroundSounds.status === "unavailable" ? "probe" : "read");
184+
await this.runBackgroundSounds({ type: this.appState.backgroundSounds.status === "unavailable" ? "probe" : "read" });
185+
}
186+
187+
async setBackgroundSoundsEnabled(value: boolean): Promise<void> {
188+
if (this.appState.backgroundSounds.status !== "ready") return;
189+
await this.runBackgroundSounds({ type: "setEnabled", value });
190+
}
191+
192+
async cycleBackgroundSound(delta: 1 | -1): Promise<void> {
193+
const state = this.appState.backgroundSounds;
194+
if (state.status !== "ready") return;
195+
const current = state.snapshot.sounds.findIndex((sound) => sound.id === state.snapshot.sound.id);
196+
const index = (current + delta + state.snapshot.sounds.length) % state.snapshot.sounds.length;
197+
const target = state.snapshot.sounds[index];
198+
if (target) await this.runBackgroundSounds({ type: "setSound", value: target.id });
199+
}
200+
201+
adjustBackgroundSoundsVolume(delta: 1 | -1): void {
202+
const state = this.appState.backgroundSounds;
203+
if (state.status !== "ready") return;
204+
const current = this.uiState.background.pendingVolumePercent ?? state.snapshot.volumePercent;
205+
const target = Math.max(0, Math.min(100, current + delta * 5));
206+
this.dispatchUi({ type: "setBackgroundPendingVolume", percent: target });
207+
if (this.backgroundVolumeTimer) clearTimeout(this.backgroundVolumeTimer);
208+
this.backgroundVolumeTimer = setTimeout(() => {
209+
this.backgroundVolumeTimer = null;
210+
void this.runBackgroundSounds({ type: "setVolume", value: target }).finally(() => {
211+
this.dispatchUi({ type: "setBackgroundPendingVolume", percent: null });
212+
});
213+
}, 150);
177214
}
178215

179-
private async runBackgroundSounds(operation: "probe" | "read"): Promise<void> {
216+
private async runBackgroundSounds(operation: BackgroundSoundsOperation): Promise<void> {
180217
if (!this.backgroundSoundsControl || this.appState.backgroundSounds.status === "hidden" || this.tornDown) return;
181218
const task = async () => {
182219
const previous = this.appState.backgroundSounds;
@@ -187,7 +224,14 @@ export class AppCoordinator {
187224
const controller = new AbortController();
188225
this.backgroundSoundsAbort = controller;
189226
try {
190-
const snapshot = await this.backgroundSoundsControl![operation](controller.signal);
227+
const control = this.backgroundSoundsControl!;
228+
const snapshot = operation.type === "setEnabled" ? await control.setEnabled(operation.value, controller.signal)
229+
: operation.type === "setSound" ? await control.setSound(operation.value, controller.signal)
230+
: operation.type === "setVolume" ? await control.setVolume(operation.value, controller.signal)
231+
: await control[operation.type](controller.signal);
232+
if ("snapshot" in previous && mutationChangedIndependentBackgroundValues(operation, previous.snapshot, snapshot)) {
233+
throw new BackgroundSoundsError("apply-mismatch", "macOS changed an independent Background Sounds setting; refresh and retry.");
234+
}
191235
this.appState.backgroundSounds = { status: "ready", snapshot };
192236
} catch (error) {
193237
if (this.tornDown && error instanceof BackgroundSoundsError && error.code === "cancelled") return;
@@ -286,6 +330,9 @@ export class AppCoordinator {
286330
async teardown(): Promise<void> {
287331
if (this.tornDown) return;
288332
this.tornDown = true;
333+
if (this.backgroundVolumeTimer) clearTimeout(this.backgroundVolumeTimer);
334+
this.backgroundVolumeTimer = null;
335+
this.dispatchUi({ type: "setBackgroundPendingVolume", percent: null });
289336
this.backgroundSoundsAbort?.abort();
290337
await this.saveLastPlaylistSnapshot({ meaningful: false });
291338
this.pendingDownloadBatches.length = 0;
@@ -1427,3 +1474,15 @@ export class AppCoordinator {
14271474
for (const listener of this.stateListeners) listener(reason);
14281475
}
14291476
}
1477+
1478+
function mutationChangedIndependentBackgroundValues(
1479+
operation: BackgroundSoundsOperation,
1480+
before: import("./background-sounds").BackgroundSoundsSnapshot,
1481+
after: import("./background-sounds").BackgroundSoundsSnapshot,
1482+
): boolean {
1483+
const sameSound = before.sound.id === after.sound.id;
1484+
if (operation.type === "setEnabled") return !sameSound || before.volumePercent !== after.volumePercent;
1485+
if (operation.type === "setSound") return before.enabled !== after.enabled || before.volumePercent !== after.volumePercent;
1486+
if (operation.type === "setVolume") return before.enabled !== after.enabled || !sameSound;
1487+
return false;
1488+
}

src/domain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export type UiState = {
252252
selectedBatchIndex: number;
253253
scroll: number;
254254
};
255-
background: { selectedRow: number };
255+
background: { selectedRow: number; pendingVolumePercent: number | null };
256256
terminal: {
257257
columns: number;
258258
rows: number;

src/ui-state.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type UiStateAction =
2323
| { type: "setDownloaderInputFocus"; focused: boolean }
2424
| { type: "setDownloaderBatchSelection"; index: number; resultCount: number }
2525
| { type: "setBackgroundSelection"; index: number }
26+
| { type: "setBackgroundPendingVolume"; percent: number | null }
2627
| { type: "setPendingVimChord"; pending: boolean }
2728
| { type: "selectPlaylistTrack"; index: number; identities: readonly TrackIdentity[] }
2829
| { type: "resetPlaylistSelection"; index: number; identities: readonly TrackIdentity[] }
@@ -59,7 +60,7 @@ export function createInitialUiState(options: InitialUiStateOptions = {}): UiSta
5960
selectedPlaylistIdentity: null,
6061
library: { query: "", inputFocused: false, selectedIndex: 0, healthSelectedIndex: 0, scroll: 0 },
6162
downloader: { urlInput: "", inputFocused: true, selectedBatchIndex: 0, scroll: 0 },
62-
background: { selectedRow: 0 },
63+
background: { selectedRow: 0, pendingVolumePercent: null },
6364
terminal: { columns, rows, tier: responsiveTier(columns, rows) },
6465
pendingConfirmation: null,
6566
renameDialog: null,
@@ -122,7 +123,9 @@ export function reduceUiState(state: UiState, action: UiStateAction): UiState {
122123
downloader: { ...state.downloader, selectedBatchIndex, scroll: visibleScroll(state.downloader.scroll, selectedBatchIndex) },
123124
}; }
124125
case "setBackgroundSelection":
125-
return { ...state, background: { selectedRow: clampIndex(action.index, 3) } };
126+
return { ...state, background: { ...state.background, selectedRow: clampIndex(action.index, 3) } };
127+
case "setBackgroundPendingVolume":
128+
return { ...state, background: { ...state.background, pendingVolumePercent: action.percent } };
126129
case "setPendingVimChord":
127130
return { ...state, pendingVimChord: action.pending ? { key: "g", expiresAtMs: Date.now() + 1_000 } : null };
128131
case "selectPlaylistTrack": {

src/vue-tui/component.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,7 @@ function backgroundView(snapshot: PublicationSnapshot, noColor: boolean) {
665665
}
666666
if (!("snapshot" in state)) return null;
667667
const snapshotValue = state.snapshot;
668+
const pendingVolume = snapshot.uiState.background.pendingVolumePercent;
668669
const stale = state.status === "degraded";
669670
const busy = state.status === "busy";
670671
const filled = Math.round(snapshotValue.volumePercent / 10);
@@ -673,7 +674,7 @@ function backgroundView(snapshot: PublicationSnapshot, noColor: boolean) {
673674
h(Text, () => ""),
674675
row(0, "Background Sounds", snapshotValue.enabled ? "● On" : "○ Off"),
675676
row(1, "Sound", `‹ ${snapshotValue.sound.label} ›`),
676-
row(2, "Volume", `[${"■".repeat(filled)}${"·".repeat(10 - filled)}] ${snapshotValue.volumePercent}%`),
677+
row(2, "Volume", `[${"■".repeat(filled)}${"·".repeat(10 - filled)}] ${snapshotValue.volumePercent}%${pendingVolume === null ? "" : ` → ${pendingVolume}% pending`}`),
677678
h(Text, { dimColor: true }, () => ` ${"State".padEnd(20)}${busy ? "Refreshing from macOS…" : stale ? "Stale · press u to retry" : "Confirmed from macOS"}`),
678679
stale ? h(Text, { color: noColor ? undefined : "red" }, () => state.error) : null,
679680
]);
@@ -689,8 +690,18 @@ async function routeBackground(input: string, key: InputKey, coordinator: AppCoo
689690
coordinator.dispatchUi({ type: "setBackgroundSelection", index: coordinator.uiState.background.selectedRow + delta });
690691
return true;
691692
}
692-
// Read-only slice: consume arrows on Settings rows so they never seek the Player.
693-
return Boolean(key.leftArrow || key.rightArrow || key.return);
693+
if (!key.leftArrow && !key.rightArrow && !key.return) return false;
694+
const state = coordinator.appState.backgroundSounds;
695+
if (state.status !== "ready") return true;
696+
const row = coordinator.uiState.background.selectedRow;
697+
if (row === 0) {
698+
if (key.leftArrow) await coordinator.setBackgroundSoundsEnabled(false);
699+
else if (key.rightArrow) await coordinator.setBackgroundSoundsEnabled(true);
700+
else await coordinator.setBackgroundSoundsEnabled(!state.snapshot.enabled);
701+
}
702+
else if (row === 1) await coordinator.cycleBackgroundSound(key.leftArrow ? -1 : 1);
703+
else if (row === 2 && (key.leftArrow || key.rightArrow)) coordinator.adjustBackgroundSoundsVolume(key.leftArrow ? -1 : 1);
704+
return true;
694705
}
695706

696707
function activePlaylistName(appState: PublicationSnapshot["appState"]): string {
@@ -1057,7 +1068,7 @@ function activeShortcutGroups(tab: UiState["activeTab"], incompleteSelected: boo
10571068
title: "BACKGROUND SOUNDS",
10581069
rows: [
10591070
["j/k, ↑/↓", "Move selection"], ["u", "Refresh or retry macOS state"],
1060-
["←/→, Enter", "Controls are read-only in this release"],
1071+
["←/→", "Adjust focused sound or volume"], ["Enter", "Activate focused control"],
10611072
],
10621073
}];
10631074
return [
@@ -1198,7 +1209,7 @@ function footer(ui: UiState, incompleteSelected = false, noColor = false) {
11981209
? [["j/k", "Move"], ["/", "Search"], ["Enter", "Play"], ["a", "Add"], ["?", "Help"]]
11991210
: [["j/k", "Move"], ["/", "Search"], ["Enter", "Play"], ["a", "Add to Playlist"], ["e", "Rename"], ["?", "Help"]]
12001211
: ui.activeTab === "background"
1201-
? [["j/k", "Move"], ["u", "Refresh"], ["?", "Help"]]
1212+
? [["j/k", "Move"], ["←/→", "Adjust"], ["Enter", "Activate"], ["u", "Refresh"], ["?", "Help"]]
12021213
: ui.downloader.inputFocused
12031214
? [["Type", "URL"], ["Enter", "Submit"], ["Esc/Tab → ?", "Help"]]
12041215
: [["j/k", "Move"], ["x", "Cancel/Remove"], ["gg/G", "Ends"], ["Tab", "Focus"], ["?", "Help"]];

0 commit comments

Comments
 (0)