@hkdigital/lib-core 0.4.27 → 0.4.28

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.
@@ -8,36 +8,23 @@
8
8
  * @property {AudioLoader} audioLoader
9
9
  * @property {SourceConfig} [config]
10
10
  */
11
- export default class AudioScene {
12
- state: string;
13
- loaded: boolean;
11
+ export default class AudioScene extends SceneBase {
14
12
  muted: boolean;
15
13
  targetGain: number;
16
14
  /**
17
- * Get audio scene loading progress
18
- */
19
- get progress(): {
20
- totalBytesLoaded: number;
21
- totalSize: number;
22
- sourcesLoaded: number;
23
- numberOfSources: number;
24
- };
25
- /**
26
- * Get audio scene abort progress
27
- */
28
- get abortProgress(): {
29
- sourcesAborted: number;
30
- numberOfSources: number;
31
- };
32
- /**
33
- * Start loading all audio sources
15
+ * Get the array of memory sources managed by this scene
16
+ *
17
+ * @returns {MemorySource[]}
34
18
  */
35
- load(): void;
19
+ get sources(): MemorySource[];
36
20
  /**
37
- * Abort loading all audio sources
21
+ * Extract the audio loader from a source object
22
+ *
23
+ * @param {MemorySource} source
24
+ *
25
+ * @returns {AudioLoader}
38
26
  */
39
- abort(): void;
40
- destroy(): void;
27
+ getLoaderFromSource(source: MemorySource): AudioLoader;
41
28
  /**
42
29
  * Add in-memory audio source
43
30
  * - Uses an AudioLoader instance to load audio data from network
@@ -67,18 +54,33 @@ export default class AudioScene {
67
54
  */
68
55
  setAudioContext(audioContext?: AudioContext): void;
69
56
  /**
70
- * Set target gain
57
+ * Set target gain (volume level) for all audio in this scene
58
+ * - Currently applies immediately, but "target" allows for future
59
+ * smooth transitions using Web Audio API's gain scheduling
60
+ * - Range: 0.0 (silence) to 1.0 (original) to 1.0+ (amplified)
71
61
  *
72
- * @param {number} value
62
+ * @param {number} value - Target gain value (0.0 to 1.0+)
73
63
  */
74
64
  setTargetGain(value: number): void;
75
65
  /**
76
- * Get the scene gain
66
+ * Get the current target gain (volume level)
77
67
  *
78
- * @returns {number} value
68
+ * @returns {number} Target gain value (0.0 to 1.0+)
79
69
  */
80
70
  getTargetGain(): number;
71
+ /**
72
+ * Mute all audio in this scene
73
+ * - Saves current volume level for restoration
74
+ * - Sets target gain to 0 (silence)
75
+ * - Safe to call multiple times
76
+ */
81
77
  mute(): void;
78
+ /**
79
+ * Unmute all audio in this scene
80
+ * - Restores volume level from before muting
81
+ * - Safe to call multiple times
82
+ * - No effect if scene is not currently muted
83
+ */
82
84
  unmute(): void;
83
85
  #private;
84
86
  }
@@ -91,4 +93,5 @@ export type MemorySource = {
91
93
  audioLoader: AudioLoader;
92
94
  config?: SourceConfig;
93
95
  };
96
+ import SceneBase from '../base/SceneBase.svelte.js';
94
97
  import AudioLoader from './AudioLoader.svelte.js';
@@ -1,20 +1,6 @@
1
1
  import * as expect from '../../../util/expect.js';
2
2
 
3
- import { LoadingStateMachine } from '../../../state/machines.js';
4
-
5
- import {
6
- STATE_INITIAL,
7
- STATE_LOADING,
8
- STATE_LOADED,
9
- STATE_ABORTING,
10
- STATE_ABORTED,
11
- STATE_ERROR,
12
- LOAD,
13
- LOADED,
14
- ABORT,
15
- ABORTED
16
- } from '../../../state/machines.js';
17
-
3
+ import SceneBase from '../base/SceneBase.svelte.js';
18
4
  import AudioLoader from './AudioLoader.svelte.js';
19
5
 
20
6
  /**
@@ -29,15 +15,7 @@ import AudioLoader from './AudioLoader.svelte.js';
29
15
  * @property {SourceConfig} [config]
30
16
  */
31
17
 
32
- export default class AudioScene {
33
- #state = new LoadingStateMachine();
34
-
35
- // @note this exported state is set by onenter
36
- state = $state(STATE_INITIAL);
37
-
38
- loaded = $derived.by(() => {
39
- return this.state === STATE_LOADED;
40
- });
18
+ export default class AudioScene extends SceneBase {
41
19
 
42
20
  #targetGain = $state(1);
43
21
 
@@ -56,143 +34,35 @@ export default class AudioScene {
56
34
  /** @type {MemorySource[]} */
57
35
  #memorySources = $state([]);
58
36
 
59
- #progress = $derived.by(() => {
60
- // console.log('update progress');
61
-
62
- let totalSize = 0;
63
- let totalBytesLoaded = 0;
64
- let sourcesLoaded = 0;
65
-
66
- const sources = this.#memorySources;
67
- const numberOfSources = sources.length;
68
-
69
- for (let j = 0; j < numberOfSources; j++) {
70
- const source = sources[j];
71
- const { audioLoader } = source;
72
-
73
- const { bytesLoaded, size, loaded } = audioLoader.progress;
74
-
75
- totalSize += size;
76
- totalBytesLoaded += bytesLoaded;
77
-
78
- if (loaded) {
79
- sourcesLoaded++;
80
- }
81
- } // end for
82
-
83
- return {
84
- totalBytesLoaded,
85
- totalSize,
86
- sourcesLoaded,
87
- numberOfSources
88
- };
89
- });
90
-
91
- #abortProgress = $derived.by(() => {
92
- let sourcesAborted = 0;
93
- const sources = this.#memorySources;
94
- const numberOfSources = sources.length;
95
-
96
- for (let j = 0; j < numberOfSources; j++) {
97
- const source = sources[j];
98
- const { audioLoader } = source;
99
- const loaderState = audioLoader.state;
100
-
101
- if (loaderState === STATE_ABORTED || loaderState === STATE_ERROR) {
102
- sourcesAborted++;
103
- }
104
- } // end for
105
-
106
- return {
107
- sourcesAborted,
108
- numberOfSources
109
- };
110
- });
111
37
 
112
38
  /**
113
39
  * Construct AudioScene
114
40
  */
115
41
  constructor() {
116
- const state = this.#state;
117
-
118
- $effect(() => {
119
- if (state.current === STATE_LOADING) {
120
- // console.log(
121
- // 'progress',
122
- // JSON.stringify($state.snapshot(this.#progress))
123
- // );
124
-
125
- const { sourcesLoaded, numberOfSources } = this.#progress;
126
-
127
- if (sourcesLoaded === numberOfSources) {
128
- // console.debug(`AudioScene: ${numberOfSources} sources loaded`);
129
- this.#state.send(LOADED);
130
- }
131
- }
132
- });
133
-
134
- $effect(() => {
135
- if (state.current === STATE_ABORTING) {
136
- const { sourcesAborted, numberOfSources } = this.#abortProgress;
137
-
138
- if (sourcesAborted === numberOfSources) {
139
- // console.debug(`AudioScene: ${numberOfSources} sources aborted`);
140
- this.#state.send(ABORTED);
141
- }
142
- }
143
- });
144
-
145
- state.onenter = ( currentState ) => {
146
- // console.log('onenter', currentState );
147
-
148
- if(currentState === STATE_LOADING )
149
- {
150
- // console.log('AudioScene:loading');
151
- this.#startLoading();
152
- }
153
- else if(currentState === STATE_ABORTING )
154
- {
155
- // console.log('AudioScene:aborting');
156
- this.#startAbort();
157
- }
158
-
159
- this.state = state.current;
160
- };
42
+ super();
161
43
  }
162
44
 
163
- /* ==== Common loader interface */
45
+ /* ==== SceneBase implementation */
164
46
 
165
47
  /**
166
- * Get audio scene loading progress
167
- */
168
- get progress() {
169
- return this.#progress;
170
- }
171
-
172
- /**
173
- * Get audio scene abort progress
174
- */
175
- get abortProgress() {
176
- return this.#abortProgress;
177
- }
178
-
179
- /**
180
- * Start loading all audio sources
48
+ * Get the array of memory sources managed by this scene
49
+ *
50
+ * @returns {MemorySource[]}
181
51
  */
182
- load() {
183
- this.#state.send(LOAD);
52
+ get sources() {
53
+ return this.#memorySources;
184
54
  }
185
55
 
186
56
  /**
187
- * Abort loading all audio sources
57
+ * Extract the audio loader from a source object
58
+ *
59
+ * @param {MemorySource} source
60
+ *
61
+ * @returns {AudioLoader}
188
62
  */
189
- abort() {
190
- this.#state.send(ABORT);
191
- }
192
-
193
- destroy() {
194
- // TODO: disconnect all audio sources?
195
- // TODO: Unload AUdioLoaders?
63
+ // eslint-disable-next-line no-unused-vars
64
+ getLoaderFromSource(source) {
65
+ return source.audioLoader;
196
66
  }
197
67
 
198
68
  /* ==== Source definitions */
@@ -260,28 +130,16 @@ export default class AudioScene {
260
130
  this.#audioContext = audioContext;
261
131
  }
262
132
 
263
- async #startLoading() {
264
- // console.log('#startLoading');
265
-
266
- for (const { audioLoader } of this.#memorySources) {
267
- audioLoader.load();
268
- }
269
- }
270
-
271
- #startAbort() {
272
- // console.log('#startAbort');
273
-
274
- for (const { audioLoader } of this.#memorySources) {
275
- audioLoader.abort();
276
- }
277
- }
278
133
 
279
134
  /* ==== Audio specific */
280
135
 
281
136
  /**
282
- * Set target gain
137
+ * Set target gain (volume level) for all audio in this scene
138
+ * - Currently applies immediately, but "target" allows for future
139
+ * smooth transitions using Web Audio API's gain scheduling
140
+ * - Range: 0.0 (silence) to 1.0 (original) to 1.0+ (amplified)
283
141
  *
284
- * @param {number} value
142
+ * @param {number} value - Target gain value (0.0 to 1.0+)
285
143
  */
286
144
  setTargetGain( value ) {
287
145
  this.#targetGain = value;
@@ -291,15 +149,21 @@ export default class AudioScene {
291
149
  }
292
150
 
293
151
  /**
294
- * Get the scene gain
152
+ * Get the current target gain (volume level)
295
153
  *
296
- * @returns {number} value
154
+ * @returns {number} Target gain value (0.0 to 1.0+)
297
155
  */
298
156
  getTargetGain()
299
157
  {
300
158
  return this.#targetGain;
301
159
  }
302
160
 
161
+ /**
162
+ * Mute all audio in this scene
163
+ * - Saves current volume level for restoration
164
+ * - Sets target gain to 0 (silence)
165
+ * - Safe to call multiple times
166
+ */
303
167
  mute() {
304
168
  if( this.muted )
305
169
  {
@@ -310,6 +174,12 @@ export default class AudioScene {
310
174
  this.setTargetGain(0);
311
175
  }
312
176
 
177
+ /**
178
+ * Unmute all audio in this scene
179
+ * - Restores volume level from before muting
180
+ * - Safe to call multiple times
181
+ * - No effect if scene is not currently muted
182
+ */
313
183
  unmute() {
314
184
  if( !this.muted )
315
185
  {
@@ -0,0 +1,195 @@
1
+ # Audio Loaders
2
+
3
+ Audio loading and scene management for web applications using the Web Audio API.
4
+
5
+ ## Overview
6
+
7
+ The audio loading system consists of two main components:
8
+
9
+ - **AudioLoader** - Loads individual audio files from network sources
10
+ - **AudioScene** - Manages collections of audio sources with centralized playback control
11
+
12
+ ## AudioScene
13
+
14
+ `AudioScene` extends `SceneBase` to provide audio-specific functionality including volume control, muting, and Web Audio API integration.
15
+
16
+ ### Key Features
17
+
18
+ - **Scene-wide volume control** via gain nodes
19
+ - **Mute/unmute functionality** with volume restoration
20
+ - **Web Audio API integration** for precise audio control
21
+ - **Multiple audio source management** with unified loading states
22
+ - **Audio context management** with automatic creation or custom injection
23
+
24
+ ### Audio Context Usage
25
+
26
+ AudioScene uses the Web Audio API's `AudioContext` for advanced audio processing:
27
+
28
+ ```javascript
29
+ const audioScene = new AudioScene();
30
+
31
+ // Option 1: Use automatic context creation (default)
32
+ audioScene.load(); // Creates AudioContext internally
33
+
34
+ // Option 2: Provide your own context for integration
35
+ const customContext = new AudioContext();
36
+ audioScene.setAudioContext(customContext);
37
+ ```
38
+
39
+ ### Volume Control (Target Gain)
40
+
41
+ The scene uses a "target gain" system based on Web Audio API's gain scheduling:
42
+
43
+ - **Target Gain**: The desired volume level that the audio system transitions towards
44
+ - `0.0` = silence (muted)
45
+ - `1.0` = original volume
46
+ - `> 1.0` = amplified (use carefully to avoid distortion)
47
+
48
+ **Why "Target"?**
49
+ The Web Audio API allows smooth transitions between gain values over time. When you set a target gain, the audio system can gradually transition from the current level to the target level, preventing audio pops and clicks.
50
+
51
+ ```javascript
52
+ // Set immediate volume change to 50%
53
+ audioScene.setTargetGain(0.5);
54
+
55
+ // Future: Could support smooth transitions
56
+ // gainNode.gain.setTargetAtTime(0.5, audioContext.currentTime, 0.3);
57
+
58
+ // Get current target volume
59
+ const volume = audioScene.getTargetGain(); // 0.5
60
+
61
+ // Mute (remembers previous volume)
62
+ audioScene.mute();
63
+
64
+ // Restore previous volume
65
+ audioScene.unmute();
66
+ ```
67
+
68
+ The current implementation sets gain immediately, but the "target" naming prepares for potential smooth volume transitions in future versions.
69
+
70
+ ### Audio Source Management
71
+
72
+ Define audio sources before loading:
73
+
74
+ ```javascript
75
+ // Add audio sources
76
+ audioScene.defineMemorySource({
77
+ label: 'background-music',
78
+ url: '/audio/bg-music.mp3'
79
+ });
80
+
81
+ audioScene.defineMemorySource({
82
+ label: 'sound-effect',
83
+ url: '/audio/beep.wav'
84
+ });
85
+
86
+ // Load all sources
87
+ audioScene.load();
88
+
89
+ // Wait for loading completion
90
+ await waitForState(() => audioScene.loaded);
91
+ ```
92
+
93
+ ### Playback
94
+
95
+ Get playable audio sources after loading:
96
+
97
+ ```javascript
98
+ // Get a source node for one-time playback
99
+ const sourceNode = await audioScene.getSourceNode('sound-effect');
100
+
101
+ // Play immediately
102
+ sourceNode.start();
103
+
104
+ // Play with delay
105
+ sourceNode.start(audioContext.currentTime + 0.5);
106
+
107
+ // Cleanup is automatic when playback ends
108
+ ```
109
+
110
+ ### Web Audio API Architecture
111
+
112
+ ```
113
+ AudioSources → GainNode → AudioContext.destination → Speakers
114
+
115
+ Volume Control
116
+ ```
117
+
118
+ Each audio source connects through a gain node that provides scene-wide volume control before reaching the audio output.
119
+
120
+ ### State Management
121
+
122
+ AudioScene inherits state management from SceneBase:
123
+
124
+ - `STATE_INITIAL` - Scene created, sources defined
125
+ - `STATE_LOADING` - Audio files downloading
126
+ - `STATE_LOADED` - All sources ready for playback
127
+ - `STATE_ABORTING` - Canceling downloads
128
+ - `STATE_ABORTED` - Downloads canceled
129
+
130
+ ### Preloading with Progress and Abort
131
+
132
+ AudioScene supports convenient preloading with automatic progress tracking:
133
+
134
+ ```javascript
135
+ // Basic preload - returns promise and abort function
136
+ const { promise, abort } = audioScene.preload();
137
+
138
+ // Preload with options
139
+ const { promise, abort } = audioScene.preload({
140
+ timeoutMs: 5000, // Timeout after 5 seconds
141
+ onProgress: (progress) => {
142
+ console.log(`Loading: ${progress.sourcesLoaded}/${progress.numberOfSources}`);
143
+ console.log(`Bytes: ${progress.totalBytesLoaded}/${progress.totalSize}`);
144
+ }
145
+ });
146
+
147
+ // Wait for completion
148
+ try {
149
+ const loadedScene = await promise;
150
+ console.log('All audio loaded successfully!');
151
+ } catch (error) {
152
+ console.error('Preload failed:', error.message);
153
+ }
154
+
155
+ // Can abort anytime
156
+ document.getElementById('cancelBtn').onclick = () => abort();
157
+ ```
158
+
159
+ **Preload Features:**
160
+ - **Promise-based**: Returns `{ promise, abort }` object
161
+ - **Progress tracking**: Optional callback with loading progress
162
+ - **Timeout support**: Configurable timeout with automatic abort
163
+ - **Manual abort**: Call `abort()` function to cancel loading
164
+ - **Error handling**: Promise rejects on timeout, abort, or loading errors
165
+
166
+ ### Progress Tracking
167
+
168
+ Monitor loading progress across all sources:
169
+
170
+ ```javascript
171
+ const progress = audioScene.progress;
172
+ console.log(`${progress.sourcesLoaded}/${progress.numberOfSources} loaded`);
173
+ console.log(`${progress.totalBytesLoaded}/${progress.totalSize} bytes`);
174
+ ```
175
+
176
+ ## Best Practices
177
+
178
+ 1. **Audio Context Lifecycle**: Create AudioContext in response to user interaction to avoid browser autoplay restrictions
179
+
180
+ 2. **Memory Management**: Audio sources are loaded into memory - consider file sizes for mobile devices
181
+
182
+ 3. **Source Node Usage**: Each `getSourceNode()` call creates a new playable instance - don't reuse source nodes
183
+
184
+ 4. **Volume Levels**: Keep target gain ≤ 1.0 to avoid audio clipping and distortion
185
+
186
+ 5. **Error Handling**: Check scene state before attempting playback
187
+
188
+ ```javascript
189
+ if (audioScene.loaded) {
190
+ const source = await audioScene.getSourceNode('audio-label');
191
+ source.start();
192
+ } else {
193
+ console.warn('Audio scene not ready for playback');
194
+ }
195
+ ```
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Base class for scene loaders that manage collections of media sources
3
+ */
4
+ export default class SceneBase {
5
+ state: string;
6
+ loaded: boolean;
7
+ /**
8
+ * Get the array of sources managed by this scene
9
+ *
10
+ * @returns {Array} Array of source objects
11
+ */
12
+ get sources(): any[];
13
+ /**
14
+ * Extract the loader from a source object
15
+ *
16
+ * @param {*} source
17
+ *
18
+ * @returns {*} Loader object with progress and state properties
19
+ */
20
+ getLoaderFromSource(source: any): any;
21
+ /**
22
+ * Get scene loading progress
23
+ */
24
+ get progress(): {
25
+ totalBytesLoaded: number;
26
+ totalSize: number;
27
+ sourcesLoaded: number;
28
+ numberOfSources: number;
29
+ };
30
+ /**
31
+ * Get scene abort progress
32
+ */
33
+ get abortProgress(): {
34
+ sourcesAborted: number;
35
+ numberOfSources: number;
36
+ };
37
+ /**
38
+ * Start loading all sources
39
+ */
40
+ load(): void;
41
+ /**
42
+ * Abort loading all sources
43
+ */
44
+ abort(): void;
45
+ /**
46
+ * Preload all sources with progress tracking and abort capability
47
+ * - Starts loading and waits for completion
48
+ * - Supports timeout and progress callbacks
49
+ * - Returns object with promise and abort function
50
+ *
51
+ * @param {object} [options]
52
+ * @param {number} [options.timeoutMs=10000] - Timeout in milliseconds
53
+ * @param {Function} [options.onProgress] - Progress callback function
54
+ *
55
+ * @returns {object} Object with promise and abort function
56
+ * @returns {Promise<SceneBase>} returns.promise - Promise that resolves when loaded
57
+ * @returns {Function} returns.abort - Function to abort preloading
58
+ */
59
+ preload({ timeoutMs, onProgress }?: {
60
+ timeoutMs?: number;
61
+ onProgress?: Function;
62
+ }): object;
63
+ destroy(): void;
64
+ #private;
65
+ }
@@ -0,0 +1,283 @@
1
+ import { LoadingStateMachine } from '../../../state/machines.js';
2
+
3
+ import {
4
+ STATE_INITIAL,
5
+ STATE_LOADING,
6
+ STATE_LOADED,
7
+ STATE_ABORTING,
8
+ STATE_ABORTED,
9
+ STATE_ERROR,
10
+ LOAD,
11
+ LOADED,
12
+ ABORT,
13
+ ABORTED
14
+ } from '../../../state/machines.js';
15
+
16
+ import { waitForState } from '../../../util/svelte.js';
17
+
18
+ /**
19
+ * Base class for scene loaders that manage collections of media sources
20
+ */
21
+ export default class SceneBase {
22
+ #state = new LoadingStateMachine();
23
+
24
+ // @note this exported state is set by onenter
25
+ state = $state(STATE_INITIAL);
26
+
27
+ loaded = $derived.by(() => {
28
+ return this.state === STATE_LOADED;
29
+ });
30
+
31
+ #progress = $derived.by(() => {
32
+ let totalSize = 0;
33
+ let totalBytesLoaded = 0;
34
+ let sourcesLoaded = 0;
35
+
36
+ const sources = this.sources;
37
+ const numberOfSources = sources.length;
38
+
39
+ for (let j = 0; j < numberOfSources; j++) {
40
+ const source = sources[j];
41
+ const loader = this.getLoaderFromSource(source);
42
+
43
+ const { bytesLoaded, size, loaded } = loader.progress;
44
+
45
+ totalSize += size;
46
+ totalBytesLoaded += bytesLoaded;
47
+
48
+ if (loaded) {
49
+ sourcesLoaded++;
50
+ }
51
+ }
52
+
53
+ return {
54
+ totalBytesLoaded,
55
+ totalSize,
56
+ sourcesLoaded,
57
+ numberOfSources
58
+ };
59
+ });
60
+
61
+ #abortProgress = $derived.by(() => {
62
+ let sourcesAborted = 0;
63
+ const sources = this.sources;
64
+ const numberOfSources = sources.length;
65
+
66
+ for (let j = 0; j < numberOfSources; j++) {
67
+ const source = sources[j];
68
+ const loader = this.getLoaderFromSource(source);
69
+ const loaderState = loader.state;
70
+
71
+ if (loaderState === STATE_ABORTED || loaderState === STATE_ERROR) {
72
+ sourcesAborted++;
73
+ }
74
+ }
75
+
76
+ return {
77
+ sourcesAborted,
78
+ numberOfSources
79
+ };
80
+ });
81
+
82
+ /**
83
+ * Construct SceneBase
84
+ */
85
+ constructor() {
86
+ const state = this.#state;
87
+
88
+ $effect(() => {
89
+ if (state.current === STATE_LOADING) {
90
+ const { sourcesLoaded, numberOfSources } = this.#progress;
91
+
92
+ if (sourcesLoaded === numberOfSources) {
93
+ this.#state.send(LOADED);
94
+ }
95
+ }
96
+ });
97
+
98
+ $effect(() => {
99
+ if (state.current === STATE_ABORTING) {
100
+ const { sourcesAborted, numberOfSources } = this.#abortProgress;
101
+
102
+ if (sourcesAborted === numberOfSources) {
103
+ this.#state.send(ABORTED);
104
+ }
105
+ }
106
+ });
107
+
108
+ state.onenter = (currentState) => {
109
+ if (currentState === STATE_LOADING) {
110
+ this.#startLoading();
111
+ } else if (currentState === STATE_ABORTING) {
112
+ this.#startAbort();
113
+ }
114
+
115
+ this.state = currentState;
116
+ };
117
+ }
118
+
119
+ /* ==== Abstract methods - must be implemented by subclasses */
120
+
121
+ /**
122
+ * Get the array of sources managed by this scene
123
+ *
124
+ * @returns {Array} Array of source objects
125
+ */
126
+ get sources() {
127
+ throw new Error('Subclass must implement sources getter');
128
+ }
129
+
130
+ /**
131
+ * Extract the loader from a source object
132
+ *
133
+ * @param {*} source
134
+ *
135
+ * @returns {*} Loader object with progress and state properties
136
+ */
137
+ // eslint-disable-next-line no-unused-vars
138
+ getLoaderFromSource(source) {
139
+ throw new Error('Subclass must implement getLoaderFromSource method');
140
+ }
141
+
142
+ /* ==== Common loader interface */
143
+
144
+ /**
145
+ * Get scene loading progress
146
+ */
147
+ get progress() {
148
+ return this.#progress;
149
+ }
150
+
151
+ /**
152
+ * Get scene abort progress
153
+ */
154
+ get abortProgress() {
155
+ return this.#abortProgress;
156
+ }
157
+
158
+ /**
159
+ * Start loading all sources
160
+ */
161
+ load() {
162
+ this.#state.send(LOAD);
163
+ }
164
+
165
+ /**
166
+ * Abort loading all sources
167
+ */
168
+ abort() {
169
+ this.#state.send(ABORT);
170
+ }
171
+
172
+ /**
173
+ * Preload all sources with progress tracking and abort capability
174
+ * - Starts loading and waits for completion
175
+ * - Supports timeout and progress callbacks
176
+ * - Returns object with promise and abort function
177
+ *
178
+ * @param {object} [options]
179
+ * @param {number} [options.timeoutMs=10000] - Timeout in milliseconds
180
+ * @param {Function} [options.onProgress] - Progress callback function
181
+ *
182
+ * @returns {object} Object with promise and abort function
183
+ * @returns {Promise<SceneBase>} returns.promise - Promise that resolves when loaded
184
+ * @returns {Function} returns.abort - Function to abort preloading
185
+ */
186
+ preload({ timeoutMs = 10000, onProgress } = {}) {
187
+ let timeoutId = null;
188
+ let progressIntervalId = null;
189
+ let isAborted = false;
190
+
191
+ const abort = () => {
192
+ if (isAborted) return;
193
+ isAborted = true;
194
+
195
+ if (timeoutId) {
196
+ clearTimeout(timeoutId);
197
+ timeoutId = null;
198
+ }
199
+
200
+ if (progressIntervalId) {
201
+ clearInterval(progressIntervalId);
202
+ progressIntervalId = null;
203
+ }
204
+
205
+ this.abort();
206
+ };
207
+
208
+ const promise = new Promise((resolve, reject) => {
209
+ // Set up progress tracking with polling
210
+ if (onProgress) {
211
+ progressIntervalId = setInterval(() => {
212
+ if (!isAborted) {
213
+ onProgress(this.progress);
214
+ }
215
+ }, 50); // Poll every 50ms
216
+ }
217
+
218
+ // Set up timeout
219
+ if (timeoutMs > 0) {
220
+ timeoutId = setTimeout(() => {
221
+ abort();
222
+ reject(new Error(`Preload timed out after ${timeoutMs}ms`));
223
+ }, timeoutMs);
224
+ }
225
+
226
+ // Start loading
227
+ this.load();
228
+
229
+ // Wait for completion with extended timeout
230
+ const waitTimeout = Math.max(timeoutMs + 1000, 2000);
231
+ waitForState(() => {
232
+ return this.loaded ||
233
+ this.state === STATE_ABORTED ||
234
+ this.state === STATE_ERROR;
235
+ }, waitTimeout)
236
+ .then(() => {
237
+ if (timeoutId) {
238
+ clearTimeout(timeoutId);
239
+ timeoutId = null;
240
+ }
241
+
242
+ if (progressIntervalId) {
243
+ clearInterval(progressIntervalId);
244
+ progressIntervalId = null;
245
+ }
246
+
247
+ if (isAborted || this.state === STATE_ABORTED) {
248
+ reject(new Error('Preload was aborted'));
249
+ } else if (this.state === STATE_ERROR) {
250
+ reject(new Error('Preload failed due to error'));
251
+ } else if (this.loaded) {
252
+ resolve(this);
253
+ } else {
254
+ reject(new Error(`Preload failed: unexpected state ${this.state}`));
255
+ }
256
+ })
257
+ .catch(reject);
258
+ });
259
+
260
+ return { promise, abort };
261
+ }
262
+
263
+ destroy() {
264
+ // TODO: disconnect all sources?
265
+ // TODO: Unload loaders?
266
+ }
267
+
268
+ /* ==== Internal methods */
269
+
270
+ #startLoading() {
271
+ for (const source of this.sources) {
272
+ const loader = this.getLoaderFromSource(source);
273
+ loader.load();
274
+ }
275
+ }
276
+
277
+ #startAbort() {
278
+ for (const source of this.sources) {
279
+ const loader = this.getLoaderFromSource(source);
280
+ loader.abort();
281
+ }
282
+ }
283
+ }
@@ -7,34 +7,21 @@
7
7
  * @property {string} label
8
8
  * @property {ImageLoader} imageLoader
9
9
  */
10
- export default class ImageScene {
11
- state: string;
12
- loaded: boolean;
10
+ export default class ImageScene extends SceneBase {
13
11
  /**
14
- * Get image scene loading progress
15
- */
16
- get progress(): {
17
- totalBytesLoaded: number;
18
- totalSize: number;
19
- sourcesLoaded: number;
20
- numberOfSources: number;
21
- };
22
- /**
23
- * Get image scene abort progress
24
- */
25
- get abortProgress(): {
26
- sourcesAborted: number;
27
- numberOfSources: number;
28
- };
29
- /**
30
- * Start loading all image sources
12
+ * Get the array of image sources managed by this scene
13
+ *
14
+ * @returns {ImageSceneSource[]}
31
15
  */
32
- load(): void;
16
+ get sources(): ImageSceneSource[];
33
17
  /**
34
- * Abort loading all image sources
18
+ * Extract the image loader from a source object
19
+ *
20
+ * @param {ImageSceneSource} source
21
+ *
22
+ * @returns {ImageLoader}
35
23
  */
36
- abort(): void;
37
- destroy(): void;
24
+ getLoaderFromSource(source: ImageSceneSource): ImageLoader;
38
25
  /**
39
26
  * Add image source
40
27
  * - Uses an ImageLoader instance to load image data from network
@@ -84,4 +71,5 @@ export type ImageSceneSource = {
84
71
  label: string;
85
72
  imageLoader: ImageLoader;
86
73
  };
74
+ import SceneBase from '../base/SceneBase.svelte.js';
87
75
  import ImageLoader from './ImageLoader.svelte.js';
@@ -2,21 +2,7 @@
2
2
 
3
3
  import * as expect from '../../../util/expect.js';
4
4
 
5
- import { LoadingStateMachine } from '../../../state/machines.js';
6
-
7
- import {
8
- STATE_INITIAL,
9
- STATE_LOADING,
10
- STATE_LOADED,
11
- STATE_ABORTING,
12
- STATE_ABORTED,
13
- STATE_ERROR,
14
- LOAD,
15
- LOADED,
16
- ABORT,
17
- ABORTED
18
- } from '../../../state/machines.js';
19
-
5
+ import SceneBase from '../base/SceneBase.svelte.js';
20
6
  import ImageLoader from './ImageLoader.svelte.js';
21
7
 
22
8
  /**
@@ -30,152 +16,40 @@ import ImageLoader from './ImageLoader.svelte.js';
30
16
  * @property {ImageLoader} imageLoader
31
17
  */
32
18
 
33
- export default class ImageScene {
34
- #state = new LoadingStateMachine();
35
-
36
- // @note this exported state is set by onenter
37
- state = $state(STATE_INITIAL);
38
-
39
- loaded = $derived.by(() => {
40
- return this.state === STATE_LOADED;
41
- });
19
+ export default class ImageScene extends SceneBase {
42
20
 
43
21
  /** @type {ImageSceneSource[]} */
44
22
  #imageSources = $state([]);
45
23
 
46
- #progress = $derived.by(() => {
47
- // console.log('update progress');
48
-
49
- let totalSize = 0;
50
- let totalBytesLoaded = 0;
51
- let sourcesLoaded = 0;
52
-
53
- const sources = this.#imageSources;
54
- const numberOfSources = sources.length;
55
-
56
- for (let j = 0; j < numberOfSources; j++) {
57
- const source = sources[j];
58
- const { imageLoader } = source;
59
-
60
- const { bytesLoaded, size, loaded } = imageLoader.progress;
61
-
62
- totalSize += size;
63
- totalBytesLoaded += bytesLoaded;
64
-
65
- if (loaded) {
66
- sourcesLoaded++;
67
- }
68
- } // end for
69
-
70
- return {
71
- totalBytesLoaded,
72
- totalSize,
73
- sourcesLoaded,
74
- numberOfSources
75
- };
76
- });
77
-
78
- #abortProgress = $derived.by(() => {
79
- let sourcesAborted = 0;
80
- const sources = this.#imageSources;
81
- const numberOfSources = sources.length;
82
-
83
- for (let j = 0; j < numberOfSources; j++) {
84
- const source = sources[j];
85
- const { imageLoader } = source;
86
- const loaderState = imageLoader.state;
87
-
88
- if (loaderState === STATE_ABORTED || loaderState === STATE_ERROR) {
89
- sourcesAborted++;
90
- }
91
- } // end for
92
-
93
- return {
94
- sourcesAborted,
95
- numberOfSources
96
- };
97
- });
98
-
99
- #sourcesLoaded = $derived( this.#progress.sourcesLoaded );
100
- #numberOfSources = $derived( this.#progress.numberOfSources );
101
24
 
102
25
  /**
103
26
  * Construct ImageScene
104
27
  */
105
28
  constructor() {
106
- const state = this.#state;
107
-
108
- $effect( () => {
109
- if (state.current === STATE_LOADING) {
110
- if (this.#sourcesLoaded === this.#numberOfSources) {
111
- // console.log(`All [${this.#numberOfSources}] sources loaded`);
112
- this.#state.send(LOADED);
113
- }
114
- }
115
- } );
116
-
117
- $effect(() => {
118
- if (state.current === STATE_ABORTING) {
119
- const { sourcesAborted, numberOfSources } = this.#abortProgress;
120
-
121
- if (sourcesAborted === numberOfSources) {
122
- // console.debug(`ImageScene: ${numberOfSources} sources aborted`);
123
- this.#state.send(ABORTED);
124
- }
125
- }
126
- });
127
-
128
- state.onenter = ( currentState ) => {
129
- // console.log('onenter', currentState );
130
-
131
- if(currentState === STATE_LOADING )
132
- {
133
- // console.log('ImageScene:loading');
134
- this.#startLoading();
135
- }
136
- else if(currentState === STATE_ABORTING )
137
- {
138
- // console.log('ImageScene:aborting');
139
- this.#startAbort();
140
- }
141
-
142
- this.state = currentState;
143
- };
144
- }
145
-
146
- /* ==== Common loader interface */
147
-
148
- /**
149
- * Get image scene loading progress
150
- */
151
- get progress() {
152
- return this.#progress;
29
+ super();
153
30
  }
154
31
 
155
- /**
156
- * Get image scene abort progress
157
- */
158
- get abortProgress() {
159
- return this.#abortProgress;
160
- }
32
+ /* ==== SceneBase implementation */
161
33
 
162
34
  /**
163
- * Start loading all image sources
35
+ * Get the array of image sources managed by this scene
36
+ *
37
+ * @returns {ImageSceneSource[]}
164
38
  */
165
- load() {
166
- this.#state.send(LOAD);
39
+ get sources() {
40
+ return this.#imageSources;
167
41
  }
168
42
 
169
43
  /**
170
- * Abort loading all image sources
44
+ * Extract the image loader from a source object
45
+ *
46
+ * @param {ImageSceneSource} source
47
+ *
48
+ * @returns {ImageLoader}
171
49
  */
172
- abort() {
173
- this.#state.send(ABORT);
174
- }
175
-
176
- destroy() {
177
- // TODO: disconnect all image sources?
178
- // TODO: Unload ImageLoaders?
50
+ // eslint-disable-next-line no-unused-vars
51
+ getLoaderFromSource(source) {
52
+ return source.imageLoader;
179
53
  }
180
54
 
181
55
  /* ==== Source definitions */
@@ -241,18 +115,6 @@ export default class ImageScene {
241
115
  return source.imageLoader.getObjectURL();
242
116
  }
243
117
 
244
- async #startLoading() {
245
- for (const { imageLoader } of this.#imageSources) {
246
- imageLoader.load();
247
- }
248
- }
249
-
250
- #startAbort() {
251
- for (const { imageLoader } of this.#imageSources) {
252
- imageLoader.abort();
253
- }
254
- }
255
-
256
118
  /* ==== Internals */
257
119
 
258
120
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hkdigital/lib-core",
3
- "version": "0.4.27",
3
+ "version": "0.4.28",
4
4
  "author": {
5
5
  "name": "HKdigital",
6
6
  "url": "https://hkdigital.nl"