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 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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
+10
-10
@@ -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>
|
||||||
@@ -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