diff --git a/webapp/src/App.svelte b/webapp/src/App.svelte
index 84b609d..26522d7 100644
--- a/webapp/src/App.svelte
+++ b/webapp/src/App.svelte
@@ -4,19 +4,12 @@ import { Toaster } from 'svelte-sonner'
import Footer from '$lib/layouts/Footer.svelte'
import Main from '$lib/layouts/Main.svelte'
-import Unsupported from '$lib/layouts/Unsupported.svelte'
-
-const unsupported = navigator.requestMIDIAccess === undefined
- {#if unsupported}
-
- {:else}
-
- {/if}
+
diff --git a/webapp/src/lib/contexts/device.svelte.ts b/webapp/src/lib/contexts/device.svelte.ts
index 2eaa690..bba9540 100644
--- a/webapp/src/lib/contexts/device.svelte.ts
+++ b/webapp/src/lib/contexts/device.svelte.ts
@@ -1,6 +1,8 @@
import { Device } from '$lib/image/device'
-import { getContext, setContext, untrack } from 'svelte'
+import { getContext, setContext } from 'svelte'
import { toast } from 'svelte-sonner'
+import type { MidiContext } from './midi.svelte'
+import { effect } from '$lib/utils.svelte'
function isInkclip(port: MIDIPort): boolean {
return !!(
@@ -10,54 +12,59 @@ function isInkclip(port: MIDIPort): boolean {
}
export class DeviceContext {
+ private midi: MidiContext
private input: MIDIInput | null = $state(null)
private output: MIDIOutput | null = $state(null)
device: Device | null = $state(null)
- constructor() {
- $effect(() => {
- if (untrack(() => this.device == null)) {
- if (this.input == null || this.output == null) return
- this.device = new Device(this.input, this.output)
- toast.info('Device connected')
- } else {
- if (this.input != null && this.output != null) return
- this.device = null
- toast.info('Device disconnected')
- }
- })
+ constructor(midi: MidiContext) {
+ this.midi = midi
+ effect(
+ () => [this.midi.midi],
+ () => this.initialize(),
+ )
+ effect(
+ () => [this.input, this.output],
+ () => this.update(),
+ )
}
- async initialize() {
- try {
- const midi = await navigator.requestMIDIAccess({ sysex: true })
- const inputs = Array.from(midi.inputs.values())
- const outputs = Array.from(midi.outputs.values())
- console.log(
- 'MIDI inputs:',
- Array.from(midi.inputs.values()),
- 'outputs:',
- Array.from(midi.outputs.values()),
- )
-
- this.input = inputs.find(isInkclip) ?? null
- this.output = outputs.find(isInkclip) ?? null
-
- midi.addEventListener('statechange', e => {
- console.log(`MIDI port statechange:`, e.port)
- if (!e.port || !isInkclip(e.port)) return
- if (e.port?.state === 'connected') {
- if (e.port.type === 'input' && !this.input) this.input = e.port as MIDIInput
- else if (e.port.type === 'output' && !this.output) this.output = e.port as MIDIOutput
- } else {
- if (e.port.type === 'input' && e.port === this.input) this.input = null
- else if (e.port.type === 'output' && e.port === this.output) this.output = null
- }
- })
- } catch (e) {
- toast.error(`Failed to acquire MIDI access: ${e}`)
+ private update() {
+ if (this.device == null) {
+ if (this.input == null || this.output == null) return
+ this.device = new Device(this.input, this.output)
+ toast.info('Device connected')
+ } else {
+ if (this.input != null && this.output != null) return
+ this.device = null
+ toast.info('Device disconnected')
}
}
+
+ private async initialize() {
+ if (!this.midi.midi) return
+ const midi = this.midi.midi
+
+ const inputs = Array.from(midi.inputs.values())
+ const outputs = Array.from(midi.outputs.values())
+ console.log('MIDI inputs:', Array.from(midi.inputs.values()))
+ console.log('MIDI outputs:', Array.from(midi.outputs.values()))
+
+ this.input = inputs.find(isInkclip) ?? null
+ this.output = outputs.find(isInkclip) ?? null
+
+ midi.addEventListener('statechange', e => {
+ console.log(`MIDI port statechange:`, e.port)
+ if (!e.port || !isInkclip(e.port)) return
+ if (e.port?.state === 'connected') {
+ if (e.port.type === 'input' && !this.input) this.input = e.port as MIDIInput
+ else if (e.port.type === 'output' && !this.output) this.output = e.port as MIDIOutput
+ } else {
+ if (e.port.type === 'input' && e.port === this.input) this.input = null
+ else if (e.port.type === 'output' && e.port === this.output) this.output = null
+ }
+ })
+ }
}
const DeviceContextToken = Symbol('device')
@@ -66,7 +73,7 @@ export function getDeviceContext(): DeviceContext {
return getContext(DeviceContextToken)
}
-export function createDeviceContext(): DeviceContext {
- let ctx = new DeviceContext()
+export function createDeviceContext(midi: MidiContext): DeviceContext {
+ let ctx = new DeviceContext(midi)
return setContext(DeviceContextToken, ctx)
}
diff --git a/webapp/src/lib/contexts/midi.svelte.ts b/webapp/src/lib/contexts/midi.svelte.ts
new file mode 100644
index 0000000..4e93d9d
--- /dev/null
+++ b/webapp/src/lib/contexts/midi.svelte.ts
@@ -0,0 +1,72 @@
+import { getContext, setContext } from 'svelte'
+
+export class MidiContext {
+ // Value before initialize() should not be relied upon. 'granted' is used as
+ // initial value purely because it doesn't bring up a flash of dialog on
+ // page load.
+ perm: 'unsupported' | PermissionState = $state('granted')
+ midi: MIDIAccess | null = $state(null)
+
+ async initialize() {
+ if (navigator.requestMIDIAccess == null) {
+ // This browser definitely doesn't support Web MIDI
+ this.perm = 'unsupported'
+ return
+ }
+
+ // We decide how to acquire MIDI access based on likelyPerm:
+ // - 'granted': we should try implicitly acquiring MIDI access.
+ // - 'prompt': we should display a dialog explaining why we need MIDI
+ // before trying to acquire.
+ // - 'denied': we are denied MIDI access.
+ let likelyPerm: PermissionState
+
+ if (navigator.permissions == null) {
+ // We don't know our permissions. Try to acquire implicitly nonetheless,
+ // since we think the advantage of auto-connect outweighs the browser
+ // prompting the user without warning
+ likelyPerm = 'granted'
+ } else {
+ try {
+ const status = await navigator.permissions.query({
+ name: 'midi',
+ sysex: true,
+ } as PermissionDescriptor)
+ console.log('MIDI permission status:', status)
+ likelyPerm = status.state
+ status.addEventListener('change', () => (this.perm = status.state))
+ } catch (e) {
+ // Non-compliant Permissions API. Since we know requestMIDIAccess is
+ // present, try acquiring implicitly nonetheless.
+ console.error('Error querying permission:', e)
+ likelyPerm = 'granted'
+ }
+ }
+
+ if (likelyPerm === 'granted') await this.acquire()
+ else this.perm = likelyPerm
+ }
+
+ // Attempt to acquire MIDI access. If permission isn't granted already, this
+ // will likely cause the browser to prompt the user.
+ async acquire() {
+ try {
+ this.midi = await navigator.requestMIDIAccess({ sysex: true })
+ this.perm = 'granted'
+ } catch (e) {
+ // The user or OS denied us access, or no support
+ console.error('Error acquiring MIDI access:', e)
+ this.perm = 'denied'
+ }
+ }
+}
+
+const MidiContextToken = Symbol('midi')
+
+export function getMidiContext(): MidiContext {
+ return getContext(MidiContextToken)
+}
+
+export function createMidiContext(): MidiContext {
+ return setContext(MidiContextToken, new MidiContext())
+}
diff --git a/webapp/src/lib/layouts/Main.svelte b/webapp/src/lib/layouts/Main.svelte
index da2a948..73cc956 100644
--- a/webapp/src/lib/layouts/Main.svelte
+++ b/webapp/src/lib/layouts/Main.svelte
@@ -9,16 +9,21 @@ import { createConversionConfig } from '$lib/contexts/config.svelte'
import { createFilesContext } from '$lib/contexts/files.svelte'
import { createImageContext } from '$lib/contexts/image.svelte'
import { createRenderedContext } from '$lib/contexts/rendered.svelte'
+import { createMidiContext } from '$lib/contexts/midi.svelte'
import { onMount } from 'svelte'
+import PermissionRequestDialog from './dialog/PermissionRequestDialog.svelte'
+import PermissionDeniedDialog from './dialog/PermissionDeniedDialog.svelte'
+import UnsupportedDialog from './dialog/UnsupportedDialog.svelte'
-const deviceCtx = createDeviceContext()
+const midiCtx = createMidiContext()
+createDeviceContext(midiCtx)
const config = createConversionConfig()
const filesCtx = createFilesContext()
const imageCtx = createImageContext(filesCtx)
createRenderedContext(imageCtx, config)
-onMount(() => {
- deviceCtx.initialize()
+onMount(async () => {
+ await midiCtx.initialize()
})
@@ -34,4 +39,8 @@ onMount(() => {
+
+
+
+
diff --git a/webapp/src/lib/layouts/dialog/PermissionDeniedDialog.svelte b/webapp/src/lib/layouts/dialog/PermissionDeniedDialog.svelte
new file mode 100644
index 0000000..e7590c0
--- /dev/null
+++ b/webapp/src/lib/layouts/dialog/PermissionDeniedDialog.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ MIDI permission needed
+
+
+
+ We detected that we are denied MIDI access. This means this tool cannot function properly.
+
+
+ To use this tool, you will need to grant us MIDI permission manually in your browser
+ settings, and then reload this page.
+
+
+
+
+
diff --git a/webapp/src/lib/layouts/dialog/PermissionRequestDialog.svelte b/webapp/src/lib/layouts/dialog/PermissionRequestDialog.svelte
new file mode 100644
index 0000000..616e928
--- /dev/null
+++ b/webapp/src/lib/layouts/dialog/PermissionRequestDialog.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ MIDI permission needed
+
+
+ Write to Inkclip uses the Web MIDI API to talk to your devices.
+
+ Click on "I understand", and then follow your browser's prompt to grant us permission.
+
+
+
+
+ midiCtx.acquire()}>I understand
+
+
+
+
diff --git a/webapp/src/lib/layouts/Unsupported.svelte b/webapp/src/lib/layouts/dialog/UnsupportedDialog.svelte
similarity index 63%
rename from webapp/src/lib/layouts/Unsupported.svelte
rename to webapp/src/lib/layouts/dialog/UnsupportedDialog.svelte
index 7766c07..bcf8a37 100644
--- a/webapp/src/lib/layouts/Unsupported.svelte
+++ b/webapp/src/lib/layouts/dialog/UnsupportedDialog.svelte
@@ -1,18 +1,17 @@
-
-
- Not Supported
-
-
-
+
-
+
Browser not supported
@@ -21,7 +20,8 @@
We recommend using a Chromium-based browser, such as a recent version of
Google Chrome,
Microsoft Edge, or
- Opera. Firefox may also work, although we are aware of more issues there.
+ Opera. Firefox may also work, although we are aware
+ of more issues there.
diff --git a/webapp/src/lib/utils.svelte.ts b/webapp/src/lib/utils.svelte.ts
new file mode 100644
index 0000000..682986c
--- /dev/null
+++ b/webapp/src/lib/utils.svelte.ts
@@ -0,0 +1,11 @@
+import { untrack } from 'svelte'
+
+/**
+ * Like $effect(), but requires you to declare dependencies explicitly. Conceptually, `track` should be a pure function that returns the dependencies, and `action` is executed whenever these dependencies change.
+ */
+export function effect(track: () => unknown, action: () => unknown) {
+ $effect(() => {
+ track()
+ untrack(action)
+ })
+}