-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
useSyncExteranlStore
を使うように変更
#66
base: feat/v0.6.0
Are you sure you want to change the base?
Changes from 8 commits
7cd5c16
ec251bc
999856a
8a77a8f
67ca909
51a6bee
e40af32
cf6eb62
939d360
045f531
fc8915f
869c9cf
bd552c7
93838aa
f79b7ff
20a3261
041a415
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,86 +1,71 @@ | ||||||||||||||||||||||||||||||||||||||||
import { useState } from 'react' | ||||||||||||||||||||||||||||||||||||||||
import { SD } from './sd' | ||||||||||||||||||||||||||||||||||||||||
import { useCallback, useSyncExternalStore } from 'react' | ||||||||||||||||||||||||||||||||||||||||
import { Preview, type PreviewSettings } from './components/preview' | ||||||||||||||||||||||||||||||||||||||||
import { Program, type ProgramSettings } from './components/program' | ||||||||||||||||||||||||||||||||||||||||
import type { SendToPropertyInspector, SendInputs, DestinationToInputs } from './types/streamdeck' | ||||||||||||||||||||||||||||||||||||||||
import { Activator, type ActivatorSettings } from './components/activator' | ||||||||||||||||||||||||||||||||||||||||
import { headlessStreamDeck } from './adapters/stream-deck' | ||||||||||||||||||||||||||||||||||||||||
import { settingsStore, inputsStore, actionInfoStore } from './stores' | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
declare global { | ||||||||||||||||||||||||||||||||||||||||
interface Window { | ||||||||||||||||||||||||||||||||||||||||
connectElgatoStreamDeckSocket: ( | ||||||||||||||||||||||||||||||||||||||||
inPort: number, | ||||||||||||||||||||||||||||||||||||||||
inUUID: string, | ||||||||||||||||||||||||||||||||||||||||
inRegisterEvent: string, | ||||||||||||||||||||||||||||||||||||||||
inInfo: string, | ||||||||||||||||||||||||||||||||||||||||
inActionInfo: string | ||||||||||||||||||||||||||||||||||||||||
) => void | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
type T = PreviewSettings | ProgramSettings | ActivatorSettings | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
function App() { | ||||||||||||||||||||||||||||||||||||||||
type T = PreviewSettings | ProgramSettings | ActivatorSettings | ||||||||||||||||||||||||||||||||||||||||
// States | ||||||||||||||||||||||||||||||||||||||||
const [sd, setSD] = useState<SD<unknown> | null>(null) | ||||||||||||||||||||||||||||||||||||||||
const [settings, setSettings] = useState<T>({} as T) | ||||||||||||||||||||||||||||||||||||||||
const [inputs, setInputs] = useState<DestinationToInputs>({}) | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
// connectElgatoStreamDeckSocket is a function that is called by the Stream Deck software when the Property Inspector is opened. | ||||||||||||||||||||||||||||||||||||||||
// グローバル変数である必要がある | ||||||||||||||||||||||||||||||||||||||||
window.connectElgatoStreamDeckSocket = ( | ||||||||||||||||||||||||||||||||||||||||
inPort: number, | ||||||||||||||||||||||||||||||||||||||||
inUUID: string, | ||||||||||||||||||||||||||||||||||||||||
inRegisterEvent: string, | ||||||||||||||||||||||||||||||||||||||||
inInfo: string, | ||||||||||||||||||||||||||||||||||||||||
inActionInfo: string, | ||||||||||||||||||||||||||||||||||||||||
) => { | ||||||||||||||||||||||||||||||||||||||||
setSD(new SD(inPort, inUUID, inRegisterEvent, inInfo, inActionInfo, | ||||||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||||||
onOpen: () => { | ||||||||||||||||||||||||||||||||||||||||
console.log('Opened') | ||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||
OnDidReceiveSettings: (s) => { | ||||||||||||||||||||||||||||||||||||||||
console.log('Settings received', s) | ||||||||||||||||||||||||||||||||||||||||
setSettings(s as T) | ||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||
OnDidReceiveGlobalSettings: (s) => { | ||||||||||||||||||||||||||||||||||||||||
console.log(s) | ||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||
OnSendToPropertyInspector: (payload: unknown) => { | ||||||||||||||||||||||||||||||||||||||||
// カスみてえな型チェック | ||||||||||||||||||||||||||||||||||||||||
if (!payload) return | ||||||||||||||||||||||||||||||||||||||||
if (typeof payload !== 'object') return | ||||||||||||||||||||||||||||||||||||||||
if (!('event' in payload)) return | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
if (payload?.event === 'inputs') { | ||||||||||||||||||||||||||||||||||||||||
const p: SendToPropertyInspector<SendInputs> = payload as SendToPropertyInspector<SendInputs> | ||||||||||||||||||||||||||||||||||||||||
console.log('inputs', p.payload.inputs) | ||||||||||||||||||||||||||||||||||||||||
setInputs(p.payload.inputs) | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
// TODO: 型をもっと扱いやすく厳密にする | ||||||||||||||||||||||||||||||||||||||||
// Actionごとにカスタムしたくなると思うので、もっと冗長性を持たせる | ||||||||||||||||||||||||||||||||||||||||
// 例えばSettings, コールバック関数を外部から設定できるようにして、StreamDeckとの接続のみを担うコンポーネントを切り出す | ||||||||||||||||||||||||||||||||||||||||
// actionInfo.action で描画先を変更するのではなく、もっと細かく分ける | ||||||||||||||||||||||||||||||||||||||||
)) | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
// TODO: Apply colours | ||||||||||||||||||||||||||||||||||||||||
// addDynamicStyles(inInfo.colors); | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||||||||||||||
* useSyncExternalStore は getValue で受け入れた値が not shallow equal だと | ||||||||||||||||||||||||||||||||||||||||
* 再レンダリングトリガーされるため getValue は cached な値である必要がある | ||||||||||||||||||||||||||||||||||||||||
* headlessStreamDeck から返却される getActionInfo, getActionInfos は計算して返却されるため、object が毎回異なる | ||||||||||||||||||||||||||||||||||||||||
* xxxStore は event があるたびに値を更新し、それを保持するため getValue は cached な値になるため Infinite Loop を回避する | ||||||||||||||||||||||||||||||||||||||||
* ちなみにこの辺は rxjs と jotai を使うと簡単に回避できるのだが、jotai は内部的に useSyncExternalStore を使っていないため本質的には別物になる | ||||||||||||||||||||||||||||||||||||||||
**/ | ||||||||||||||||||||||||||||||||||||||||
const inputs = useSyncExternalStore( | ||||||||||||||||||||||||||||||||||||||||
inputsStore.subscribe, | ||||||||||||||||||||||||||||||||||||||||
inputsStore.getValue, | ||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||
const settings = useSyncExternalStore( | ||||||||||||||||||||||||||||||||||||||||
settingsStore.subscribe, | ||||||||||||||||||||||||||||||||||||||||
settingsStore.getValue, | ||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||
const actionInfos = useSyncExternalStore( | ||||||||||||||||||||||||||||||||||||||||
actionInfoStore.subscribe, | ||||||||||||||||||||||||||||||||||||||||
actionInfoStore.getValue, | ||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const onSettingsUpdate = (s: T) => { | ||||||||||||||||||||||||||||||||||||||||
const onSettingsUpdate = useCallback((s: T) => { | ||||||||||||||||||||||||||||||||||||||||
console.log('Updated. sending payload...', s) | ||||||||||||||||||||||||||||||||||||||||
setSettings(s) | ||||||||||||||||||||||||||||||||||||||||
sd?.setSettings(s) | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
headlessStreamDeck.setSettings(s) | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. headlessStreamDeck.setSettings 内部では didReceiveSettings が内部向けに先んじて発火するので settingsStore の subscribe が発火し、settings が更新される(
後からサーバーから websocket の message を受信して実際の設定が更新される
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 更新するときに発火し streamdeck-vmix-plugin/Source/pi/src/stores/settings.ts Lines 18 to 34 in 041a415
|
||||||||||||||||||||||||||||||||||||||||
}, []) | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||||||||||||||||
<> | ||||||||||||||||||||||||||||||||||||||||
{ sd?.actionInfo.action === 'dev.flowingspdg.vmix.preview' && <Preview inputs={inputs} settings={settings as PreviewSettings} onUpdate={onSettingsUpdate} /> } | ||||||||||||||||||||||||||||||||||||||||
{ sd?.actionInfo.action === 'dev.flowingspdg.vmix.program' && <Program inputs={inputs} settings={settings as ProgramSettings} onUpdate={onSettingsUpdate} /> } | ||||||||||||||||||||||||||||||||||||||||
{ sd?.actionInfo.action === 'dev.flowingspdg.vmix.activator' && <Activator inputs={inputs} settings={settings as ActivatorSettings} onUpdate={onSettingsUpdate} /> } | ||||||||||||||||||||||||||||||||||||||||
{ sd?.actionInfo.action === 'dev.flowingspdg.vmix.function' && 'NOT YET!' } | ||||||||||||||||||||||||||||||||||||||||
{actionInfos | ||||||||||||||||||||||||||||||||||||||||
.map(info => info.action) | ||||||||||||||||||||||||||||||||||||||||
.includes('dev.flowingspdg.vmix.preview') && ( | ||||||||||||||||||||||||||||||||||||||||
<Preview | ||||||||||||||||||||||||||||||||||||||||
inputs={inputs} | ||||||||||||||||||||||||||||||||||||||||
settings={settings as PreviewSettings} | ||||||||||||||||||||||||||||||||||||||||
onUpdate={onSettingsUpdate} | ||||||||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
{actionInfos | ||||||||||||||||||||||||||||||||||||||||
.map(info => info.action) | ||||||||||||||||||||||||||||||||||||||||
.includes('dev.flowingspdg.vmix.program') && ( | ||||||||||||||||||||||||||||||||||||||||
<Program | ||||||||||||||||||||||||||||||||||||||||
inputs={inputs} | ||||||||||||||||||||||||||||||||||||||||
settings={settings as ProgramSettings} | ||||||||||||||||||||||||||||||||||||||||
onUpdate={onSettingsUpdate} | ||||||||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||||||||
{actionInfos | ||||||||||||||||||||||||||||||||||||||||
.map(info => info.action) | ||||||||||||||||||||||||||||||||||||||||
.includes('dev.flowingspdg.vmix.activator') && ( | ||||||||||||||||||||||||||||||||||||||||
<Activator | ||||||||||||||||||||||||||||||||||||||||
inputs={inputs} | ||||||||||||||||||||||||||||||||||||||||
settings={settings as ActivatorSettings} | ||||||||||||||||||||||||||||||||||||||||
onUpdate={onSettingsUpdate} | ||||||||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||||||||
{actionInfos | ||||||||||||||||||||||||||||||||||||||||
.map(info => info.action) | ||||||||||||||||||||||||||||||||||||||||
.includes('dev.flowingspdg.vmix') && 'NOT YET!'} | ||||||||||||||||||||||||||||||||||||||||
</> | ||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import { HeadlessStreamDeckImpl } from '../sd/headless' | ||
|
||
export const headlessStreamDeck = new HeadlessStreamDeckImpl<unknown>() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { EventListener } from '../../event-listener' | ||
|
||
type EventMap = { | ||
open: (event: { target: MockWebSocket }) => void | ||
message: (event: { data: string }) => void | ||
close: (event: { target: MockWebSocket }) => void | ||
} | ||
|
||
export class MockWebSocket { | ||
readyState: number = 0 // CONNECTING | ||
sentMessages: string[] = [] | ||
// EventListener を使用 | ||
private listeners = new EventListener<EventMap>() | ||
|
||
static CONNECTING = 0 as const | ||
static OPEN = 1 as const | ||
static CLOSING = 2 as const | ||
static CLOSED = 3 as const | ||
|
||
constructor(readonly url: string) { | ||
// 接続をシミュレート | ||
setTimeout(() => { | ||
this.readyState = MockWebSocket.OPEN | ||
this.dispatchEvent('open', { target: this }) | ||
}, 0) | ||
} | ||
|
||
addEventListener<K extends keyof EventMap>(event: K, callback: EventMap[K]) { | ||
this.listeners.add(event, callback) | ||
} | ||
|
||
removeEventListener<K extends keyof EventMap>(event: K, callback: EventMap[K]) { | ||
this.listeners.remove(event, callback) | ||
} | ||
|
||
dispatchEvent<K extends keyof EventMap>(event: K, ...data: Parameters<EventMap[K]>) { | ||
this.listeners.dispatch(event, ...data) | ||
} | ||
|
||
send(data: string) { | ||
this.sentMessages.push(data) | ||
} | ||
|
||
close() { | ||
this.readyState = MockWebSocket.CLOSED | ||
this.dispatchEvent('close', { target: this }) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import { EventListener } from '../event-listener' | ||
|
||
type Events = { | ||
testEvent: (message: string) => void | ||
anotherEvent: (value: number) => void | ||
} | ||
|
||
describe('EventListener', () => { | ||
it('should add and dispatch event listeners correctly', () => { | ||
const listener = new EventListener<Events>() | ||
const callback = vi.fn() | ||
|
||
listener.add('testEvent', callback) | ||
listener.dispatch('testEvent', 'Hello') | ||
|
||
expect(callback).toHaveBeenCalledWith('Hello') | ||
}) | ||
|
||
it('should remove event listeners correctly', () => { | ||
const listener = new EventListener<Events>() | ||
const callback = vi.fn() | ||
|
||
listener.add('testEvent', callback) | ||
listener.remove('testEvent', callback) | ||
listener.dispatch('testEvent', 'Hello') | ||
|
||
expect(callback).not.toHaveBeenCalled() | ||
}) | ||
|
||
it('should not throw when dispatching non-existent event', () => { | ||
const listener = new EventListener<Events>() | ||
|
||
expect(() => { | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-expect-error | ||
listener.dispatch('nonExistentEvent', 'Hello') | ||
}).not.toThrow() | ||
}) | ||
|
||
it('should not throw when removing non-existent callback', () => { | ||
const listener = new EventListener<Events>() | ||
const callback = vi.fn() | ||
|
||
expect(() => { | ||
listener.remove('testEvent', callback) | ||
}).not.toThrow() | ||
}) | ||
|
||
it('should call each callback once even if added multiple times', () => { | ||
const listener = new EventListener<Events>() | ||
const callback = vi.fn() | ||
|
||
listener.add('testEvent', callback) | ||
listener.add('testEvent', callback) | ||
listener.dispatch('testEvent', 'Hello') | ||
|
||
expect(callback).toHaveBeenCalledTimes(1) | ||
}) | ||
|
||
it('should call all callbacks for an event', () => { | ||
const listener = new EventListener<Events>() | ||
const callback1 = vi.fn() | ||
const callback2 = vi.fn() | ||
|
||
listener.add('testEvent', callback1) | ||
listener.add('testEvent', callback2) | ||
listener.dispatch('testEvent', 'Hello') | ||
|
||
expect(callback1).toHaveBeenCalledWith('Hello') | ||
expect(callback2).toHaveBeenCalledWith('Hello') | ||
}) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
なぜ mini-store を毎回作っているのか?
useSyncExternalStore は getValue で受け入れた値が not shallow equal だと再レンダリングトリガーされるため getValue は cached な値である必要がある。
headlessStreamDeck から返却される getActionInfo, getActionInfos は計算して返却されるため、object が毎回異なる
xxxStore は event があるたびに値を更新し、それを保持するため getValue は cached な値になるため Infinite Loop を回避する
ちなみにこの辺は rxjs と jotai を使うと簡単に回避できるのだが、jotai は内部的に useSyncExternalStore を使っていないため本質的には別物になる