one 2.11.5 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/margelo/nitro/one/HybridOneAudio.kt +309 -124
- package/dist/cjs/cli/build.cjs +31 -33
- package/dist/cjs/cli/build.native.cjs +31 -33
- package/dist/cjs/cli/build.native.js +31 -33
- package/dist/cjs/cli/build.native.js.map +1 -1
- package/dist/cjs/cli/buildPage.cjs +11 -7
- package/dist/cjs/cli/buildPage.native.cjs +11 -7
- package/dist/cjs/cli/buildPage.native.js +11 -7
- package/dist/cjs/cli/buildPage.native.js.map +1 -1
- package/dist/cjs/cli/buildPageWorker.cjs +1 -1
- package/dist/cjs/cli/buildPageWorker.native.cjs +1 -1
- package/dist/cjs/cli/buildPageWorker.native.js +1 -1
- package/dist/cjs/cli/buildPageWorker.native.js.map +1 -1
- package/dist/cjs/headless-server.cjs +2 -5
- package/dist/cjs/headless-server.native.cjs +2 -5
- package/dist/cjs/headless-server.native.js +2 -5
- package/dist/cjs/headless-server.native.js.map +1 -1
- package/dist/cjs/platform/audio/index.cjs +83 -76
- package/dist/cjs/platform/audio/index.native.cjs +2 -2
- package/dist/cjs/platform/audio/index.native.js +2 -2
- package/dist/cjs/platform/audio/index.native.js.map +1 -1
- package/dist/cjs/platform/audio/nowPlaying.cjs +126 -0
- package/dist/cjs/platform/audio/nowPlaying.native.cjs +160 -0
- package/dist/cjs/platform/audio/nowPlaying.native.js +162 -0
- package/dist/cjs/platform/audio/nowPlaying.native.js.map +1 -0
- package/dist/cjs/platform/audio/player.cjs +15 -0
- package/dist/cjs/platform/audio/player.native.cjs +14 -0
- package/dist/cjs/platform/audio/player.native.js +14 -0
- package/dist/cjs/platform/audio/player.native.js.map +1 -1
- package/dist/cjs/platform/audio/unavailable.cjs +1 -1
- package/dist/cjs/platform/audio/unavailable.native.cjs +1 -1
- package/dist/cjs/platform/audio/unavailable.native.js +1 -1
- package/dist/cjs/platform/audio/unavailable.native.js.map +1 -1
- package/dist/cjs/serve-worker.cjs +0 -3
- package/dist/cjs/serve-worker.native.cjs +0 -3
- package/dist/cjs/serve-worker.native.js +0 -3
- package/dist/cjs/serve-worker.native.js.map +1 -1
- package/dist/cjs/server/oneServe.cjs +0 -6
- package/dist/cjs/server/oneServe.native.cjs +0 -6
- package/dist/cjs/server/oneServe.native.js +0 -6
- package/dist/cjs/server/oneServe.native.js.map +1 -1
- package/dist/cjs/server/workerHandler.cjs +12 -10
- package/dist/cjs/server/workerHandler.native.cjs +14 -10
- package/dist/cjs/server/workerHandler.native.js +14 -10
- package/dist/cjs/server/workerHandler.native.js.map +1 -1
- package/dist/cjs/vite/plugins/workerdDevPlugin.cjs +33 -37
- package/dist/cjs/vite/plugins/workerdDevPlugin.native.cjs +33 -37
- package/dist/cjs/vite/plugins/workerdDevPlugin.native.js +33 -37
- package/dist/cjs/vite/plugins/workerdDevPlugin.native.js.map +1 -1
- package/dist/esm/cli/build.mjs +31 -33
- package/dist/esm/cli/build.mjs.map +1 -1
- package/dist/esm/cli/build.native.js +31 -33
- package/dist/esm/cli/build.native.js.map +1 -1
- package/dist/esm/cli/buildPage.mjs +11 -7
- package/dist/esm/cli/buildPage.mjs.map +1 -1
- package/dist/esm/cli/buildPage.native.js +11 -7
- package/dist/esm/cli/buildPage.native.js.map +1 -1
- package/dist/esm/cli/buildPageWorker.mjs +1 -1
- package/dist/esm/cli/buildPageWorker.mjs.map +1 -1
- package/dist/esm/cli/buildPageWorker.native.js +1 -1
- package/dist/esm/cli/buildPageWorker.native.js.map +1 -1
- package/dist/esm/headless-server.mjs +2 -3
- package/dist/esm/headless-server.mjs.map +1 -1
- package/dist/esm/headless-server.native.js +2 -3
- package/dist/esm/headless-server.native.js.map +1 -1
- package/dist/esm/platform/audio/index.mjs +83 -76
- package/dist/esm/platform/audio/index.mjs.map +1 -1
- package/dist/esm/platform/audio/index.native.js +2 -2
- package/dist/esm/platform/audio/index.native.js.map +1 -1
- package/dist/esm/platform/audio/nowPlaying.mjs +102 -0
- package/dist/esm/platform/audio/nowPlaying.mjs.map +1 -0
- package/dist/esm/platform/audio/nowPlaying.native.js +134 -0
- package/dist/esm/platform/audio/nowPlaying.native.js.map +1 -0
- package/dist/esm/platform/audio/player.mjs +15 -0
- package/dist/esm/platform/audio/player.mjs.map +1 -1
- package/dist/esm/platform/audio/player.native.js +14 -0
- package/dist/esm/platform/audio/player.native.js.map +1 -1
- package/dist/esm/platform/audio/unavailable.mjs +1 -1
- package/dist/esm/platform/audio/unavailable.mjs.map +1 -1
- package/dist/esm/platform/audio/unavailable.native.js +1 -1
- package/dist/esm/platform/audio/unavailable.native.js.map +1 -1
- package/dist/esm/serve-worker.mjs +1 -2
- package/dist/esm/serve-worker.mjs.map +1 -1
- package/dist/esm/serve-worker.native.js +1 -2
- package/dist/esm/serve-worker.native.js.map +1 -1
- package/dist/esm/server/oneServe.mjs +0 -6
- package/dist/esm/server/oneServe.mjs.map +1 -1
- package/dist/esm/server/oneServe.native.js +0 -6
- package/dist/esm/server/oneServe.native.js.map +1 -1
- package/dist/esm/server/workerHandler.mjs +12 -10
- package/dist/esm/server/workerHandler.mjs.map +1 -1
- package/dist/esm/server/workerHandler.native.js +14 -10
- package/dist/esm/server/workerHandler.native.js.map +1 -1
- package/dist/esm/vite/plugins/workerdDevPlugin.mjs +33 -37
- package/dist/esm/vite/plugins/workerdDevPlugin.mjs.map +1 -1
- package/dist/esm/vite/plugins/workerdDevPlugin.native.js +33 -37
- package/dist/esm/vite/plugins/workerdDevPlugin.native.js.map +1 -1
- package/ios/Nitro/HybridOneAudio.swift +309 -96
- package/nitrogen/generated/android/c++/JAudioPlayOptions.hpp +61 -0
- package/nitrogen/generated/android/c++/JAudioRemoteCommandEvent.hpp +8 -3
- package/nitrogen/generated/android/c++/JFunc_void_AudioRemoteCommandEvent.hpp +1 -0
- package/nitrogen/generated/android/c++/JHybridOneAudioSpec.cpp +37 -3
- package/nitrogen/generated/android/c++/JHybridOneAudioSpec.hpp +3 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/AudioPlayOptions.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/AudioRemoteCommandEvent.kt +9 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/one/HybridOneAudioSpec.kt +40 -32
- package/nitrogen/generated/ios/One-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridOneAudioSpecSwift.hpp +21 -2
- package/nitrogen/generated/ios/swift/AudioPlayOptions.swift +60 -0
- package/nitrogen/generated/ios/swift/AudioRemoteCommandEvent.swift +19 -1
- package/nitrogen/generated/ios/swift/HybridOneAudioSpec.swift +3 -1
- package/nitrogen/generated/ios/swift/HybridOneAudioSpec_cxx.swift +70 -32
- package/nitrogen/generated/shared/c++/AudioPlayOptions.hpp +87 -0
- package/nitrogen/generated/shared/c++/AudioRemoteCommandEvent.hpp +7 -2
- package/nitrogen/generated/shared/c++/HybridOneAudioSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridOneAudioSpec.hpp +6 -1
- package/package.json +13 -13
- package/src/cli/build.ts +35 -32
- package/src/cli/buildPage.test.ts +152 -0
- package/src/cli/buildPage.ts +14 -9
- package/src/cli/buildPageWorker.ts +2 -1
- package/src/cli/workerPool.ts +1 -0
- package/src/headless-server.ts +2 -2
- package/src/index.ts +1 -0
- package/src/platform/audio/index.native.ts +4 -2
- package/src/platform/audio/index.ts +102 -74
- package/src/platform/audio/nowPlaying.ts +122 -0
- package/src/platform/audio/player.native.ts +17 -1
- package/src/platform/audio/player.ts +23 -1
- package/src/platform/audio/types.ts +6 -1
- package/src/platform/audio/unavailable.ts +3 -1
- package/src/platform/index.native.ts +1 -0
- package/src/platform/index.ts +1 -0
- package/src/platform/specs/OneAudio.nitro.ts +11 -1
- package/src/serve-worker.ts +2 -3
- package/src/server/oneServe.ts +0 -6
- package/src/server/workerHandler.ts +34 -18
- package/src/vite/plugins/workerdDevPlugin.ts +33 -37
- package/src/vite/resolveResponse.ts +2 -2
- package/types/cli/build.d.ts.map +1 -1
- package/types/cli/buildPage.d.ts +1 -1
- package/types/cli/buildPage.d.ts.map +1 -1
- package/types/cli/workerPool.d.ts +1 -0
- package/types/cli/workerPool.d.ts.map +1 -1
- package/types/headless-server.d.ts +3 -3
- package/types/headless-server.d.ts.map +1 -1
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/platform/audio/index.d.ts +2 -2
- package/types/platform/audio/index.d.ts.map +1 -1
- package/types/platform/audio/index.native.d.ts +3 -3
- package/types/platform/audio/index.native.d.ts.map +1 -1
- package/types/platform/audio/nowPlaying.d.ts +12 -0
- package/types/platform/audio/nowPlaying.d.ts.map +1 -0
- package/types/platform/audio/player.d.ts +3 -1
- package/types/platform/audio/player.d.ts.map +1 -1
- package/types/platform/audio/player.native.d.ts +3 -1
- package/types/platform/audio/player.native.d.ts.map +1 -1
- package/types/platform/audio/types.d.ts +3 -1
- package/types/platform/audio/types.d.ts.map +1 -1
- package/types/platform/audio/unavailable.d.ts +3 -3
- package/types/platform/audio/unavailable.d.ts.map +1 -1
- package/types/platform/index.d.ts +1 -1
- package/types/platform/index.d.ts.map +1 -1
- package/types/platform/index.native.d.ts +1 -1
- package/types/platform/index.native.d.ts.map +1 -1
- package/types/platform/specs/OneAudio.nitro.d.ts +8 -1
- package/types/platform/specs/OneAudio.nitro.d.ts.map +1 -1
- package/types/serve-worker.d.ts +3 -3
- package/types/serve-worker.d.ts.map +1 -1
- package/types/server/oneServe.d.ts.map +1 -1
- package/types/server/workerHandler.d.ts +1 -1
- package/types/server/workerHandler.d.ts.map +1 -1
- package/types/vite/plugins/workerdDevPlugin.d.ts.map +1 -1
- package/types/vite/resolveResponse.d.ts +1 -1
- package/types/vite/resolveResponse.d.ts.map +1 -1
- package/dist/cjs/server/staticHtmlFetcher.cjs +0 -33
- package/dist/cjs/server/staticHtmlFetcher.native.cjs +0 -35
- package/dist/cjs/server/staticHtmlFetcher.native.js +0 -37
- package/dist/cjs/server/staticHtmlFetcher.native.js.map +0 -1
- package/dist/esm/server/staticHtmlFetcher.mjs +0 -10
- package/dist/esm/server/staticHtmlFetcher.mjs.map +0 -1
- package/dist/esm/server/staticHtmlFetcher.native.js +0 -10
- package/dist/esm/server/staticHtmlFetcher.native.js.map +0 -1
- package/src/server/staticHtmlFetcher.ts +0 -12
- package/types/server/staticHtmlFetcher.d.ts +0 -3
- package/types/server/staticHtmlFetcher.d.ts.map +0 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, it } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
LOADER_JS_POSTFIX,
|
|
7
|
+
NATIVE_LOADER_JS_POSTFIX_UNCACHED,
|
|
8
|
+
} from '../constants'
|
|
9
|
+
import { buildPage } from './buildPage'
|
|
10
|
+
|
|
11
|
+
let root: string | undefined
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
if (root) await rm(root, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
async function fixture() {
|
|
18
|
+
root = await mkdtemp(join(tmpdir(), 'one-build-page-'))
|
|
19
|
+
const serverEntry = join(root, 'entry.mjs')
|
|
20
|
+
const { writeFile, mkdir } = await import('node:fs/promises')
|
|
21
|
+
await writeFile(
|
|
22
|
+
serverEntry,
|
|
23
|
+
`export default {
|
|
24
|
+
options: { routes: {
|
|
25
|
+
'/app/probe.tsx': async () => ({
|
|
26
|
+
loader: ({ params }) => ({ slug: params.slug }),
|
|
27
|
+
}),
|
|
28
|
+
'/app/gated.tsx': async () => ({
|
|
29
|
+
loader: () => { throw Response.redirect('http://example.com/login', 302) },
|
|
30
|
+
}),
|
|
31
|
+
} },
|
|
32
|
+
render: async ({ loaderData }) => '<html><head></head><body>' + JSON.stringify(loaderData) + '</body></html>'
|
|
33
|
+
}`
|
|
34
|
+
)
|
|
35
|
+
return serverEntry
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function buildRoute(
|
|
39
|
+
serverEntry: string,
|
|
40
|
+
out: string,
|
|
41
|
+
route: { path: string; file: string; params: any },
|
|
42
|
+
skipNativeLoaders?: boolean
|
|
43
|
+
) {
|
|
44
|
+
const clientDir = join(out, 'client')
|
|
45
|
+
const staticDir = join(out, 'static')
|
|
46
|
+
const { mkdir } = await import('node:fs/promises')
|
|
47
|
+
await mkdir(join(clientDir, 'assets'), { recursive: true })
|
|
48
|
+
await mkdir(staticDir, { recursive: true })
|
|
49
|
+
const built = await buildPage(
|
|
50
|
+
serverEntry,
|
|
51
|
+
'app',
|
|
52
|
+
route.path,
|
|
53
|
+
'/probe.tsx',
|
|
54
|
+
route.params,
|
|
55
|
+
{ type: 'ssg', file: route.file, layouts: [], middlewares: [] } as any,
|
|
56
|
+
{ file: 'assets/probe.js' },
|
|
57
|
+
staticDir,
|
|
58
|
+
clientDir,
|
|
59
|
+
{},
|
|
60
|
+
'server/probe.js',
|
|
61
|
+
[],
|
|
62
|
+
[],
|
|
63
|
+
[],
|
|
64
|
+
[],
|
|
65
|
+
undefined,
|
|
66
|
+
undefined,
|
|
67
|
+
undefined,
|
|
68
|
+
undefined,
|
|
69
|
+
undefined,
|
|
70
|
+
skipNativeLoaders
|
|
71
|
+
)
|
|
72
|
+
return { built, clientDir, staticDir }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function nativeTwins(clientDir: string, loaderPath: string) {
|
|
76
|
+
const cached = join(clientDir, loaderPath.replace(/\.js$/, '.native.js'))
|
|
77
|
+
const uncached = join(
|
|
78
|
+
clientDir,
|
|
79
|
+
loaderPath.replace(LOADER_JS_POSTFIX, NATIVE_LOADER_JS_POSTFIX_UNCACHED)
|
|
80
|
+
)
|
|
81
|
+
return { cached, uncached }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function exists(path: string) {
|
|
85
|
+
try {
|
|
86
|
+
await readFile(path)
|
|
87
|
+
return true
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
it('omits native loader twins when skipped, keeping web output identical', async () => {
|
|
94
|
+
const serverEntry = await fixture()
|
|
95
|
+
for (const route of [
|
|
96
|
+
{ path: '/probe', file: './probe.tsx', params: { slug: 'probe' } },
|
|
97
|
+
{ path: '/gated', file: './gated.tsx', params: {} },
|
|
98
|
+
]) {
|
|
99
|
+
const enabled = await buildRoute(serverEntry, join(root!, 'enabled', route.path), route)
|
|
100
|
+
const skipped = await buildRoute(
|
|
101
|
+
serverEntry,
|
|
102
|
+
join(root!, 'skipped', route.path),
|
|
103
|
+
route,
|
|
104
|
+
true
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
// web loader and html are byte-identical either way
|
|
108
|
+
const [enabledLoader, skippedLoader] = await Promise.all([
|
|
109
|
+
readFile(join(enabled.clientDir, enabled.built.loaderPath), 'utf8'),
|
|
110
|
+
readFile(join(skipped.clientDir, skipped.built.loaderPath), 'utf8'),
|
|
111
|
+
])
|
|
112
|
+
expect(skippedLoader, route.path).toBe(enabledLoader)
|
|
113
|
+
const [enabledHtml, skippedHtml] = await Promise.all([
|
|
114
|
+
readFile(join(enabled.staticDir, `${route.path.slice(1)}.html`), 'utf8'),
|
|
115
|
+
readFile(join(skipped.staticDir, `${route.path.slice(1)}.html`), 'utf8'),
|
|
116
|
+
])
|
|
117
|
+
expect(skippedHtml, route.path).toBe(enabledHtml)
|
|
118
|
+
|
|
119
|
+
// native twins exist by default, absent when skipped
|
|
120
|
+
const enabledTwins = nativeTwins(enabled.clientDir, enabled.built.loaderPath)
|
|
121
|
+
expect(await exists(enabledTwins.cached), `${route.path} cached`).toBe(true)
|
|
122
|
+
expect(await exists(enabledTwins.uncached), `${route.path} uncached`).toBe(true)
|
|
123
|
+
const skippedTwins = nativeTwins(skipped.clientDir, skipped.built.loaderPath)
|
|
124
|
+
expect(await exists(skippedTwins.cached), `${route.path} cached`).toBe(false)
|
|
125
|
+
expect(await exists(skippedTwins.uncached), `${route.path} uncached`).toBe(false)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// the redirect loader still carries its signal on the web file
|
|
129
|
+
const skipped = await buildRoute(
|
|
130
|
+
serverEntry,
|
|
131
|
+
join(root!, 'redirect'),
|
|
132
|
+
{ path: '/gated', file: './gated.tsx', params: {} },
|
|
133
|
+
true
|
|
134
|
+
)
|
|
135
|
+
const webLoader = await readFile(
|
|
136
|
+
join(skipped.clientDir, skipped.built.loaderPath),
|
|
137
|
+
'utf8'
|
|
138
|
+
)
|
|
139
|
+
expect(webLoader).toContain('__oneRedirect')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('writes native loader twins by default', async () => {
|
|
143
|
+
const serverEntry = await fixture()
|
|
144
|
+
const { built, clientDir } = await buildRoute(
|
|
145
|
+
serverEntry,
|
|
146
|
+
join(root!, 'default'),
|
|
147
|
+
{ path: '/probe', file: './probe.tsx', params: { slug: 'probe' } }
|
|
148
|
+
)
|
|
149
|
+
const twins = nativeTwins(clientDir, built.loaderPath)
|
|
150
|
+
expect(await exists(twins.cached)).toBe(true)
|
|
151
|
+
expect(await exists(twins.uncached)).toBe(true)
|
|
152
|
+
})
|
package/src/cli/buildPage.ts
CHANGED
|
@@ -63,7 +63,8 @@ export async function buildPage(
|
|
|
63
63
|
criticalPreloads?: string[],
|
|
64
64
|
deferredPreloads?: string[],
|
|
65
65
|
useAfterLCP?: boolean,
|
|
66
|
-
useAfterLCPAggressive?: boolean
|
|
66
|
+
useAfterLCPAggressive?: boolean,
|
|
67
|
+
skipNativeLoaders?: boolean
|
|
67
68
|
): Promise<One.RouteBuildInfo> {
|
|
68
69
|
let t0 = performance.now()
|
|
69
70
|
|
|
@@ -275,10 +276,12 @@ prefetchCSS()
|
|
|
275
276
|
loaderPartialPath,
|
|
276
277
|
`export function loader(){return ${redirectData}}`
|
|
277
278
|
)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
if (!skipNativeLoaders) {
|
|
280
|
+
// native-friendly CJS version
|
|
281
|
+
const nativeCjs = `exports.loader = function(){return ${redirectData}}`
|
|
282
|
+
await outputFile(loaderPartialPath.replace(/\.js$/, '.native.js'), nativeCjs)
|
|
283
|
+
await outputFile(uncachedNativePath, nativeCjs)
|
|
284
|
+
}
|
|
282
285
|
loaderPath = getLoaderPath(path)
|
|
283
286
|
loaderData = {}
|
|
284
287
|
} else {
|
|
@@ -289,10 +292,12 @@ prefetchCSS()
|
|
|
289
292
|
// stub of its own to replace)
|
|
290
293
|
const data = JSON.stringify(loaderData)
|
|
291
294
|
await outputFile(loaderPartialPath, `export function loader(){return ${data}}`)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
295
|
+
if (!skipNativeLoaders) {
|
|
296
|
+
// native-friendly CJS version
|
|
297
|
+
const nativeCjs = `exports.loader = function(){return ${data}}`
|
|
298
|
+
await outputFile(loaderPartialPath.replace(/\.js$/, '.native.js'), nativeCjs)
|
|
299
|
+
await outputFile(uncachedNativePath, nativeCjs)
|
|
300
|
+
}
|
|
296
301
|
loaderPath = getLoaderPath(path)
|
|
297
302
|
}
|
|
298
303
|
}
|
|
@@ -86,7 +86,8 @@ parentPort.on('message', async (msg: any) => {
|
|
|
86
86
|
msg.args.criticalPreloads,
|
|
87
87
|
msg.args.deferredPreloads,
|
|
88
88
|
msg.args.useAfterLCP,
|
|
89
|
-
msg.args.useAfterLCPAggressive
|
|
89
|
+
msg.args.useAfterLCPAggressive,
|
|
90
|
+
msg.args.skipNativeLoaders
|
|
90
91
|
)
|
|
91
92
|
})
|
|
92
93
|
// loader data is already in the artifacts and is discarded by the parent.
|
package/src/cli/workerPool.ts
CHANGED
|
@@ -127,6 +127,7 @@ export class BuildWorkerPool {
|
|
|
127
127
|
deferredPreloads?: string[]
|
|
128
128
|
useAfterLCP?: boolean
|
|
129
129
|
useAfterLCPAggressive?: boolean
|
|
130
|
+
skipNativeLoaders?: boolean
|
|
130
131
|
}): Promise<any> {
|
|
131
132
|
if (this._terminated) {
|
|
132
133
|
throw new Error('Worker pool has been terminated')
|
package/src/headless-server.ts
CHANGED
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
* })
|
|
28
28
|
*
|
|
29
29
|
* const response = await server.fetch(request)
|
|
30
|
+
* // pass a request-scoped static HTML reader as the fourth argument for SPA or SSG
|
|
30
31
|
* server.updateRoutes(newManifest, newRoutes)
|
|
31
32
|
*/
|
|
32
33
|
|
|
33
34
|
export { createWorkerHandler, type LazyRoutes } from './server/workerHandler'
|
|
34
35
|
export { compileManifest } from './createHandleRequest'
|
|
35
36
|
export type { RequestHandlers } from './createHandleRequest'
|
|
36
|
-
export { getFetchStaticHtml, setFetchStaticHtml } from './server/staticHtmlFetcher'
|
|
37
37
|
export type { One } from './vite/types'
|
|
38
38
|
|
|
39
39
|
import { setServerGlobals } from './server/setServerGlobals'
|
|
@@ -92,7 +92,7 @@ export async function createServer(options: HeadlessServerOptions) {
|
|
|
92
92
|
})
|
|
93
93
|
|
|
94
94
|
return {
|
|
95
|
-
/** handle
|
|
95
|
+
/** handle a request with an optional request-scoped static HTML reader */
|
|
96
96
|
fetch: handler.handleRequest,
|
|
97
97
|
|
|
98
98
|
/** hot-swap the route table without recreating the server */
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { validateCallback } from '../validateCallback'
|
|
|
2
2
|
import { rethrowNativeError } from '../nativeError'
|
|
3
3
|
import type {
|
|
4
4
|
AudioPlaybackStatus,
|
|
5
|
+
AudioPlayOptions,
|
|
5
6
|
AudioRecordingPermission,
|
|
6
7
|
AudioRecordingResult,
|
|
7
8
|
AudioRecordingStatus,
|
|
@@ -15,6 +16,7 @@ import { AudioPlayer, createPlayer } from './player.native'
|
|
|
15
16
|
export type {
|
|
16
17
|
AudioPlaybackState,
|
|
17
18
|
AudioPlaybackStatus,
|
|
19
|
+
AudioPlayOptions,
|
|
18
20
|
AudioRecordingPermission,
|
|
19
21
|
AudioRecordingResult,
|
|
20
22
|
AudioRecordingState,
|
|
@@ -33,8 +35,8 @@ export const Audio = Object.freeze({
|
|
|
33
35
|
native().getRecordingPermissionStatus().catch(rethrowNativeError),
|
|
34
36
|
requestRecordingPermission: (): Promise<AudioRecordingPermission> =>
|
|
35
37
|
native().requestRecordingPermission().catch(rethrowNativeError),
|
|
36
|
-
play: (uri: string): Promise<AudioPlaybackStatus> =>
|
|
37
|
-
native().play(uri).catch(rethrowNativeError),
|
|
38
|
+
play: (uri: string, options?: AudioPlayOptions): Promise<AudioPlaybackStatus> =>
|
|
39
|
+
native().play(uri, options ?? {}).catch(rethrowNativeError),
|
|
38
40
|
getPlaybackStatus: (): Promise<AudioPlaybackStatus> =>
|
|
39
41
|
native().getPlaybackStatus().catch(rethrowNativeError),
|
|
40
42
|
pause: (): Promise<AudioPlaybackStatus> => native().pause().catch(rethrowNativeError),
|
|
@@ -2,17 +2,24 @@ import { Audio as unavailable } from './unavailable'
|
|
|
2
2
|
import { validateCallback } from '../validateCallback'
|
|
3
3
|
import type {
|
|
4
4
|
AudioPlaybackStatus,
|
|
5
|
+
AudioPlayOptions,
|
|
5
6
|
AudioRecordingPermission,
|
|
6
7
|
AudioRecordingResult,
|
|
7
8
|
AudioRecordingStatus,
|
|
8
9
|
AudioNowPlayingInfo,
|
|
9
10
|
AudioRemoteCommandEvent,
|
|
10
11
|
} from '../specs/OneAudio.nitro'
|
|
12
|
+
import { addRemoteListener, claimNowPlaying, releaseNowPlaying } from './nowPlaying'
|
|
11
13
|
import { AudioPlayer, createPlayer } from './player'
|
|
12
14
|
export type { PitchCorrectionQuality } from '../specs/OneAudio.nitro'
|
|
13
15
|
export type * from './types'
|
|
14
16
|
export type * from './unavailable'
|
|
15
17
|
let player: HTMLAudioElement | undefined
|
|
18
|
+
// a play with a position or paused flag, between creation and the landed
|
|
19
|
+
// seek. paused is the intent pause and resume update while it loads.
|
|
20
|
+
let pendingLoad:
|
|
21
|
+
| { audio: HTMLAudioElement; paused: boolean; cancel?: () => void }
|
|
22
|
+
| undefined
|
|
16
23
|
let playbackError: string | undefined
|
|
17
24
|
let permission: AudioRecordingPermission = 'undetermined'
|
|
18
25
|
let openingRecording = false
|
|
@@ -27,8 +34,6 @@ let recording:
|
|
|
27
34
|
error?: Error
|
|
28
35
|
}
|
|
29
36
|
| undefined
|
|
30
|
-
const remoteListeners = new Set<(event: AudioRemoteCommandEvent) => void>()
|
|
31
|
-
let remoteInstalled = false
|
|
32
37
|
|
|
33
38
|
async function getRecordingPermissionStatus(): Promise<AudioRecordingPermission> {
|
|
34
39
|
if (typeof window === 'undefined' || !navigator.mediaDevices?.getUserMedia)
|
|
@@ -74,7 +79,7 @@ function playbackStatus(): AudioPlaybackStatus {
|
|
|
74
79
|
? 'failed'
|
|
75
80
|
: player.ended
|
|
76
81
|
? 'ended'
|
|
77
|
-
: player.readyState < 2
|
|
82
|
+
: pendingLoad || player.readyState < 2
|
|
78
83
|
? 'loading'
|
|
79
84
|
: player.paused
|
|
80
85
|
? 'paused'
|
|
@@ -90,7 +95,9 @@ function requirePlayer(): HTMLAudioElement {
|
|
|
90
95
|
return player
|
|
91
96
|
}
|
|
92
97
|
function clearPlayer(): void {
|
|
93
|
-
|
|
98
|
+
releaseNowPlaying('global')
|
|
99
|
+
pendingLoad?.cancel?.()
|
|
100
|
+
pendingLoad = undefined
|
|
94
101
|
if (player) {
|
|
95
102
|
player.pause()
|
|
96
103
|
player.removeAttribute('src')
|
|
@@ -152,69 +159,88 @@ function recorderChange(event: 'pause' | 'resume'): Promise<AudioRecordingStatus
|
|
|
152
159
|
}
|
|
153
160
|
})
|
|
154
161
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
remoteInstalled = true
|
|
162
|
+
// resolves once the element has metadata and sits at the position, without
|
|
163
|
+
// playing. replacement cancels it through the load's cancel hook.
|
|
164
|
+
function prepareAt(load: NonNullable<typeof pendingLoad>, positionMs: number): Promise<void> {
|
|
165
|
+
const { audio } = load
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const cleanup = () => {
|
|
168
|
+
audio.removeEventListener('loadedmetadata', ready)
|
|
169
|
+
audio.removeEventListener('seeked', done)
|
|
170
|
+
audio.removeEventListener('error', failed)
|
|
171
|
+
load.cancel = undefined
|
|
172
|
+
}
|
|
173
|
+
let seconds = 0
|
|
174
|
+
// a source without range support lands somewhere else, often 0; fail
|
|
175
|
+
// rather than let a later resume play the opening.
|
|
176
|
+
const done = () => {
|
|
177
|
+
cleanup()
|
|
178
|
+
if (Math.abs(audio.currentTime - seconds) > 0.25)
|
|
179
|
+
reject(new Error('Audio.play: the source cannot seek to the position'))
|
|
180
|
+
else resolve()
|
|
181
|
+
}
|
|
182
|
+
const failed = () => {
|
|
183
|
+
cleanup()
|
|
184
|
+
reject(new Error(`Audio.play: ${audio.error?.message || 'decoding failed'}`))
|
|
185
|
+
}
|
|
186
|
+
function ready() {
|
|
187
|
+
audio.removeEventListener('loadedmetadata', ready)
|
|
188
|
+
seconds = Math.min(
|
|
189
|
+
positionMs / 1000,
|
|
190
|
+
Number.isFinite(audio.duration) ? audio.duration : Infinity
|
|
191
|
+
)
|
|
192
|
+
if (audio.currentTime === seconds) return done()
|
|
193
|
+
audio.addEventListener('seeked', done, { once: true })
|
|
194
|
+
audio.currentTime = seconds
|
|
195
|
+
}
|
|
196
|
+
load.cancel = () => {
|
|
197
|
+
cleanup()
|
|
198
|
+
reject(new Error('Audio.play: playback was replaced'))
|
|
199
|
+
}
|
|
200
|
+
audio.addEventListener('error', failed, { once: true })
|
|
201
|
+
if (audio.readyState >= 1) ready()
|
|
202
|
+
else audio.addEventListener('loadedmetadata', ready, { once: true })
|
|
203
|
+
})
|
|
198
204
|
}
|
|
199
205
|
export const Audio = Object.freeze({
|
|
200
206
|
getRecordingPermissionStatus,
|
|
201
207
|
requestRecordingPermission,
|
|
202
|
-
play: async (uri: string): Promise<AudioPlaybackStatus> => {
|
|
203
|
-
if (typeof window === 'undefined') return unavailable.play(uri)
|
|
208
|
+
play: async (uri: string, options?: AudioPlayOptions): Promise<AudioPlaybackStatus> => {
|
|
209
|
+
if (typeof window === 'undefined') return unavailable.play(uri, options)
|
|
204
210
|
if (openingRecording || recording)
|
|
205
211
|
throw new Error('Audio.play: stop the current recording first')
|
|
206
212
|
const url = new URL(uri, document.baseURI)
|
|
207
213
|
if (!['https:', 'http:', 'blob:', 'data:'].includes(url.protocol))
|
|
208
214
|
throw new Error('Audio.play: invalid browser audio URI')
|
|
215
|
+
const prepare = options?.positionMs !== undefined || options?.paused !== undefined
|
|
216
|
+
const positionMs = options?.positionMs ?? 0
|
|
217
|
+
if (typeof positionMs !== 'number' || !Number.isFinite(positionMs) || positionMs < 0)
|
|
218
|
+
throw new Error('Audio.play: position must be finite and nonnegative')
|
|
209
219
|
clearPlayer()
|
|
210
220
|
const audio = new window.Audio(url.href)
|
|
211
221
|
player = audio
|
|
212
|
-
for (const event of ['play', 'pause', 'ended'])
|
|
213
|
-
audio.addEventListener(event, syncNowPlaying)
|
|
214
222
|
audio.addEventListener('error', () => {
|
|
215
223
|
if (player === audio)
|
|
216
224
|
playbackError = audio.error?.message ?? 'Audio.play: decoding failed'
|
|
217
225
|
})
|
|
226
|
+
if (prepare) {
|
|
227
|
+
const load: NonNullable<typeof pendingLoad> = { audio, paused: options?.paused ?? false }
|
|
228
|
+
pendingLoad = load
|
|
229
|
+
audio.preload = 'auto'
|
|
230
|
+
try {
|
|
231
|
+
await prepareAt(load, positionMs)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (pendingLoad === load) {
|
|
234
|
+
pendingLoad = undefined
|
|
235
|
+
playbackError = (error as Error).message
|
|
236
|
+
}
|
|
237
|
+
throw error
|
|
238
|
+
}
|
|
239
|
+
if (player !== audio || pendingLoad !== load)
|
|
240
|
+
throw new Error('Audio.play: playback was replaced')
|
|
241
|
+
pendingLoad = undefined
|
|
242
|
+
if (load.paused) return playbackStatus()
|
|
243
|
+
}
|
|
218
244
|
try {
|
|
219
245
|
await audio.play()
|
|
220
246
|
} catch (error) {
|
|
@@ -228,6 +254,10 @@ export const Audio = Object.freeze({
|
|
|
228
254
|
pause: async (): Promise<AudioPlaybackStatus> => {
|
|
229
255
|
if (typeof window === 'undefined') return unavailable.pause()
|
|
230
256
|
const audio = requirePlayer()
|
|
257
|
+
if (pendingLoad) {
|
|
258
|
+
pendingLoad.paused = true
|
|
259
|
+
return playbackStatus()
|
|
260
|
+
}
|
|
231
261
|
if (!audio.paused)
|
|
232
262
|
await new Promise<void>((resolve) => {
|
|
233
263
|
audio.addEventListener('pause', () => resolve(), { once: true })
|
|
@@ -238,6 +268,11 @@ export const Audio = Object.freeze({
|
|
|
238
268
|
resume: async (): Promise<AudioPlaybackStatus> => {
|
|
239
269
|
if (typeof window === 'undefined') return unavailable.resume()
|
|
240
270
|
const audio = requirePlayer()
|
|
271
|
+
// a prepared load plays itself once its seek lands.
|
|
272
|
+
if (pendingLoad) {
|
|
273
|
+
pendingLoad.paused = false
|
|
274
|
+
return playbackStatus()
|
|
275
|
+
}
|
|
241
276
|
if (audio.ended) audio.currentTime = 0
|
|
242
277
|
await audio.play()
|
|
243
278
|
if (player !== audio) throw new Error('Audio.resume: playback was replaced')
|
|
@@ -248,7 +283,8 @@ export const Audio = Object.freeze({
|
|
|
248
283
|
if (!Number.isFinite(positionMs) || positionMs < 0)
|
|
249
284
|
throw new Error('Audio.seek: position must be finite and nonnegative')
|
|
250
285
|
const audio = requirePlayer()
|
|
251
|
-
if (audio.readyState < 1
|
|
286
|
+
if (audio.readyState < 1 || pendingLoad)
|
|
287
|
+
throw new Error('Audio.seek: playback is not ready')
|
|
252
288
|
const seconds = Math.min(
|
|
253
289
|
positionMs / 1000,
|
|
254
290
|
Number.isFinite(audio.duration) ? audio.duration : Infinity
|
|
@@ -402,27 +438,23 @@ export const Audio = Object.freeze({
|
|
|
402
438
|
watchInterruptions: unavailable.watchInterruptions,
|
|
403
439
|
setNowPlayingInfo: async (info: AudioNowPlayingInfo): Promise<void> => {
|
|
404
440
|
if (typeof window === 'undefined') return unavailable.setNowPlayingInfo(info)
|
|
405
|
-
if (!info.title.trim())
|
|
406
|
-
throw new Error('Audio.setNowPlayingInfo: title must not be empty')
|
|
407
441
|
const audio = requirePlayer()
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
installRemote()
|
|
421
|
-
syncNowPlaying()
|
|
442
|
+
await claimNowPlaying(
|
|
443
|
+
{
|
|
444
|
+
key: 'global',
|
|
445
|
+
element: audio,
|
|
446
|
+
play: Audio.resume,
|
|
447
|
+
pause: Audio.pause,
|
|
448
|
+
seek: Audio.seek,
|
|
449
|
+
},
|
|
450
|
+
info,
|
|
451
|
+
'Audio.setNowPlayingInfo',
|
|
452
|
+
() => player === audio
|
|
453
|
+
)
|
|
422
454
|
},
|
|
423
455
|
clearNowPlayingInfo: async (): Promise<void> => {
|
|
424
456
|
if (typeof window === 'undefined') return unavailable.clearNowPlayingInfo()
|
|
425
|
-
|
|
457
|
+
releaseNowPlaying('global')
|
|
426
458
|
},
|
|
427
459
|
watchRemoteCommands: (
|
|
428
460
|
onEvent: (event: AudioRemoteCommandEvent) => void
|
|
@@ -430,11 +462,7 @@ export const Audio = Object.freeze({
|
|
|
430
462
|
validateCallback(onEvent, 'Audio.watchRemoteCommands: onEvent must be a function')
|
|
431
463
|
if (typeof window === 'undefined' || !navigator.mediaSession)
|
|
432
464
|
return unavailable.watchRemoteCommands(onEvent)
|
|
433
|
-
|
|
434
|
-
return () => {
|
|
435
|
-
remoteListeners.delete(onEvent)
|
|
436
|
-
|
|
437
|
-
}
|
|
465
|
+
return addRemoteListener(onEvent)
|
|
438
466
|
},
|
|
439
467
|
AudioPlayer,
|
|
440
468
|
createPlayer,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { AudioNowPlayingInfo, AudioRemoteCommandEvent } from '../specs/OneAudio.nitro'
|
|
2
|
+
|
|
3
|
+
// the one browser media session, owned by the singleton or by one player
|
|
4
|
+
// instance. a claim moves ownership without clearing the metadata first;
|
|
5
|
+
// owners release only their own claim, so an old owner's cleanup never
|
|
6
|
+
// clears a newer one, and a claim that finishes after a newer claim or
|
|
7
|
+
// release (slow artwork) rejects instead of taking ownership back.
|
|
8
|
+
|
|
9
|
+
export type NowPlayingOwner = {
|
|
10
|
+
// 'global' for the singleton, else the player id.
|
|
11
|
+
key: string
|
|
12
|
+
element: HTMLAudioElement
|
|
13
|
+
play: () => Promise<unknown>
|
|
14
|
+
pause: () => Promise<unknown>
|
|
15
|
+
seek: (positionMs: number) => Promise<unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const listeners = new Set<(event: AudioRemoteCommandEvent) => void>()
|
|
19
|
+
const syncEvents = ['play', 'pause', 'ended'] as const
|
|
20
|
+
let owner: NowPlayingOwner | undefined
|
|
21
|
+
let claim = 0
|
|
22
|
+
let pendingKey: string | undefined
|
|
23
|
+
let installed = false
|
|
24
|
+
|
|
25
|
+
function sync(): void {
|
|
26
|
+
if (!owner || !navigator.mediaSession?.metadata) return
|
|
27
|
+
navigator.mediaSession.playbackState =
|
|
28
|
+
owner.element.paused || owner.element.ended ? 'paused' : 'playing'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setOwner(next: NowPlayingOwner | undefined): void {
|
|
32
|
+
if (owner?.element !== next?.element) {
|
|
33
|
+
for (const event of syncEvents) {
|
|
34
|
+
owner?.element.removeEventListener(event, sync)
|
|
35
|
+
next?.element.addEventListener(event, sync)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
owner = next
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function remote(event: AudioRemoteCommandEvent): Promise<void> {
|
|
42
|
+
const current = owner
|
|
43
|
+
if (!current || !navigator.mediaSession?.metadata) return
|
|
44
|
+
try {
|
|
45
|
+
if (event.type === 'play') await current.play()
|
|
46
|
+
else if (event.type === 'pause') await current.pause()
|
|
47
|
+
else await current.seek(event.positionMs!)
|
|
48
|
+
} catch {
|
|
49
|
+
// the browser callback has no error channel; failed work emits no success.
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (owner !== current) return
|
|
53
|
+
sync()
|
|
54
|
+
const emitted = current.key === 'global' ? event : { ...event, playerId: current.key }
|
|
55
|
+
for (const listener of [...listeners]) listener(emitted)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function install(session: MediaSession): void {
|
|
59
|
+
if (installed) return
|
|
60
|
+
session.setActionHandler('play', () => remote({ type: 'play' }))
|
|
61
|
+
session.setActionHandler('pause', () => remote({ type: 'pause' }))
|
|
62
|
+
session.setActionHandler('seekto', (event) =>
|
|
63
|
+
remote({ type: 'seek', positionMs: (event.seekTime ?? 0) * 1000 })
|
|
64
|
+
)
|
|
65
|
+
installed = true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function claimNowPlaying(
|
|
69
|
+
next: NowPlayingOwner,
|
|
70
|
+
info: AudioNowPlayingInfo,
|
|
71
|
+
verb: string,
|
|
72
|
+
alive: () => boolean
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (!info.title.trim()) throw new Error(`${verb}: title must not be empty`)
|
|
75
|
+
if (!navigator.mediaSession) throw new Error('Audio: Media Session is unavailable')
|
|
76
|
+
const ticket = ++claim
|
|
77
|
+
pendingKey = next.key
|
|
78
|
+
if (info.artworkUri) {
|
|
79
|
+
const artwork = new window.Image()
|
|
80
|
+
artwork.src = info.artworkUri
|
|
81
|
+
await artwork.decode()
|
|
82
|
+
if (!alive()) throw new Error(`${verb}: playback was replaced`)
|
|
83
|
+
if (ticket !== claim) throw new Error(`${verb}: now playing was claimed or cleared again`)
|
|
84
|
+
}
|
|
85
|
+
if (pendingKey === next.key) pendingKey = undefined
|
|
86
|
+
const session = navigator.mediaSession
|
|
87
|
+
setOwner(next)
|
|
88
|
+
session.metadata = new MediaMetadata({
|
|
89
|
+
title: info.title,
|
|
90
|
+
artist: info.artist,
|
|
91
|
+
album: info.albumTitle,
|
|
92
|
+
artwork: info.artworkUri ? [{ src: info.artworkUri }] : [],
|
|
93
|
+
})
|
|
94
|
+
install(session)
|
|
95
|
+
sync()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function releaseNowPlaying(key: string): void {
|
|
99
|
+
if (pendingKey === key) {
|
|
100
|
+
claim++
|
|
101
|
+
pendingKey = undefined
|
|
102
|
+
}
|
|
103
|
+
if (owner?.key !== key) return
|
|
104
|
+
setOwner(undefined)
|
|
105
|
+
if (typeof window === 'undefined' || !navigator.mediaSession) return
|
|
106
|
+
const session = navigator.mediaSession
|
|
107
|
+
if (installed)
|
|
108
|
+
for (const action of ['play', 'pause', 'seekto'] as const)
|
|
109
|
+
session.setActionHandler(action, null)
|
|
110
|
+
installed = false
|
|
111
|
+
session.metadata = null
|
|
112
|
+
session.playbackState = 'none'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function addRemoteListener(
|
|
116
|
+
onEvent: (event: AudioRemoteCommandEvent) => void
|
|
117
|
+
): () => void {
|
|
118
|
+
listeners.add(onEvent)
|
|
119
|
+
return () => {
|
|
120
|
+
listeners.delete(onEvent)
|
|
121
|
+
}
|
|
122
|
+
}
|