@@ -54,6 +54,13 @@ import {
5454import { UiStateStore , type UiStateAction } from "./ui-state" ;
5555import { 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+
5764export 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+ }
0 commit comments