Implement MIDI permission detection and user prompting
This commit is contained in:
@@ -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
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
<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">
|
||||
{#if unsupported}
|
||||
<Unsupported />
|
||||
{:else}
|
||||
<Main />
|
||||
{/if}
|
||||
<Main />
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -34,4 +39,8 @@ onMount(() => {
|
||||
<Separator decorative />
|
||||
|
||||
<WriteSection />
|
||||
|
||||
<PermissionRequestDialog open={midiCtx.perm === 'prompt'} />
|
||||
<PermissionDeniedDialog open={midiCtx.perm === 'denied'} />
|
||||
<UnsupportedDialog open={midiCtx.perm === 'unsupported'} />
|
||||
</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>
|
||||
+10
-10
@@ -1,18 +1,17 @@
|
||||
<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>
|
||||
|
||||
<main class="grow row justify-center gap-2 font-medium text-xl text-muted-foreground">
|
||||
<IconDisabledByDefault />
|
||||
<div>Not Supported</div>
|
||||
</main>
|
||||
|
||||
<AlertDialog.Root open>
|
||||
<AlertDialog.Root {open}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay />
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Content escapeKeydownBehavior="ignore">
|
||||
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description class="stack gap-2">
|
||||
@@ -21,7 +20,8 @@
|
||||
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.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>
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Content>
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user