Implement MIDI permission detection and user prompting

This commit is contained in:
daylily
2026-01-12 18:23:13 -06:00
parent 2af6e1726b
commit 63134df7d6
8 changed files with 216 additions and 64 deletions
+1 -8
View File
@@ -4,19 +4,12 @@ import { Toaster } from 'svelte-sonner'
import Footer from '$lib/layouts/Footer.svelte' import Footer from '$lib/layouts/Footer.svelte'
import Main from '$lib/layouts/Main.svelte' import Main from '$lib/layouts/Main.svelte'
import Unsupported from '$lib/layouts/Unsupported.svelte'
const unsupported = navigator.requestMIDIAccess === undefined
</script> </script>
<ModeWatcher /> <ModeWatcher />
<Toaster position="bottom-center" duration={2000} theme={mode.current} invert /> <Toaster position="bottom-center" duration={2000} theme={mode.current} invert />
<div class="max-w-7xl min-h-dvh m-auto p-6 stack gap-4"> <div class="max-w-7xl min-h-dvh m-auto p-6 stack gap-4">
{#if unsupported} <Main />
<Unsupported />
{:else}
<Main />
{/if}
<Footer /> <Footer />
</div> </div>
+50 -43
View File
@@ -1,6 +1,8 @@
import { Device } from '$lib/image/device' import { Device } from '$lib/image/device'
import { getContext, setContext, untrack } from 'svelte' import { getContext, setContext } from 'svelte'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import type { MidiContext } from './midi.svelte'
import { effect } from '$lib/utils.svelte'
function isInkclip(port: MIDIPort): boolean { function isInkclip(port: MIDIPort): boolean {
return !!( return !!(
@@ -10,54 +12,59 @@ function isInkclip(port: MIDIPort): boolean {
} }
export class DeviceContext { export class DeviceContext {
private midi: MidiContext
private input: MIDIInput | null = $state(null) private input: MIDIInput | null = $state(null)
private output: MIDIOutput | null = $state(null) private output: MIDIOutput | null = $state(null)
device: Device | null = $state(null) device: Device | null = $state(null)
constructor() { constructor(midi: MidiContext) {
$effect(() => { this.midi = midi
if (untrack(() => this.device == null)) { effect(
if (this.input == null || this.output == null) return () => [this.midi.midi],
this.device = new Device(this.input, this.output) () => this.initialize(),
toast.info('Device connected') )
} else { effect(
if (this.input != null && this.output != null) return () => [this.input, this.output],
this.device = null () => this.update(),
toast.info('Device disconnected') )
}
})
} }
async initialize() { private update() {
try { if (this.device == null) {
const midi = await navigator.requestMIDIAccess({ sysex: true }) if (this.input == null || this.output == null) return
const inputs = Array.from(midi.inputs.values()) this.device = new Device(this.input, this.output)
const outputs = Array.from(midi.outputs.values()) toast.info('Device connected')
console.log( } else {
'MIDI inputs:', if (this.input != null && this.output != null) return
Array.from(midi.inputs.values()), this.device = null
'outputs:', toast.info('Device disconnected')
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 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') const DeviceContextToken = Symbol('device')
@@ -66,7 +73,7 @@ export function getDeviceContext(): DeviceContext {
return getContext(DeviceContextToken) return getContext(DeviceContextToken)
} }
export function createDeviceContext(): DeviceContext { export function createDeviceContext(midi: MidiContext): DeviceContext {
let ctx = new DeviceContext() let ctx = new DeviceContext(midi)
return setContext(DeviceContextToken, ctx) return setContext(DeviceContextToken, ctx)
} }
+72
View File
@@ -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())
}
+12 -3
View File
@@ -9,16 +9,21 @@ import { createConversionConfig } from '$lib/contexts/config.svelte'
import { createFilesContext } from '$lib/contexts/files.svelte' import { createFilesContext } from '$lib/contexts/files.svelte'
import { createImageContext } from '$lib/contexts/image.svelte' import { createImageContext } from '$lib/contexts/image.svelte'
import { createRenderedContext } from '$lib/contexts/rendered.svelte' import { createRenderedContext } from '$lib/contexts/rendered.svelte'
import { createMidiContext } from '$lib/contexts/midi.svelte'
import { onMount } from '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 config = createConversionConfig()
const filesCtx = createFilesContext() const filesCtx = createFilesContext()
const imageCtx = createImageContext(filesCtx) const imageCtx = createImageContext(filesCtx)
createRenderedContext(imageCtx, config) createRenderedContext(imageCtx, config)
onMount(() => { onMount(async () => {
deviceCtx.initialize() await midiCtx.initialize()
}) })
</script> </script>
@@ -34,4 +39,8 @@ onMount(() => {
<Separator decorative /> <Separator decorative />
<WriteSection /> <WriteSection />
<PermissionRequestDialog open={midiCtx.perm === 'prompt'} />
<PermissionDeniedDialog open={midiCtx.perm === 'denied'} />
<UnsupportedDialog open={midiCtx.perm === 'unsupported'} />
</main> </main>
@@ -0,0 +1,28 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog'
interface Props {
open: boolean
}
let { open }: Props = $props()
</script>
<AlertDialog.Root {open}>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content escapeKeydownBehavior="ignore">
<AlertDialog.Title>MIDI permission needed</AlertDialog.Title>
<AlertDialog.Description class="stack gap-2">
<p>
We detected that we are denied MIDI access. This means this tool cannot function properly.
</p>
<p>
To use this tool, you will need to grant us MIDI permission manually in your browser
settings, and then reload this page.
</p>
</AlertDialog.Description>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
@@ -0,0 +1,32 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog'
import { getMidiContext } from '$lib/contexts/midi.svelte'
interface Props {
open: boolean
}
let { open }: Props = $props()
const midiCtx = getMidiContext()
</script>
<AlertDialog.Root {open}>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content escapeKeydownBehavior="ignore">
<AlertDialog.Title>MIDI permission needed</AlertDialog.Title>
<AlertDialog.Description class="stack gap-2">
<p>Write to Inkclip uses the Web MIDI API to talk to your devices.</p>
<p>
Click on "I understand", and then follow your browser's prompt to grant us permission.
</p>
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Action onclick={() => midiCtx.acquire()}>I understand</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
@@ -1,18 +1,17 @@
<script lang="ts"> <script lang="ts">
import IconDisabledByDefault from '~icons/material-symbols/disabled-by-default' import * as AlertDialog from '$lib/components/ui/alert-dialog'
import * as AlertDialog from '$lib/components/ui/alert-dialog' interface Props {
open: boolean
}
const { open }: Props = $props()
</script> </script>
<main class="grow row justify-center gap-2 font-medium text-xl text-muted-foreground"> <AlertDialog.Root {open}>
<IconDisabledByDefault />
<div>Not Supported</div>
</main>
<AlertDialog.Root open>
<AlertDialog.Portal> <AlertDialog.Portal>
<AlertDialog.Overlay /> <AlertDialog.Overlay />
<AlertDialog.Content> <AlertDialog.Content escapeKeydownBehavior="ignore">
<AlertDialog.Title>Browser not supported</AlertDialog.Title> <AlertDialog.Title>Browser not supported</AlertDialog.Title>
<AlertDialog.Description class="stack gap-2"> <AlertDialog.Description class="stack gap-2">
@@ -21,7 +20,8 @@
We recommend using a Chromium-based browser, such as a recent version of We recommend using a Chromium-based browser, such as a recent version of
<a href="https://www.google.com/chrome/">Google Chrome</a>, <a href="https://www.google.com/chrome/">Google Chrome</a>,
<a href="https://www.microsoft.com/edge">Microsoft Edge</a>, or <a href="https://www.microsoft.com/edge">Microsoft Edge</a>, or
<a href="https://www.opera.com/">Opera</a>. Firefox may also work, although we are aware of more issues there. <a href="https://www.opera.com/">Opera</a>. Firefox may also work, although we are aware
of more issues there.
</p> </p>
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Content> </AlertDialog.Content>
+11
View File
@@ -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)
})
}