@replayablejs/runtime 0.1.0-alpha.2 → 0.1.0-alpha.3

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.
@@ -1,4 +1,4 @@
1
- import { t as AudioController } from "../audio-CyLjSreN.js";
1
+ import { t as AudioController } from "../audio-DTYPqdzS.js";
2
2
  //#region src/audio/disabled.d.ts
3
3
  /**
4
4
  * Returns the no-op facade used by variants whose audio capability is disabled.
@@ -1,3 +1,4 @@
1
+ import { i as resolveAudioVolume } from "../resolve-options-CqtH9GnM.js";
1
2
  //#region src/audio/disabled.ts
2
3
  /**
3
4
  * Stable handle returned for every disabled managed-playback request.
@@ -6,7 +7,14 @@
6
7
  * checking whether the current variant includes audio. It is safe to reuse
7
8
  * because the handle contains no playback ID or mutable state.
8
9
  */
9
- const disabledPlayback = { stop() {} };
10
+ const disabledPlayback = {
11
+ position: 0,
12
+ duration: 0,
13
+ setVolume(volume) {
14
+ resolveAudioVolume(volume);
15
+ },
16
+ stop() {}
17
+ };
10
18
  /**
11
19
  * Complete no-op audio controller selected for an audio-disabled variant.
12
20
  *
@@ -1 +1 @@
1
- {"version":3,"file":"disabled.js","names":[],"sources":["../../src/audio/disabled.ts"],"sourcesContent":["import type { AudioController, AudioPlayback } from '#types/audio.js';\n\n/**\n * Stable handle returned for every disabled managed-playback request.\n *\n * Shared application code may retain this handle and call `stop()` without\n * checking whether the current variant includes audio. It is safe to reuse\n * because the handle contains no playback ID or mutable state.\n */\nconst disabledPlayback: AudioPlayback = {\n stop(): void {},\n};\n\n/**\n * Complete no-op audio controller selected for an audio-disabled variant.\n *\n * This module deliberately imports no Howler implementation and registers no\n * sound loader. Consequently, disabled builds require neither sound resources\n * nor audio lifecycle work while exposing exactly the same application API.\n * Transient effects, host updates, visibility changes, and unlock attempts are\n * all intentionally ignored.\n */\nconst disabledAudio: AudioController = {\n get muted(): boolean {\n return true;\n },\n setMuted(): void {},\n play(): AudioPlayback {\n return disabledPlayback;\n },\n playOneShot(): void {},\n setVisible(): void {},\n update(): void {},\n unlock(): void {},\n};\n\n/**\n * Returns the no-op facade used by variants whose audio capability is disabled.\n *\n * Reusing this singleton is safe because both the controller and its returned\n * playback handle are stateless.\n */\nexport function createAudio(): AudioController {\n return disabledAudio;\n}\n"],"mappings":";;;;;;;;AASA,MAAM,mBAAkC,EACtC,OAAa,CAAC,EAChB;;;;;;;;;;AAWA,MAAM,gBAAiC;CACrC,IAAI,QAAiB;EACnB,OAAO;CACT;CACA,WAAiB,CAAC;CAClB,OAAsB;EACpB,OAAO;CACT;CACA,cAAoB,CAAC;CACrB,aAAmB,CAAC;CACpB,SAAe,CAAC;CAChB,SAAe,CAAC;AAClB;;;;;;;AAQA,SAAgB,cAA+B;CAC7C,OAAO;AACT"}
1
+ {"version":3,"file":"disabled.js","names":[],"sources":["../../src/audio/disabled.ts"],"sourcesContent":["import { resolveAudioVolume } from '#audio/resolve-options.js';\nimport type { AudioController, AudioPlayback } from '#types/audio.js';\n\n/**\n * Stable handle returned for every disabled managed-playback request.\n *\n * Shared application code may retain this handle and call `stop()` without\n * checking whether the current variant includes audio. It is safe to reuse\n * because the handle contains no playback ID or mutable state.\n */\nconst disabledPlayback: AudioPlayback = {\n position: 0,\n duration: 0,\n setVolume(volume): void {\n resolveAudioVolume(volume);\n },\n stop(): void {},\n};\n\n/**\n * Complete no-op audio controller selected for an audio-disabled variant.\n *\n * This module deliberately imports no Howler implementation and registers no\n * sound loader. Consequently, disabled builds require neither sound resources\n * nor audio lifecycle work while exposing exactly the same application API.\n * Transient effects, host updates, visibility changes, and unlock attempts are\n * all intentionally ignored.\n */\nconst disabledAudio: AudioController = {\n get muted(): boolean {\n return true;\n },\n setMuted(): void {},\n play(): AudioPlayback {\n return disabledPlayback;\n },\n playOneShot(): void {},\n setVisible(): void {},\n update(): void {},\n unlock(): void {},\n};\n\n/**\n * Returns the no-op facade used by variants whose audio capability is disabled.\n *\n * Reusing this singleton is safe because both the controller and its returned\n * playback handle are stateless.\n */\nexport function createAudio(): AudioController {\n return disabledAudio;\n}\n"],"mappings":";;;;;;;;;AAUA,MAAM,mBAAkC;CACtC,UAAU;CACV,UAAU;CACV,UAAU,QAAc;EACtB,mBAAmB,MAAM;CAC3B;CACA,OAAa,CAAC;AAChB;;;;;;;;;;AAWA,MAAM,gBAAiC;CACrC,IAAI,QAAiB;EACnB,OAAO;CACT;CACA,WAAiB,CAAC;CAClB,OAAsB;EACpB,OAAO;CACT;CACA,cAAoB,CAAC;CACrB,aAAmB,CAAC;CACpB,SAAe,CAAC;CAChB,SAAe,CAAC;AAClB;;;;;;;AAQA,SAAgB,cAA+B;CAC7C,OAAO;AACT"}
@@ -1,4 +1,4 @@
1
- import { t as AudioController } from "../audio-CyLjSreN.js";
1
+ import { t as AudioController } from "../audio-DTYPqdzS.js";
2
2
  import { s as AssetLoader } from "../loader-Czz-x3KH.js";
3
3
  //#region src/audio/enabled.d.ts
4
4
  /**
@@ -1,3 +1,4 @@
1
+ import { i as resolveAudioVolume, n as resolveAudioPlaybackOptions, r as resolveAudioStopOptions, t as resolveAudioOneShotOptions } from "../resolve-options-CqtH9GnM.js";
1
2
  import { Howl, Howler } from "howler/dist/howler.core.min.js";
2
3
  //#region src/audio/howler-lifecycle.ts
3
4
  const CONTEXT_RESUME_DELAY_MS = 200;
@@ -52,86 +53,6 @@ function createHowlerLifecycle() {
52
53
  }
53
54
  }
54
55
  //#endregion
55
- //#region src/audio/resolve-options.ts
56
- const DEFAULT_FADE_DURATION = 0;
57
- const DEFAULT_VOLUME = 1;
58
- /**
59
- * Produces the complete options used to create one managed playback.
60
- *
61
- * Defaults are applied once at the public API boundary so the playback state
62
- * machine never needs to distinguish omitted values from explicit values:
63
- *
64
- * ```ts
65
- * resolveAudioPlaybackOptions();
66
- * // { volume: 1, loop: false, fadeIn: 0 }
67
- *
68
- * resolveAudioPlaybackOptions({ loop: true, volume: 0.5 });
69
- * // { volume: 0.5, loop: true, fadeIn: 0 }
70
- * ```
71
- *
72
- * Invalid numeric values throw before a Howler voice is created.
73
- */
74
- function resolveAudioPlaybackOptions(options = {}) {
75
- return {
76
- volume: resolveVolume(options.volume),
77
- loop: options.loop ?? false,
78
- fadeIn: resolveFadeDuration("fade-in", options.fadeIn)
79
- };
80
- }
81
- /**
82
- * Produces the complete options used by one transient sound effect.
83
- *
84
- * ```ts
85
- * resolveAudioOneShotOptions();
86
- * // { volume: 1 }
87
- *
88
- * resolveAudioOneShotOptions({ volume: 0.8 });
89
- * // { volume: 0.8 }
90
- * ```
91
- */
92
- function resolveAudioOneShotOptions(options = {}) {
93
- return { volume: resolveVolume(options.volume) };
94
- }
95
- /**
96
- * Produces the complete options used to stop managed playback.
97
- *
98
- * ```ts
99
- * resolveAudioStopOptions();
100
- * // { fadeOut: 0 }
101
- *
102
- * resolveAudioStopOptions({ fadeOut: 0.4 });
103
- * // { fadeOut: 0.4 }
104
- * ```
105
- *
106
- * A zero duration stops immediately; a positive duration is converted to
107
- * milliseconds only when the managed playback calls Howler.
108
- */
109
- function resolveAudioStopOptions(options = {}) {
110
- return { fadeOut: resolveFadeDuration("fade-out", options.fadeOut) };
111
- }
112
- /**
113
- * Resolves public volume onto Howler's normalized linear gain range.
114
- *
115
- * `Number.isFinite` deliberately rejects `NaN` and both infinities in addition
116
- * to values outside the inclusive `0..1` range.
117
- */
118
- function resolveVolume(volume = DEFAULT_VOLUME) {
119
- if (!Number.isFinite(volume) || volume < 0 || volume > 1) throw new RangeError(`Audio volume must be a finite number from 0 through 1; received ${String(volume)}.`);
120
- return volume;
121
- }
122
- /**
123
- * Resolves one public fade duration expressed in Replayable-standard seconds.
124
- *
125
- * Keeping seconds in the public API matches Replayable's update and fixed-update
126
- * timing contracts. Only the Howler integration converts this value to
127
- * milliseconds. `Number.isFinite` also prevents `NaN` and infinity from
128
- * reaching browser timers.
129
- */
130
- function resolveFadeDuration(name, duration = DEFAULT_FADE_DURATION) {
131
- if (!Number.isFinite(duration) || duration < 0) throw new RangeError(`Audio ${name} duration must be a finite, non-negative number of seconds; received ${String(duration)}.`);
132
- return duration;
133
- }
134
- //#endregion
135
56
  //#region src/audio/managed-playback.ts
136
57
  const MILLISECONDS_PER_SECOND = 1e3;
137
58
  /**
@@ -165,17 +86,38 @@ const MILLISECONDS_PER_SECOND = 1e3;
165
86
  * control.
166
87
  *
167
88
  * `start` is invoked by the audio controller when loading or permission opens a gate.
168
- * `stop` is the stable public handle returned to playable application code.
89
+ * Public controls and metadata remain available on the same handle after finishing.
169
90
  */
170
91
  function createManagedPlayback(options, onFinished) {
171
92
  let activeVoice;
172
93
  let endListener;
173
94
  let sound;
174
95
  let status = "pending";
96
+ let volume = options.volume;
97
+ let soundDuration = 0;
175
98
  const playback = {
99
+ get position() {
100
+ if (activeVoice === void 0) return 0;
101
+ const position = activeVoice.sound.seek(activeVoice.id);
102
+ return options.loop && soundDuration > 0 ? position % soundDuration : position;
103
+ },
104
+ get duration() {
105
+ return soundDuration;
106
+ },
107
+ setVolume(nextVolume) {
108
+ resolveAudioVolume(nextVolume);
109
+ if (status === "stopping" || status === "finished") return;
110
+ volume = nextVolume;
111
+ if (activeVoice !== void 0) {
112
+ const { sound: activeSound, id } = activeVoice;
113
+ activeSound.volume(volume, id);
114
+ activeSound.volume(volume, id);
115
+ }
116
+ },
176
117
  setSound(loadedSound) {
177
118
  if (status !== "pending" || sound !== void 0) return;
178
119
  sound = loadedSound;
120
+ soundDuration = loadedSound.duration();
179
121
  },
180
122
  start() {
181
123
  if (status !== "pending" || sound === void 0) return;
@@ -186,10 +128,10 @@ function createManagedPlayback(options, onFinished) {
186
128
  };
187
129
  status = "playing";
188
130
  const fadeInMilliseconds = options.fadeIn * MILLISECONDS_PER_SECOND;
189
- const shouldFadeIn = fadeInMilliseconds > 0 && options.volume > 0;
190
- sound.volume(shouldFadeIn ? 0 : options.volume, playbackId);
131
+ const shouldFadeIn = fadeInMilliseconds > 0 && volume > 0;
132
+ sound.volume(shouldFadeIn ? 0 : volume, playbackId);
191
133
  sound.loop(options.loop, playbackId);
192
- if (shouldFadeIn) sound.fade(0, options.volume, fadeInMilliseconds, playbackId);
134
+ if (shouldFadeIn) sound.fade(0, volume, fadeInMilliseconds, playbackId);
193
135
  if (!options.loop) listenForEnd();
194
136
  },
195
137
  stop(stopOptions) {
@@ -1 +1 @@
1
- {"version":3,"file":"enabled.js","names":[],"sources":["../../src/audio/howler-lifecycle.ts","../../src/audio/resolve-options.ts","../../src/audio/managed-playback.ts","../../src/audio/sound-loader.ts","../../src/audio/enabled.ts"],"sourcesContent":["import { Howler } from '#audio/howler.js';\nimport type { RuntimeAudioState } from '#types/audio.js';\n\nconst CONTEXT_RESUME_DELAY_MS = 200;\n\n/**\n * Coordinates Howler with Replayable's normalized host lifecycle.\n *\n * Howler's automatic suspension is disabled because iOS Safari can leave its\n * `AudioContext` suspended after an ad becomes visible again. The WebKit\n * workaround suspends immediately and schedules an independent resume 200 ms\n * later. The recovery deliberately does not sequence those operations through\n * promise settlement: affected WebKit versions can leave an audio-context\n * transition promise unsettled indefinitely.\n *\n * A subsequent hidden transition cancels the pending resume and suspends the\n * context again, preventing the delayed recovery from starting background\n * audio.\n *\n * @see https://bugs.webkit.org/show_bug.cgi?id=276016\n * @see https://bugs.webkit.org/show_bug.cgi?id=281566\n */\nexport function createHowlerLifecycle() {\n let recoveryTimeout: number | undefined;\n let visible = false;\n\n Howler.autoSuspend = false;\n Howler.mute(true);\n\n return {\n setVisible(nextVisible: boolean): void {\n visible = nextVisible;\n clearRecoveryTimeout();\n\n if (!Howler.usingWebAudio) {\n return;\n }\n\n // Do not await this promise. The following resume must remain\n // independently scheduled rather than depending on WebKit settling the\n // preceding audio-context transition.\n void Howler.ctx.suspend().catch(() => {\n // The context may already be closed or unavailable.\n });\n\n if (!visible) {\n return;\n }\n\n // WebKit's published reproduction uses 200 ms to let the visibility\n // transition settle before attempting to resume the context.\n recoveryTimeout = window.setTimeout(() => {\n recoveryTimeout = undefined;\n\n if (!visible) {\n return;\n }\n\n void Howler.ctx.resume().catch(() => {\n // A later trusted interaction or visibility transition can retry.\n });\n }, CONTEXT_RESUME_DELAY_MS);\n },\n\n update({ allowed, volume }: RuntimeAudioState, muted: boolean): void {\n Howler.volume(volume);\n Howler.mute(!allowed || muted);\n },\n\n unlock(): void {\n if (!Howler.usingWebAudio || Howler.ctx.state === 'running') {\n return;\n }\n\n // Invocation—not promise settlement—must occur in the trusted event task.\n // Howler also installs an unlock listener; this explicit attempt makes the\n // runtime contract independent of that internal implementation detail.\n void Howler.ctx.resume().catch(() => {\n // A later trusted interaction or visibility recovery can retry.\n });\n },\n };\n\n function clearRecoveryTimeout(): void {\n if (recoveryTimeout === undefined) {\n return;\n }\n\n window.clearTimeout(recoveryTimeout);\n recoveryTimeout = undefined;\n }\n}\n","import type { AudioOneShotOptions, AudioPlaybackOptions, AudioStopOptions } from '#types/audio.js';\n\nconst DEFAULT_FADE_DURATION = 0;\nconst DEFAULT_VOLUME = 1;\n\n/**\n * Produces the complete options used to create one managed playback.\n *\n * Defaults are applied once at the public API boundary so the playback state\n * machine never needs to distinguish omitted values from explicit values:\n *\n * ```ts\n * resolveAudioPlaybackOptions();\n * // { volume: 1, loop: false, fadeIn: 0 }\n *\n * resolveAudioPlaybackOptions({ loop: true, volume: 0.5 });\n * // { volume: 0.5, loop: true, fadeIn: 0 }\n * ```\n *\n * Invalid numeric values throw before a Howler voice is created.\n */\nexport function resolveAudioPlaybackOptions(\n options: AudioPlaybackOptions = {},\n): Required<AudioPlaybackOptions> {\n return {\n volume: resolveVolume(options.volume),\n loop: options.loop ?? false,\n fadeIn: resolveFadeDuration('fade-in', options.fadeIn),\n };\n}\n\n/**\n * Produces the complete options used by one transient sound effect.\n *\n * ```ts\n * resolveAudioOneShotOptions();\n * // { volume: 1 }\n *\n * resolveAudioOneShotOptions({ volume: 0.8 });\n * // { volume: 0.8 }\n * ```\n */\nexport function resolveAudioOneShotOptions(\n options: AudioOneShotOptions = {},\n): Required<AudioOneShotOptions> {\n return {\n volume: resolveVolume(options.volume),\n };\n}\n\n/**\n * Produces the complete options used to stop managed playback.\n *\n * ```ts\n * resolveAudioStopOptions();\n * // { fadeOut: 0 }\n *\n * resolveAudioStopOptions({ fadeOut: 0.4 });\n * // { fadeOut: 0.4 }\n * ```\n *\n * A zero duration stops immediately; a positive duration is converted to\n * milliseconds only when the managed playback calls Howler.\n */\nexport function resolveAudioStopOptions(\n options: AudioStopOptions = {},\n): Required<AudioStopOptions> {\n return {\n fadeOut: resolveFadeDuration('fade-out', options.fadeOut),\n };\n}\n\n/**\n * Resolves public volume onto Howler's normalized linear gain range.\n *\n * `Number.isFinite` deliberately rejects `NaN` and both infinities in addition\n * to values outside the inclusive `0..1` range.\n */\nfunction resolveVolume(volume = DEFAULT_VOLUME): number {\n if (!Number.isFinite(volume) || volume < 0 || volume > 1) {\n throw new RangeError(\n `Audio volume must be a finite number from 0 through 1; received ${String(volume)}.`,\n );\n }\n\n return volume;\n}\n\n/**\n * Resolves one public fade duration expressed in Replayable-standard seconds.\n *\n * Keeping seconds in the public API matches Replayable's update and fixed-update\n * timing contracts. Only the Howler integration converts this value to\n * milliseconds. `Number.isFinite` also prevents `NaN` and infinity from\n * reaching browser timers.\n */\nfunction resolveFadeDuration(\n name: 'fade-in' | 'fade-out',\n duration = DEFAULT_FADE_DURATION,\n): number {\n if (!Number.isFinite(duration) || duration < 0) {\n throw new RangeError(\n `Audio ${name} duration must be a finite, non-negative number of seconds; received ${String(duration)}.`,\n );\n }\n\n return duration;\n}\n","import type { Howl, HowlCallback } from '#audio/howler.js';\nimport { resolveAudioStopOptions } from '#audio/resolve-options.js';\nimport type {\n PlaybackStatus,\n ActiveVoice,\n AudioPlaybackOptions,\n AudioStopOptions,\n ManagedAudioPlayback,\n} from '#types/audio.js';\n\nconst MILLISECONDS_PER_SECOND = 1000;\n\n/**\n * Creates one managed Howler voice that may wait for both its sound and unlock.\n *\n * A playback exists as a single object and moves through this lifecycle:\n *\n * 1. It starts pending, without requiring a loaded sound or allocating a voice.\n * 2. `setSound()` stores the loaded `Howl`; playback is still pending.\n * 3. `start()` allocates one voice and records its ID.\n * 4. A non-looping voice finishes through Howler's `end` event, while a loop\n * remains active until the application calls `stop()`.\n * 5. Stopping (immediate or faded) removes listeners, stops the exact voice,\n * and calls `onFinished` so the orchestrator can forget the handle.\n *\n * ```ts\n * const playback = createManagedPlayback({\n * fadeIn: 0.5,\n * loop: true,\n * volume: 0.6,\n * }, removePlayback);\n *\n * playback.setSound(sound);\n * playback.start();\n * playback.stop({ fadeOut: 0.4 });\n * ```\n *\n * Calling `stop()` before `start()` simply finishes the pending handle: no voice\n * is created and later load/unlock events will not restart it. Howler operations\n * are routed by this handle's playback ID so shared source `Howl`s do not mix\n * control.\n *\n * `start` is invoked by the audio controller when loading or permission opens a gate.\n * `stop` is the stable public handle returned to playable application code.\n */\nexport function createManagedPlayback(\n options: Required<AudioPlaybackOptions>,\n onFinished: (playback: ManagedAudioPlayback) => void,\n): ManagedAudioPlayback {\n let activeVoice: ActiveVoice | undefined;\n let endListener: HowlCallback | undefined;\n let sound: Howl | undefined;\n let status: PlaybackStatus = 'pending';\n\n const playback: ManagedAudioPlayback = {\n setSound(loadedSound): void {\n if (status !== 'pending' || sound !== undefined) {\n return;\n }\n\n sound = loadedSound;\n },\n\n start(): void {\n if (status !== 'pending' || sound === undefined) {\n return;\n }\n\n // Howler returns a new ID for this voice even when another handle is\n // already playing the same source sound.\n const playbackId = sound.play();\n\n activeVoice = { id: playbackId, sound };\n status = 'playing';\n\n const fadeInMilliseconds = options.fadeIn * MILLISECONDS_PER_SECOND;\n const shouldFadeIn = fadeInMilliseconds > 0 && options.volume > 0;\n\n // Volume and looping are scoped to this ID rather than the shared Howl.\n sound.volume(shouldFadeIn ? 0 : options.volume, playbackId);\n sound.loop(options.loop, playbackId);\n\n if (shouldFadeIn) {\n sound.fade(0, options.volume, fadeInMilliseconds, playbackId);\n }\n\n if (!options.loop) {\n listenForEnd();\n }\n },\n\n stop(stopOptions?: AudioStopOptions): void {\n if (status === 'stopping' || status === 'finished') {\n return;\n }\n\n const { fadeOut } = resolveAudioStopOptions(stopOptions);\n\n if (activeVoice === undefined || fadeOut === 0) {\n finish();\n\n return;\n }\n\n // The handle rejects repeated commands while the underlying voice remains\n // audible until its fade completes.\n status = 'stopping';\n removeEndListener();\n fadeOutPlayback(fadeOut);\n },\n };\n\n return playback;\n\n /** Stops this voice after fading from its current volume to silence. */\n function fadeOutPlayback(duration: number): void {\n if (activeVoice === undefined) {\n return;\n }\n\n const { id, sound: activeSound } = activeVoice;\n const currentVolume = activeSound.volume(id);\n\n if (typeof currentVolume !== 'number') {\n throw new Error(`Unable to read volume for Howler playback ${id}.`);\n }\n\n if (currentVolume === 0) {\n finish();\n\n return;\n }\n\n const handleFade = (fadedPlaybackId: number): void => {\n if (fadedPlaybackId !== activeVoice?.id) {\n return;\n }\n\n finish();\n };\n\n // Starting the new fade first replaces an in-progress fade-in. Howler emits\n // `fade` when cancelling that old transition, so Replayable subscribes only\n // after the replacement begins and waits for the fade-out itself.\n activeSound.fade(currentVolume, 0, duration * MILLISECONDS_PER_SECOND, id);\n activeSound.once('fade', handleFade, id);\n }\n\n /**\n * Retires this handle and releases its pending or active Howler voice.\n *\n * `onFinished` removes the handle from the orchestrator's retained set. It is\n * called for pending cancellation, natural completion, and explicit stopping.\n */\n function finish(): void {\n status = 'finished';\n stopActivePlayback();\n onFinished(playback);\n }\n\n /** Lets a finite, non-looping sound release itself after natural completion. */\n function listenForEnd(): void {\n if (activeVoice === undefined) {\n return;\n }\n\n const { id, sound: activeSound } = activeVoice;\n const handleEnd = (endedPlaybackId: number): void => {\n if (endedPlaybackId !== activeVoice?.id) {\n return;\n }\n\n endListener = undefined;\n finish();\n };\n\n endListener = handleEnd;\n activeSound.once('end', handleEnd, id);\n }\n\n /**\n * Stops the active voice and releases Replayable's Howler listeners.\n *\n * Howler emits `stop`, not `end`, for this operation. The `end` listener is\n * removed because its ID-scoped callback is no longer useful and would\n * otherwise remain retained by the shared `Howl`.\n */\n function stopActivePlayback(): void {\n if (activeVoice === undefined) {\n return;\n }\n\n const { id, sound: activeSound } = activeVoice;\n\n removeEndListener();\n activeVoice = undefined;\n activeSound.stop(id);\n }\n\n function removeEndListener(): void {\n if (endListener === undefined || activeVoice === undefined) {\n return;\n }\n\n activeVoice.sound.off('end', endListener, activeVoice.id);\n endListener = undefined;\n }\n}\n","import { Howl, type HowlErrorCallback } from '#audio/howler.js';\nimport type { AssetLoadContext, AssetLoader } from '#types/loader.js';\n\n/** Returns one loaded sound, or `undefined` while its bundle remains unloaded. */\nexport function getLoadedSound(loader: AssetLoader, id: string): Howl | undefined {\n const sound = loader.cache.sounds?.[id];\n\n return sound instanceof Howl ? sound : undefined;\n}\n\n/**\n * Converts one generated sound URL into a completely loaded Howler sound.\n *\n * Replayable's asset loader calls this handler once for every sound in the\n * requested bundle. Given this context:\n *\n * ```ts\n * {\n * category: 'sounds',\n * id: 'ui-click',\n * source: '/assets/ui-click.m4a',\n * }\n * ```\n *\n * the promise resolves with a `Howl` only after its `load` event. The asset\n * loader then stores that value at `cache.sounds['ui-click']`. Consequently,\n * `loader.load('primary')` does not finish until every primary sound is ready\n * for synchronous `play()` calls.\n *\n * A load failure unloads the partial Howler resource before rejecting the\n * containing bundle load with the generated asset ID in the error message.\n */\nexport function loadSound({ id, source }: AssetLoadContext<'sounds'>): Promise<Howl> {\n return new Promise((resolve, reject) => {\n // Manual loading lets Replayable attach both terminal listeners before any\n // network or data-URL decoding work can complete.\n const sound = new Howl({ preload: false, src: source });\n\n const handleLoad = (): void => {\n // The opposite terminal event can no longer occur for this load. Removing\n // it releases the settled promise closure retained by the cached Howl.\n sound.off('loaderror', handleLoadError);\n resolve(sound);\n };\n\n const handleLoadError: HowlErrorCallback = (_soundId, error): void => {\n sound.off('load', handleLoad);\n\n // Release any partially allocated HTMLAudio or Web Audio resources.\n sound.unload();\n reject(new Error(`Unable to load sound ${JSON.stringify(id)}: ${String(error)}.`));\n };\n\n sound.once('load', handleLoad);\n sound.once('loaderror', handleLoadError);\n sound.load();\n });\n}\n","import { createHowlerLifecycle } from '#audio/howler-lifecycle.js';\nimport type { Howl } from '#audio/howler.js';\nimport { createManagedPlayback } from '#audio/managed-playback.js';\nimport { resolveAudioOneShotOptions, resolveAudioPlaybackOptions } from '#audio/resolve-options.js';\nimport { getLoadedSound, loadSound } from '#audio/sound-loader.js';\nimport type {\n AudioController,\n AudioPlayback,\n ManagedAudioPlayback,\n RuntimeAudioState,\n} from '#types/audio.js';\nimport type { AssetLoader } from '#types/loader.js';\n\n/**\n * Creates Howler-backed sound playback for an audio-enabled variant.\n *\n * This controller coordinates three lower-level components:\n *\n * - The asset loader turns generated sound URLs into loaded `Howl` instances.\n * - The lifecycle applies host volume, visibility, and browser-unlock policy.\n * - Managed playbacks own individual voices, fades, and final cleanup.\n *\n * There is only one playback implementation (`ManagedAudioPlayback`).\n * The two internal collections below represent states of the same type:\n * 1) `managedPlaybacks` keeps every live handle.\n * 2) `unresolvedPlaybacks` indexes pending handles still missing a loaded sound.\n *\n * A managed `play()` request is retained until both gates open:\n * - sound is loaded\n * - runtime audio is allowed\n *\n * Either gate can open first. A transient `playOneShot()` is dropped while\n * audio is blocked, preventing stale effects from playing later.\n */\nexport function createAudio(loader: AssetLoader): AudioController {\n const lifecycle = createHowlerLifecycle();\n // All live handles from `play()`: pending or active.\n const managedPlaybacks = new Set<ManagedAudioPlayback>();\n // Pending-by-sound-id index for quick binding when a sound finishes loading.\n const unresolvedPlaybacks = new Map<string, Set<ManagedAudioPlayback>>();\n let allowed = false;\n let muted = false;\n let runtimeState: RuntimeAudioState | undefined;\n\n // Registering this loader callback keeps primary/secondary asset timing safe.\n loader.register('sounds', async (context) => {\n const sound = await loadSound(context);\n\n resolveSound(context.id, sound);\n\n return sound;\n });\n\n return {\n get muted(): boolean {\n return muted;\n },\n\n setMuted(nextMuted): void {\n if (muted === nextMuted) {\n return;\n }\n\n muted = nextMuted;\n\n if (runtimeState !== undefined) {\n lifecycle.update(runtimeState, muted);\n }\n },\n\n play(id, options): AudioPlayback {\n const resolvedOptions = resolveAudioPlaybackOptions(options);\n const playback = createManagedPlayback(resolvedOptions, (finishedPlayback) => {\n removeManagedPlayback(id, finishedPlayback);\n });\n const sound = getLoadedSound(loader, id);\n\n if (sound === undefined) {\n retainUnresolvedPlayback(id, playback);\n } else {\n playback.setSound(sound);\n }\n\n managedPlaybacks.add(playback);\n\n // `start()` re-checks gates for both already-ready and newly-ready cases.\n startIfAllowed(playback);\n\n return playback;\n },\n\n playOneShot(id, options): void {\n // A one-shot has no retained handle: blocked effects are intentionally\n // discarded rather than queued and replayed out of context later.\n if (!allowed) {\n return;\n }\n\n const sound = getLoadedSound(loader, id);\n\n // Unloaded one-shots are dropped intentionally and are never deferred.\n if (sound === undefined) {\n return;\n }\n\n const { volume } = resolveAudioOneShotOptions(options);\n const playbackId = sound.play();\n\n sound.volume(volume, playbackId);\n },\n\n setVisible(visible): void {\n lifecycle.setVisible(visible);\n },\n\n update(state): void {\n const becameAllowed = !allowed && state.allowed;\n allowed = state.allowed;\n runtimeState = state;\n\n // Howler volume and global muting are updated before any deferred voice\n // starts. Existing managed voices keep the same playback ID while blocked;\n // visibility policy may suspend their shared audio context, but Replayable\n // never stops and recreates them during an allowed-state transition.\n lifecycle.update(state, muted);\n\n if (becameAllowed) {\n // Start any pending voices; active voices ignore repeated start attempts.\n for (const playback of managedPlaybacks) {\n playback.start();\n }\n }\n },\n\n unlock(): void {\n lifecycle.unlock();\n },\n };\n\n /** Tracks a pending request until the sound loader resolves this asset ID. */\n function retainUnresolvedPlayback(id: string, playback: ManagedAudioPlayback): void {\n const playbacks = unresolvedPlaybacks.get(id) ?? new Set();\n\n playbacks.add(playback);\n unresolvedPlaybacks.set(id, playbacks);\n }\n\n /** Binds every pending playback for this sound ID to its loaded `Howl`. */\n function resolveSound(id: string, sound: Howl): void {\n const playbacks = unresolvedPlaybacks.get(id);\n\n if (playbacks === undefined) {\n return;\n }\n\n // Remove the index entry before notifying playbacks; a callback can clean up\n // the same playback while handling its own completion.\n unresolvedPlaybacks.delete(id);\n\n for (const playback of playbacks) {\n playback.setSound(sound);\n startIfAllowed(playback);\n }\n }\n\n /** Starts a managed handle only when permission is already allowed. */\n function startIfAllowed(playback: ManagedAudioPlayback): void {\n if (allowed) {\n playback.start();\n }\n }\n\n /** Removes the handle from both registries; pending index only if still unresolved. */\n function removeManagedPlayback(id: string, playback: ManagedAudioPlayback): void {\n managedPlaybacks.delete(playback);\n\n const playbacks = unresolvedPlaybacks.get(id);\n\n if (playbacks === undefined) {\n return;\n }\n\n playbacks.delete(playback);\n\n if (playbacks.size === 0) {\n unresolvedPlaybacks.delete(id);\n }\n }\n}\n"],"mappings":";;AAGA,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;AAmBhC,SAAgB,wBAAwB;CACtC,IAAI;CACJ,IAAI,UAAU;CAEd,OAAO,cAAc;CACrB,OAAO,KAAK,IAAI;CAEhB,OAAO;EACL,WAAW,aAA4B;GACrC,UAAU;GACV,qBAAqB;GAErB,IAAI,CAAC,OAAO,eACV;GAMF,OAAY,IAAI,QAAQ,CAAC,CAAC,YAAY,CAEtC,CAAC;GAED,IAAI,CAAC,SACH;GAKF,kBAAkB,OAAO,iBAAiB;IACxC,kBAAkB,KAAA;IAElB,IAAI,CAAC,SACH;IAGF,OAAY,IAAI,OAAO,CAAC,CAAC,YAAY,CAErC,CAAC;GACH,GAAG,uBAAuB;EAC5B;EAEA,OAAO,EAAE,SAAS,UAA6B,OAAsB;GACnE,OAAO,OAAO,MAAM;GACpB,OAAO,KAAK,CAAC,WAAW,KAAK;EAC/B;EAEA,SAAe;GACb,IAAI,CAAC,OAAO,iBAAiB,OAAO,IAAI,UAAU,WAChD;GAMF,OAAY,IAAI,OAAO,CAAC,CAAC,YAAY,CAErC,CAAC;EACH;CACF;CAEA,SAAS,uBAA6B;EACpC,IAAI,oBAAoB,KAAA,GACtB;EAGF,OAAO,aAAa,eAAe;EACnC,kBAAkB,KAAA;CACpB;AACF;;;ACzFA,MAAM,wBAAwB;AAC9B,MAAM,iBAAiB;;;;;;;;;;;;;;;;;AAkBvB,SAAgB,4BACd,UAAgC,CAAC,GACD;CAChC,OAAO;EACL,QAAQ,cAAc,QAAQ,MAAM;EACpC,MAAM,QAAQ,QAAQ;EACtB,QAAQ,oBAAoB,WAAW,QAAQ,MAAM;CACvD;AACF;;;;;;;;;;;;AAaA,SAAgB,2BACd,UAA+B,CAAC,GACD;CAC/B,OAAO,EACL,QAAQ,cAAc,QAAQ,MAAM,EACtC;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,wBACd,UAA4B,CAAC,GACD;CAC5B,OAAO,EACL,SAAS,oBAAoB,YAAY,QAAQ,OAAO,EAC1D;AACF;;;;;;;AAQA,SAAS,cAAc,SAAS,gBAAwB;CACtD,IAAI,CAAC,OAAO,SAAS,MAAM,KAAK,SAAS,KAAK,SAAS,GACrD,MAAM,IAAI,WACR,mEAAmE,OAAO,MAAM,EAAE,EACpF;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,oBACP,MACA,WAAW,uBACH;CACR,IAAI,CAAC,OAAO,SAAS,QAAQ,KAAK,WAAW,GAC3C,MAAM,IAAI,WACR,SAAS,KAAK,uEAAuE,OAAO,QAAQ,EAAE,EACxG;CAGF,OAAO;AACT;;;ACjGA,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmChC,SAAgB,sBACd,SACA,YACsB;CACtB,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI,SAAyB;CAE7B,MAAM,WAAiC;EACrC,SAAS,aAAmB;GAC1B,IAAI,WAAW,aAAa,UAAU,KAAA,GACpC;GAGF,QAAQ;EACV;EAEA,QAAc;GACZ,IAAI,WAAW,aAAa,UAAU,KAAA,GACpC;GAKF,MAAM,aAAa,MAAM,KAAK;GAE9B,cAAc;IAAE,IAAI;IAAY;GAAM;GACtC,SAAS;GAET,MAAM,qBAAqB,QAAQ,SAAS;GAC5C,MAAM,eAAe,qBAAqB,KAAK,QAAQ,SAAS;GAGhE,MAAM,OAAO,eAAe,IAAI,QAAQ,QAAQ,UAAU;GAC1D,MAAM,KAAK,QAAQ,MAAM,UAAU;GAEnC,IAAI,cACF,MAAM,KAAK,GAAG,QAAQ,QAAQ,oBAAoB,UAAU;GAG9D,IAAI,CAAC,QAAQ,MACX,aAAa;EAEjB;EAEA,KAAK,aAAsC;GACzC,IAAI,WAAW,cAAc,WAAW,YACtC;GAGF,MAAM,EAAE,YAAY,wBAAwB,WAAW;GAEvD,IAAI,gBAAgB,KAAA,KAAa,YAAY,GAAG;IAC9C,OAAO;IAEP;GACF;GAIA,SAAS;GACT,kBAAkB;GAClB,gBAAgB,OAAO;EACzB;CACF;CAEA,OAAO;;CAGP,SAAS,gBAAgB,UAAwB;EAC/C,IAAI,gBAAgB,KAAA,GAClB;EAGF,MAAM,EAAE,IAAI,OAAO,gBAAgB;EACnC,MAAM,gBAAgB,YAAY,OAAO,EAAE;EAE3C,IAAI,OAAO,kBAAkB,UAC3B,MAAM,IAAI,MAAM,6CAA6C,GAAG,EAAE;EAGpE,IAAI,kBAAkB,GAAG;GACvB,OAAO;GAEP;EACF;EAEA,MAAM,cAAc,oBAAkC;GACpD,IAAI,oBAAoB,aAAa,IACnC;GAGF,OAAO;EACT;EAKA,YAAY,KAAK,eAAe,GAAG,WAAW,yBAAyB,EAAE;EACzE,YAAY,KAAK,QAAQ,YAAY,EAAE;CACzC;;;;;;;CAQA,SAAS,SAAe;EACtB,SAAS;EACT,mBAAmB;EACnB,WAAW,QAAQ;CACrB;;CAGA,SAAS,eAAqB;EAC5B,IAAI,gBAAgB,KAAA,GAClB;EAGF,MAAM,EAAE,IAAI,OAAO,gBAAgB;EACnC,MAAM,aAAa,oBAAkC;GACnD,IAAI,oBAAoB,aAAa,IACnC;GAGF,cAAc,KAAA;GACd,OAAO;EACT;EAEA,cAAc;EACd,YAAY,KAAK,OAAO,WAAW,EAAE;CACvC;;;;;;;;CASA,SAAS,qBAA2B;EAClC,IAAI,gBAAgB,KAAA,GAClB;EAGF,MAAM,EAAE,IAAI,OAAO,gBAAgB;EAEnC,kBAAkB;EAClB,cAAc,KAAA;EACd,YAAY,KAAK,EAAE;CACrB;CAEA,SAAS,oBAA0B;EACjC,IAAI,gBAAgB,KAAA,KAAa,gBAAgB,KAAA,GAC/C;EAGF,YAAY,MAAM,IAAI,OAAO,aAAa,YAAY,EAAE;EACxD,cAAc,KAAA;CAChB;AACF;;;;AC3MA,SAAgB,eAAe,QAAqB,IAA8B;CAChF,MAAM,QAAQ,OAAO,MAAM,SAAS;CAEpC,OAAO,iBAAiB,OAAO,QAAQ,KAAA;AACzC;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,UAAU,EAAE,IAAI,UAAqD;CACnF,OAAO,IAAI,SAAS,SAAS,WAAW;EAGtC,MAAM,QAAQ,IAAI,KAAK;GAAE,SAAS;GAAO,KAAK;EAAO,CAAC;EAEtD,MAAM,mBAAyB;GAG7B,MAAM,IAAI,aAAa,eAAe;GACtC,QAAQ,KAAK;EACf;EAEA,MAAM,mBAAsC,UAAU,UAAgB;GACpE,MAAM,IAAI,QAAQ,UAAU;GAG5B,MAAM,OAAO;GACb,uBAAO,IAAI,MAAM,wBAAwB,KAAK,UAAU,EAAE,EAAE,IAAI,OAAO,KAAK,EAAE,EAAE,CAAC;EACnF;EAEA,MAAM,KAAK,QAAQ,UAAU;EAC7B,MAAM,KAAK,aAAa,eAAe;EACvC,MAAM,KAAK;CACb,CAAC;AACH;;;;;;;;;;;;;;;;;;;;;;;;ACvBA,SAAgB,YAAY,QAAsC;CAChE,MAAM,YAAY,sBAAsB;CAExC,MAAM,mCAAmB,IAAI,IAA0B;CAEvD,MAAM,sCAAsB,IAAI,IAAuC;CACvE,IAAI,UAAU;CACd,IAAI,QAAQ;CACZ,IAAI;CAGJ,OAAO,SAAS,UAAU,OAAO,YAAY;EAC3C,MAAM,QAAQ,MAAM,UAAU,OAAO;EAErC,aAAa,QAAQ,IAAI,KAAK;EAE9B,OAAO;CACT,CAAC;CAED,OAAO;EACL,IAAI,QAAiB;GACnB,OAAO;EACT;EAEA,SAAS,WAAiB;GACxB,IAAI,UAAU,WACZ;GAGF,QAAQ;GAER,IAAI,iBAAiB,KAAA,GACnB,UAAU,OAAO,cAAc,KAAK;EAExC;EAEA,KAAK,IAAI,SAAwB;GAE/B,MAAM,WAAW,sBADO,4BAA4B,OACb,IAAkB,qBAAqB;IAC5E,sBAAsB,IAAI,gBAAgB;GAC5C,CAAC;GACD,MAAM,QAAQ,eAAe,QAAQ,EAAE;GAEvC,IAAI,UAAU,KAAA,GACZ,yBAAyB,IAAI,QAAQ;QAErC,SAAS,SAAS,KAAK;GAGzB,iBAAiB,IAAI,QAAQ;GAG7B,eAAe,QAAQ;GAEvB,OAAO;EACT;EAEA,YAAY,IAAI,SAAe;GAG7B,IAAI,CAAC,SACH;GAGF,MAAM,QAAQ,eAAe,QAAQ,EAAE;GAGvC,IAAI,UAAU,KAAA,GACZ;GAGF,MAAM,EAAE,WAAW,2BAA2B,OAAO;GACrD,MAAM,aAAa,MAAM,KAAK;GAE9B,MAAM,OAAO,QAAQ,UAAU;EACjC;EAEA,WAAW,SAAe;GACxB,UAAU,WAAW,OAAO;EAC9B;EAEA,OAAO,OAAa;GAClB,MAAM,gBAAgB,CAAC,WAAW,MAAM;GACxC,UAAU,MAAM;GAChB,eAAe;GAMf,UAAU,OAAO,OAAO,KAAK;GAE7B,IAAI,eAEF,KAAK,MAAM,YAAY,kBACrB,SAAS,MAAM;EAGrB;EAEA,SAAe;GACb,UAAU,OAAO;EACnB;CACF;;CAGA,SAAS,yBAAyB,IAAY,UAAsC;EAClF,MAAM,YAAY,oBAAoB,IAAI,EAAE,qBAAK,IAAI,IAAI;EAEzD,UAAU,IAAI,QAAQ;EACtB,oBAAoB,IAAI,IAAI,SAAS;CACvC;;CAGA,SAAS,aAAa,IAAY,OAAmB;EACnD,MAAM,YAAY,oBAAoB,IAAI,EAAE;EAE5C,IAAI,cAAc,KAAA,GAChB;EAKF,oBAAoB,OAAO,EAAE;EAE7B,KAAK,MAAM,YAAY,WAAW;GAChC,SAAS,SAAS,KAAK;GACvB,eAAe,QAAQ;EACzB;CACF;;CAGA,SAAS,eAAe,UAAsC;EAC5D,IAAI,SACF,SAAS,MAAM;CAEnB;;CAGA,SAAS,sBAAsB,IAAY,UAAsC;EAC/E,iBAAiB,OAAO,QAAQ;EAEhC,MAAM,YAAY,oBAAoB,IAAI,EAAE;EAE5C,IAAI,cAAc,KAAA,GAChB;EAGF,UAAU,OAAO,QAAQ;EAEzB,IAAI,UAAU,SAAS,GACrB,oBAAoB,OAAO,EAAE;CAEjC;AACF"}
1
+ {"version":3,"file":"enabled.js","names":[],"sources":["../../src/audio/howler-lifecycle.ts","../../src/audio/managed-playback.ts","../../src/audio/sound-loader.ts","../../src/audio/enabled.ts"],"sourcesContent":["import { Howler } from '#audio/howler.js';\nimport type { RuntimeAudioState } from '#types/audio.js';\n\nconst CONTEXT_RESUME_DELAY_MS = 200;\n\n/**\n * Coordinates Howler with Replayable's normalized host lifecycle.\n *\n * Howler's automatic suspension is disabled because iOS Safari can leave its\n * `AudioContext` suspended after an ad becomes visible again. The WebKit\n * workaround suspends immediately and schedules an independent resume 200 ms\n * later. The recovery deliberately does not sequence those operations through\n * promise settlement: affected WebKit versions can leave an audio-context\n * transition promise unsettled indefinitely.\n *\n * A subsequent hidden transition cancels the pending resume and suspends the\n * context again, preventing the delayed recovery from starting background\n * audio.\n *\n * @see https://bugs.webkit.org/show_bug.cgi?id=276016\n * @see https://bugs.webkit.org/show_bug.cgi?id=281566\n */\nexport function createHowlerLifecycle() {\n let recoveryTimeout: number | undefined;\n let visible = false;\n\n Howler.autoSuspend = false;\n Howler.mute(true);\n\n return {\n setVisible(nextVisible: boolean): void {\n visible = nextVisible;\n clearRecoveryTimeout();\n\n if (!Howler.usingWebAudio) {\n return;\n }\n\n // Do not await this promise. The following resume must remain\n // independently scheduled rather than depending on WebKit settling the\n // preceding audio-context transition.\n void Howler.ctx.suspend().catch(() => {\n // The context may already be closed or unavailable.\n });\n\n if (!visible) {\n return;\n }\n\n // WebKit's published reproduction uses 200 ms to let the visibility\n // transition settle before attempting to resume the context.\n recoveryTimeout = window.setTimeout(() => {\n recoveryTimeout = undefined;\n\n if (!visible) {\n return;\n }\n\n void Howler.ctx.resume().catch(() => {\n // A later trusted interaction or visibility transition can retry.\n });\n }, CONTEXT_RESUME_DELAY_MS);\n },\n\n update({ allowed, volume }: RuntimeAudioState, muted: boolean): void {\n Howler.volume(volume);\n Howler.mute(!allowed || muted);\n },\n\n unlock(): void {\n if (!Howler.usingWebAudio || Howler.ctx.state === 'running') {\n return;\n }\n\n // Invocation—not promise settlement—must occur in the trusted event task.\n // Howler also installs an unlock listener; this explicit attempt makes the\n // runtime contract independent of that internal implementation detail.\n void Howler.ctx.resume().catch(() => {\n // A later trusted interaction or visibility recovery can retry.\n });\n },\n };\n\n function clearRecoveryTimeout(): void {\n if (recoveryTimeout === undefined) {\n return;\n }\n\n window.clearTimeout(recoveryTimeout);\n recoveryTimeout = undefined;\n }\n}\n","import type { Howl, HowlCallback } from '#audio/howler.js';\nimport { resolveAudioStopOptions, resolveAudioVolume } from '#audio/resolve-options.js';\nimport type {\n PlaybackStatus,\n ActiveVoice,\n AudioPlaybackOptions,\n AudioStopOptions,\n ManagedAudioPlayback,\n} from '#types/audio.js';\n\nconst MILLISECONDS_PER_SECOND = 1000;\n\n/**\n * Creates one managed Howler voice that may wait for both its sound and unlock.\n *\n * A playback exists as a single object and moves through this lifecycle:\n *\n * 1. It starts pending, without requiring a loaded sound or allocating a voice.\n * 2. `setSound()` stores the loaded `Howl`; playback is still pending.\n * 3. `start()` allocates one voice and records its ID.\n * 4. A non-looping voice finishes through Howler's `end` event, while a loop\n * remains active until the application calls `stop()`.\n * 5. Stopping (immediate or faded) removes listeners, stops the exact voice,\n * and calls `onFinished` so the orchestrator can forget the handle.\n *\n * ```ts\n * const playback = createManagedPlayback({\n * fadeIn: 0.5,\n * loop: true,\n * volume: 0.6,\n * }, removePlayback);\n *\n * playback.setSound(sound);\n * playback.start();\n * playback.stop({ fadeOut: 0.4 });\n * ```\n *\n * Calling `stop()` before `start()` simply finishes the pending handle: no voice\n * is created and later load/unlock events will not restart it. Howler operations\n * are routed by this handle's playback ID so shared source `Howl`s do not mix\n * control.\n *\n * `start` is invoked by the audio controller when loading or permission opens a gate.\n * Public controls and metadata remain available on the same handle after finishing.\n */\nexport function createManagedPlayback(\n options: Required<AudioPlaybackOptions>,\n onFinished: (playback: ManagedAudioPlayback) => void,\n): ManagedAudioPlayback {\n let activeVoice: ActiveVoice | undefined;\n let endListener: HowlCallback | undefined;\n let sound: Howl | undefined;\n let status: PlaybackStatus = 'pending';\n let volume = options.volume;\n let soundDuration = 0;\n\n const playback: ManagedAudioPlayback = {\n get position(): number {\n if (activeVoice === undefined) {\n return 0;\n }\n\n const position = activeVoice.sound.seek(activeVoice.id);\n return options.loop && soundDuration > 0 ? position % soundDuration : position;\n },\n\n get duration(): number {\n return soundDuration;\n },\n\n setVolume(nextVolume): void {\n resolveAudioVolume(nextVolume);\n if (status === 'stopping' || status === 'finished') {\n return;\n }\n\n volume = nextVolume;\n if (activeVoice !== undefined) {\n const { sound: activeSound, id } = activeVoice;\n activeSound.volume(volume, id);\n // Howler cancels fades by restoring their target, including its stored\n // volume. Reapply our value after cancellation so later fades/mutes use it.\n activeSound.volume(volume, id);\n }\n },\n\n setSound(loadedSound): void {\n if (status !== 'pending' || sound !== undefined) {\n return;\n }\n\n sound = loadedSound;\n soundDuration = loadedSound.duration();\n },\n\n start(): void {\n if (status !== 'pending' || sound === undefined) {\n return;\n }\n\n // Howler returns a new ID for this voice even when another handle is\n // already playing the same source sound.\n const playbackId = sound.play();\n\n activeVoice = { id: playbackId, sound };\n status = 'playing';\n\n const fadeInMilliseconds = options.fadeIn * MILLISECONDS_PER_SECOND;\n const shouldFadeIn = fadeInMilliseconds > 0 && volume > 0;\n\n // Volume and looping are scoped to this ID rather than the shared Howl.\n sound.volume(shouldFadeIn ? 0 : volume, playbackId);\n sound.loop(options.loop, playbackId);\n\n if (shouldFadeIn) {\n sound.fade(0, volume, fadeInMilliseconds, playbackId);\n }\n\n if (!options.loop) {\n listenForEnd();\n }\n },\n\n stop(stopOptions?: AudioStopOptions): void {\n if (status === 'stopping' || status === 'finished') {\n return;\n }\n\n const { fadeOut } = resolveAudioStopOptions(stopOptions);\n\n if (activeVoice === undefined || fadeOut === 0) {\n finish();\n\n return;\n }\n\n // The handle rejects repeated commands while the underlying voice remains\n // audible until its fade completes.\n status = 'stopping';\n removeEndListener();\n fadeOutPlayback(fadeOut);\n },\n };\n\n return playback;\n\n /** Stops this voice after fading from its current volume to silence. */\n function fadeOutPlayback(duration: number): void {\n if (activeVoice === undefined) {\n return;\n }\n\n const { id, sound: activeSound } = activeVoice;\n const currentVolume = activeSound.volume(id);\n\n if (typeof currentVolume !== 'number') {\n throw new Error(`Unable to read volume for Howler playback ${id}.`);\n }\n\n if (currentVolume === 0) {\n finish();\n\n return;\n }\n\n const handleFade = (fadedPlaybackId: number): void => {\n if (fadedPlaybackId !== activeVoice?.id) {\n return;\n }\n\n finish();\n };\n\n // Starting the new fade first replaces an in-progress fade-in. Howler emits\n // `fade` when cancelling that old transition, so Replayable subscribes only\n // after the replacement begins and waits for the fade-out itself.\n activeSound.fade(currentVolume, 0, duration * MILLISECONDS_PER_SECOND, id);\n activeSound.once('fade', handleFade, id);\n }\n\n /**\n * Retires this handle and releases its pending or active Howler voice.\n *\n * `onFinished` removes the handle from the orchestrator's retained set. It is\n * called for pending cancellation, natural completion, and explicit stopping.\n */\n function finish(): void {\n status = 'finished';\n stopActivePlayback();\n onFinished(playback);\n }\n\n /** Lets a finite, non-looping sound release itself after natural completion. */\n function listenForEnd(): void {\n if (activeVoice === undefined) {\n return;\n }\n\n const { id, sound: activeSound } = activeVoice;\n const handleEnd = (endedPlaybackId: number): void => {\n if (endedPlaybackId !== activeVoice?.id) {\n return;\n }\n\n endListener = undefined;\n finish();\n };\n\n endListener = handleEnd;\n activeSound.once('end', handleEnd, id);\n }\n\n /**\n * Stops the active voice and releases Replayable's Howler listeners.\n *\n * Howler emits `stop`, not `end`, for this operation. The `end` listener is\n * removed because its ID-scoped callback is no longer useful and would\n * otherwise remain retained by the shared `Howl`.\n */\n function stopActivePlayback(): void {\n if (activeVoice === undefined) {\n return;\n }\n\n const { id, sound: activeSound } = activeVoice;\n\n removeEndListener();\n activeVoice = undefined;\n activeSound.stop(id);\n }\n\n function removeEndListener(): void {\n if (endListener === undefined || activeVoice === undefined) {\n return;\n }\n\n activeVoice.sound.off('end', endListener, activeVoice.id);\n endListener = undefined;\n }\n}\n","import { Howl, type HowlErrorCallback } from '#audio/howler.js';\nimport type { AssetLoadContext, AssetLoader } from '#types/loader.js';\n\n/** Returns one loaded sound, or `undefined` while its bundle remains unloaded. */\nexport function getLoadedSound(loader: AssetLoader, id: string): Howl | undefined {\n const sound = loader.cache.sounds?.[id];\n\n return sound instanceof Howl ? sound : undefined;\n}\n\n/**\n * Converts one generated sound URL into a completely loaded Howler sound.\n *\n * Replayable's asset loader calls this handler once for every sound in the\n * requested bundle. Given this context:\n *\n * ```ts\n * {\n * category: 'sounds',\n * id: 'ui-click',\n * source: '/assets/ui-click.m4a',\n * }\n * ```\n *\n * the promise resolves with a `Howl` only after its `load` event. The asset\n * loader then stores that value at `cache.sounds['ui-click']`. Consequently,\n * `loader.load('primary')` does not finish until every primary sound is ready\n * for synchronous `play()` calls.\n *\n * A load failure unloads the partial Howler resource before rejecting the\n * containing bundle load with the generated asset ID in the error message.\n */\nexport function loadSound({ id, source }: AssetLoadContext<'sounds'>): Promise<Howl> {\n return new Promise((resolve, reject) => {\n // Manual loading lets Replayable attach both terminal listeners before any\n // network or data-URL decoding work can complete.\n const sound = new Howl({ preload: false, src: source });\n\n const handleLoad = (): void => {\n // The opposite terminal event can no longer occur for this load. Removing\n // it releases the settled promise closure retained by the cached Howl.\n sound.off('loaderror', handleLoadError);\n resolve(sound);\n };\n\n const handleLoadError: HowlErrorCallback = (_soundId, error): void => {\n sound.off('load', handleLoad);\n\n // Release any partially allocated HTMLAudio or Web Audio resources.\n sound.unload();\n reject(new Error(`Unable to load sound ${JSON.stringify(id)}: ${String(error)}.`));\n };\n\n sound.once('load', handleLoad);\n sound.once('loaderror', handleLoadError);\n sound.load();\n });\n}\n","import { createHowlerLifecycle } from '#audio/howler-lifecycle.js';\nimport type { Howl } from '#audio/howler.js';\nimport { createManagedPlayback } from '#audio/managed-playback.js';\nimport { resolveAudioOneShotOptions, resolveAudioPlaybackOptions } from '#audio/resolve-options.js';\nimport { getLoadedSound, loadSound } from '#audio/sound-loader.js';\nimport type {\n AudioController,\n AudioPlayback,\n ManagedAudioPlayback,\n RuntimeAudioState,\n} from '#types/audio.js';\nimport type { AssetLoader } from '#types/loader.js';\n\n/**\n * Creates Howler-backed sound playback for an audio-enabled variant.\n *\n * This controller coordinates three lower-level components:\n *\n * - The asset loader turns generated sound URLs into loaded `Howl` instances.\n * - The lifecycle applies host volume, visibility, and browser-unlock policy.\n * - Managed playbacks own individual voices, fades, and final cleanup.\n *\n * There is only one playback implementation (`ManagedAudioPlayback`).\n * The two internal collections below represent states of the same type:\n * 1) `managedPlaybacks` keeps every live handle.\n * 2) `unresolvedPlaybacks` indexes pending handles still missing a loaded sound.\n *\n * A managed `play()` request is retained until both gates open:\n * - sound is loaded\n * - runtime audio is allowed\n *\n * Either gate can open first. A transient `playOneShot()` is dropped while\n * audio is blocked, preventing stale effects from playing later.\n */\nexport function createAudio(loader: AssetLoader): AudioController {\n const lifecycle = createHowlerLifecycle();\n // All live handles from `play()`: pending or active.\n const managedPlaybacks = new Set<ManagedAudioPlayback>();\n // Pending-by-sound-id index for quick binding when a sound finishes loading.\n const unresolvedPlaybacks = new Map<string, Set<ManagedAudioPlayback>>();\n let allowed = false;\n let muted = false;\n let runtimeState: RuntimeAudioState | undefined;\n\n // Registering this loader callback keeps primary/secondary asset timing safe.\n loader.register('sounds', async (context) => {\n const sound = await loadSound(context);\n\n resolveSound(context.id, sound);\n\n return sound;\n });\n\n return {\n get muted(): boolean {\n return muted;\n },\n\n setMuted(nextMuted): void {\n if (muted === nextMuted) {\n return;\n }\n\n muted = nextMuted;\n\n if (runtimeState !== undefined) {\n lifecycle.update(runtimeState, muted);\n }\n },\n\n play(id, options): AudioPlayback {\n const resolvedOptions = resolveAudioPlaybackOptions(options);\n const playback = createManagedPlayback(resolvedOptions, (finishedPlayback) => {\n removeManagedPlayback(id, finishedPlayback);\n });\n const sound = getLoadedSound(loader, id);\n\n if (sound === undefined) {\n retainUnresolvedPlayback(id, playback);\n } else {\n playback.setSound(sound);\n }\n\n managedPlaybacks.add(playback);\n\n // `start()` re-checks gates for both already-ready and newly-ready cases.\n startIfAllowed(playback);\n\n return playback;\n },\n\n playOneShot(id, options): void {\n // A one-shot has no retained handle: blocked effects are intentionally\n // discarded rather than queued and replayed out of context later.\n if (!allowed) {\n return;\n }\n\n const sound = getLoadedSound(loader, id);\n\n // Unloaded one-shots are dropped intentionally and are never deferred.\n if (sound === undefined) {\n return;\n }\n\n const { volume } = resolveAudioOneShotOptions(options);\n const playbackId = sound.play();\n\n sound.volume(volume, playbackId);\n },\n\n setVisible(visible): void {\n lifecycle.setVisible(visible);\n },\n\n update(state): void {\n const becameAllowed = !allowed && state.allowed;\n allowed = state.allowed;\n runtimeState = state;\n\n // Howler volume and global muting are updated before any deferred voice\n // starts. Existing managed voices keep the same playback ID while blocked;\n // visibility policy may suspend their shared audio context, but Replayable\n // never stops and recreates them during an allowed-state transition.\n lifecycle.update(state, muted);\n\n if (becameAllowed) {\n // Start any pending voices; active voices ignore repeated start attempts.\n for (const playback of managedPlaybacks) {\n playback.start();\n }\n }\n },\n\n unlock(): void {\n lifecycle.unlock();\n },\n };\n\n /** Tracks a pending request until the sound loader resolves this asset ID. */\n function retainUnresolvedPlayback(id: string, playback: ManagedAudioPlayback): void {\n const playbacks = unresolvedPlaybacks.get(id) ?? new Set();\n\n playbacks.add(playback);\n unresolvedPlaybacks.set(id, playbacks);\n }\n\n /** Binds every pending playback for this sound ID to its loaded `Howl`. */\n function resolveSound(id: string, sound: Howl): void {\n const playbacks = unresolvedPlaybacks.get(id);\n\n if (playbacks === undefined) {\n return;\n }\n\n // Remove the index entry before notifying playbacks; a callback can clean up\n // the same playback while handling its own completion.\n unresolvedPlaybacks.delete(id);\n\n for (const playback of playbacks) {\n playback.setSound(sound);\n startIfAllowed(playback);\n }\n }\n\n /** Starts a managed handle only when permission is already allowed. */\n function startIfAllowed(playback: ManagedAudioPlayback): void {\n if (allowed) {\n playback.start();\n }\n }\n\n /** Removes the handle from both registries; pending index only if still unresolved. */\n function removeManagedPlayback(id: string, playback: ManagedAudioPlayback): void {\n managedPlaybacks.delete(playback);\n\n const playbacks = unresolvedPlaybacks.get(id);\n\n if (playbacks === undefined) {\n return;\n }\n\n playbacks.delete(playback);\n\n if (playbacks.size === 0) {\n unresolvedPlaybacks.delete(id);\n }\n }\n}\n"],"mappings":"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"}
@@ -21,6 +21,12 @@ interface AudioStopOptions {
21
21
  }
22
22
  /** Controls one managed playback while pending or active. */
23
23
  interface AudioPlayback {
24
+ /** Sets this voice's volume (0..1), without restarting it; cancels fade-in. */
25
+ setVolume(volume: number): void;
26
+ /** Backend position in seconds; zero before start and after finish, wraps for loops. */
27
+ readonly position: number;
28
+ /** Full sound duration in seconds; zero until loaded, retained after finish. */
29
+ readonly duration: number;
24
30
  /** Cancels deferred start or stops the active sound. */
25
31
  stop(options?: AudioStopOptions): void;
26
32
  }
@@ -63,4 +69,4 @@ interface AudioController extends PlayableAudio {
63
69
  }
64
70
  //#endregion
65
71
  export { AudioStopOptions as a, AudioPlaybackOptions as i, AudioOneShotOptions as n, PlayableAudio as o, AudioPlayback as r, RuntimeAudioState as s, AudioController as t };
66
- //# sourceMappingURL=audio-CyLjSreN.d.ts.map
72
+ //# sourceMappingURL=audio-DTYPqdzS.d.ts.map
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { a as RuntimeResolutionConfig, c as Screen, i as RuntimeRenderScaleConfig, l as ScreenDesign, n as RuntimeOrientationConfig, o as RuntimeScreenConfig, r as RuntimeRange, s as RuntimeViewport, t as RuntimeOrientation, u as ScreenFrame } from "./screen-DXf2FPtL.js";
2
2
  import { r as AssetMode } from "./assets-BD68MQTL.js";
3
- import { a as AudioStopOptions, i as AudioPlaybackOptions, n as AudioOneShotOptions, o as PlayableAudio, r as AudioPlayback, s as RuntimeAudioState } from "./audio-CyLjSreN.js";
3
+ import { a as AudioStopOptions, i as AudioPlaybackOptions, n as AudioOneShotOptions, o as PlayableAudio, r as AudioPlayback, s as RuntimeAudioState } from "./audio-DTYPqdzS.js";
4
4
  import { a as AssetLoadedContext, c as BuiltInAssetCategory, d as RegisteredAssetCategory, i as AssetLoadHandler, l as BuiltInAssetValueByCategory, n as AssetCache, o as AssetLoadedListener, r as AssetLoadContext, s as AssetLoader, t as AssetBundleLoadedListener, u as LoadedShaderAsset } from "./loader-Czz-x3KH.js";
5
5
  //#region src/types/completion.d.ts
6
6
  /** Supported terminal outcomes reported by a playable. */
@@ -0,0 +1,83 @@
1
+ //#region src/audio/resolve-options.ts
2
+ const DEFAULT_FADE_DURATION = 0;
3
+ const DEFAULT_VOLUME = 1;
4
+ /**
5
+ * Produces the complete options used to create one managed playback.
6
+ *
7
+ * Defaults are applied once at the public API boundary so the playback state
8
+ * machine never needs to distinguish omitted values from explicit values:
9
+ *
10
+ * ```ts
11
+ * resolveAudioPlaybackOptions();
12
+ * // { volume: 1, loop: false, fadeIn: 0 }
13
+ *
14
+ * resolveAudioPlaybackOptions({ loop: true, volume: 0.5 });
15
+ * // { volume: 0.5, loop: true, fadeIn: 0 }
16
+ * ```
17
+ *
18
+ * Invalid numeric values throw before a Howler voice is created.
19
+ */
20
+ function resolveAudioPlaybackOptions(options = {}) {
21
+ return {
22
+ volume: resolveAudioVolume(options.volume ?? DEFAULT_VOLUME),
23
+ loop: options.loop ?? false,
24
+ fadeIn: resolveFadeDuration("fade-in", options.fadeIn)
25
+ };
26
+ }
27
+ /**
28
+ * Produces the complete options used by one transient sound effect.
29
+ *
30
+ * ```ts
31
+ * resolveAudioOneShotOptions();
32
+ * // { volume: 1 }
33
+ *
34
+ * resolveAudioOneShotOptions({ volume: 0.8 });
35
+ * // { volume: 0.8 }
36
+ * ```
37
+ */
38
+ function resolveAudioOneShotOptions(options = {}) {
39
+ return { volume: resolveAudioVolume(options.volume ?? DEFAULT_VOLUME) };
40
+ }
41
+ /**
42
+ * Produces the complete options used to stop managed playback.
43
+ *
44
+ * ```ts
45
+ * resolveAudioStopOptions();
46
+ * // { fadeOut: 0 }
47
+ *
48
+ * resolveAudioStopOptions({ fadeOut: 0.4 });
49
+ * // { fadeOut: 0.4 }
50
+ * ```
51
+ *
52
+ * A zero duration stops immediately; a positive duration is converted to
53
+ * milliseconds only when the managed playback calls Howler.
54
+ */
55
+ function resolveAudioStopOptions(options = {}) {
56
+ return { fadeOut: resolveFadeDuration("fade-out", options.fadeOut) };
57
+ }
58
+ /**
59
+ * Resolves public volume onto Howler's normalized linear gain range.
60
+ *
61
+ * `Number.isFinite` deliberately rejects `NaN` and both infinities in addition
62
+ * to values outside the inclusive `0..1` range.
63
+ */
64
+ function resolveAudioVolume(volume) {
65
+ if (!Number.isFinite(volume) || volume < 0 || volume > 1) throw new RangeError(`Audio volume must be a finite number from 0 through 1; received ${String(volume)}.`);
66
+ return volume;
67
+ }
68
+ /**
69
+ * Resolves one public fade duration expressed in Replayable-standard seconds.
70
+ *
71
+ * Keeping seconds in the public API matches Replayable's update and fixed-update
72
+ * timing contracts. Only the Howler integration converts this value to
73
+ * milliseconds. `Number.isFinite` also prevents `NaN` and infinity from
74
+ * reaching browser timers.
75
+ */
76
+ function resolveFadeDuration(name, duration = DEFAULT_FADE_DURATION) {
77
+ if (!Number.isFinite(duration) || duration < 0) throw new RangeError(`Audio ${name} duration must be a finite, non-negative number of seconds; received ${String(duration)}.`);
78
+ return duration;
79
+ }
80
+ //#endregion
81
+ export { resolveAudioVolume as i, resolveAudioPlaybackOptions as n, resolveAudioStopOptions as r, resolveAudioOneShotOptions as t };
82
+
83
+ //# sourceMappingURL=resolve-options-CqtH9GnM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-options-CqtH9GnM.js","names":[],"sources":["../src/audio/resolve-options.ts"],"sourcesContent":["import type { AudioOneShotOptions, AudioPlaybackOptions, AudioStopOptions } from '#types/audio.js';\n\nconst DEFAULT_FADE_DURATION = 0;\nconst DEFAULT_VOLUME = 1;\n\n/**\n * Produces the complete options used to create one managed playback.\n *\n * Defaults are applied once at the public API boundary so the playback state\n * machine never needs to distinguish omitted values from explicit values:\n *\n * ```ts\n * resolveAudioPlaybackOptions();\n * // { volume: 1, loop: false, fadeIn: 0 }\n *\n * resolveAudioPlaybackOptions({ loop: true, volume: 0.5 });\n * // { volume: 0.5, loop: true, fadeIn: 0 }\n * ```\n *\n * Invalid numeric values throw before a Howler voice is created.\n */\nexport function resolveAudioPlaybackOptions(\n options: AudioPlaybackOptions = {},\n): Required<AudioPlaybackOptions> {\n return {\n volume: resolveAudioVolume(options.volume ?? DEFAULT_VOLUME),\n loop: options.loop ?? false,\n fadeIn: resolveFadeDuration('fade-in', options.fadeIn),\n };\n}\n\n/**\n * Produces the complete options used by one transient sound effect.\n *\n * ```ts\n * resolveAudioOneShotOptions();\n * // { volume: 1 }\n *\n * resolveAudioOneShotOptions({ volume: 0.8 });\n * // { volume: 0.8 }\n * ```\n */\nexport function resolveAudioOneShotOptions(\n options: AudioOneShotOptions = {},\n): Required<AudioOneShotOptions> {\n return {\n volume: resolveAudioVolume(options.volume ?? DEFAULT_VOLUME),\n };\n}\n\n/**\n * Produces the complete options used to stop managed playback.\n *\n * ```ts\n * resolveAudioStopOptions();\n * // { fadeOut: 0 }\n *\n * resolveAudioStopOptions({ fadeOut: 0.4 });\n * // { fadeOut: 0.4 }\n * ```\n *\n * A zero duration stops immediately; a positive duration is converted to\n * milliseconds only when the managed playback calls Howler.\n */\nexport function resolveAudioStopOptions(\n options: AudioStopOptions = {},\n): Required<AudioStopOptions> {\n return {\n fadeOut: resolveFadeDuration('fade-out', options.fadeOut),\n };\n}\n\n/**\n * Resolves public volume onto Howler's normalized linear gain range.\n *\n * `Number.isFinite` deliberately rejects `NaN` and both infinities in addition\n * to values outside the inclusive `0..1` range.\n */\nexport function resolveAudioVolume(volume: number): number {\n if (!Number.isFinite(volume) || volume < 0 || volume > 1) {\n throw new RangeError(\n `Audio volume must be a finite number from 0 through 1; received ${String(volume)}.`,\n );\n }\n\n return volume;\n}\n\n/**\n * Resolves one public fade duration expressed in Replayable-standard seconds.\n *\n * Keeping seconds in the public API matches Replayable's update and fixed-update\n * timing contracts. Only the Howler integration converts this value to\n * milliseconds. `Number.isFinite` also prevents `NaN` and infinity from\n * reaching browser timers.\n */\nfunction resolveFadeDuration(\n name: 'fade-in' | 'fade-out',\n duration = DEFAULT_FADE_DURATION,\n): number {\n if (!Number.isFinite(duration) || duration < 0) {\n throw new RangeError(\n `Audio ${name} duration must be a finite, non-negative number of seconds; received ${String(duration)}.`,\n );\n }\n\n return duration;\n}\n"],"mappings":";AAEA,MAAM,wBAAwB;AAC9B,MAAM,iBAAiB;;;;;;;;;;;;;;;;;AAkBvB,SAAgB,4BACd,UAAgC,CAAC,GACD;CAChC,OAAO;EACL,QAAQ,mBAAmB,QAAQ,UAAU,cAAc;EAC3D,MAAM,QAAQ,QAAQ;EACtB,QAAQ,oBAAoB,WAAW,QAAQ,MAAM;CACvD;AACF;;;;;;;;;;;;AAaA,SAAgB,2BACd,UAA+B,CAAC,GACD;CAC/B,OAAO,EACL,QAAQ,mBAAmB,QAAQ,UAAU,cAAc,EAC7D;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,wBACd,UAA4B,CAAC,GACD;CAC5B,OAAO,EACL,SAAS,oBAAoB,YAAY,QAAQ,OAAO,EAC1D;AACF;;;;;;;AAQA,SAAgB,mBAAmB,QAAwB;CACzD,IAAI,CAAC,OAAO,SAAS,MAAM,KAAK,SAAS,KAAK,SAAS,GACrD,MAAM,IAAI,WACR,mEAAmE,OAAO,MAAM,EAAE,EACpF;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,oBACP,MACA,WAAW,uBACH;CACR,IAAI,CAAC,OAAO,SAAS,QAAQ,KAAK,WAAW,GAC3C,MAAM,IAAI,WACR,SAAS,KAAK,uEAAuE,OAAO,QAAQ,EAAE,EACxG;CAGF,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayablejs/runtime",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "Host lifecycle and network integration for Replayable playable ads",
5
5
  "homepage": "https://github.com/replayablejs/replayable#readme",
6
6
  "bugs": {