Skip to content
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

Open
wants to merge 17 commits into
base: feat/v0.6.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
792 changes: 781 additions & 11 deletions Source/pi/package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions Source/pi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "eslint --fix . --ext ts,tsx",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
Expand All @@ -21,6 +22,7 @@
"lucide-react": "^0.378.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"reconnecting-websocket": "^4.4.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7"
},
Expand All @@ -35,9 +37,11 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"jsdom": "^25.0.1",
"tailwindcss": "^3.4.3",
"typescript": "^5.5.2",
"vite": "^5.2.0",
"vite-plugin-singlefile": "^2.0.1"
"vite-plugin-singlefile": "^2.0.1",
"vitest": "^2.1.1"
}
}
129 changes: 57 additions & 72 deletions Source/pi/src/App.tsx
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,
)
Comment on lines +18 to +29
Copy link
Author

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 を使っていないため本質的には別物になる


const onSettingsUpdate = (s: T) => {
const onSettingsUpdate = useCallback((s: T) => {
console.log('Updated. sending payload...', s)
setSettings(s)
sd?.setSettings(s)
}
headlessStreamDeck.setSettings(s)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headlessStreamDeck.setSettings 内部では didReceiveSettings が内部向けに先んじて発火するので settingsStore の subscribe が発火し、settings が更新される(

this.listeners.dispatch('didReceiveSettings', payload)
)

後からサーバーから websocket の message を受信して実際の設定が更新される

this.listeners.dispatch('didReceiveSettings', parsed.payload)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

更新するときに発火し useSyncExternalStore に通知する部分

subscribe(callback: Subscriber<unknown>) {
listeners.add(callback)
if (listeners.size === 1) {
headlessStreamDeck.addEventListener('didReceiveSettings', handler)
}
return () => {
listeners.delete(callback)
if (listeners.size === 0) {
headlessStreamDeck.removeEventListener(
'didReceiveSettings',
handler,
)
}
}
},

}, [])

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!'}
</>
)
}
Expand Down
3 changes: 3 additions & 0 deletions Source/pi/src/adapters/stream-deck.ts
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>()
16 changes: 16 additions & 0 deletions Source/pi/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { headlessStreamDeck } from './adapters/stream-deck.ts'

window.connectElgatoStreamDeckSocket = (
inPort,
inUUID,
inRegisterEvent,
inInfo,
inActionInfo,
) => {
headlessStreamDeck.add(inPort, {
inPropertyInspectorUUID: inUUID,
inRegisterEvent,
inInfo,
inActionInfo,
})
}

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
Expand Down
48 changes: 48 additions & 0 deletions Source/pi/src/sd/__tests__/__mock__/websocket.ts
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 })
}
}
72 changes: 72 additions & 0 deletions Source/pi/src/sd/__tests__/event-listener.test.ts
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')
})
})
Loading