diff --git a/.babelrc b/.babelrc old mode 100644 new mode 100755 diff --git a/.github/workflows/check-license.yml b/.github/workflows/check-license.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy-renderer.yml b/.github/workflows/deploy-renderer.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore index 9278f650..bc0a26be 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ types dist-ssr db log.txt +*.TODO *.local docs/ .npmrc diff --git a/.npmignore b/.npmignore old mode 100644 new mode 100755 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..49329247 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +tabWidth: 2 +semi: false +singleQuote: true +trailingComma: all +jsxSelfClosing: false diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index a4bd2e3d..00000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tabWidth": 2, - "semi": false, - "singleQuote": true, - "trailingComma": "all", - "jsxSelfClosing": false -} diff --git a/LICENSE.txt b/LICENSE.txt old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/build/LICENSE.header-formatted.txt b/build/LICENSE.header-formatted.txt old mode 100644 new mode 100755 diff --git a/build/LICENSE.header.txt b/build/LICENSE.header.txt old mode 100644 new mode 100755 diff --git a/build/hosted-renderer/README.md b/build/hosted-renderer/README.md old mode 100644 new mode 100755 diff --git a/build/hosted-renderer/directory-to-kv.js b/build/hosted-renderer/directory-to-kv.js old mode 100644 new mode 100755 diff --git a/build/hosted-renderer/package-lock.json b/build/hosted-renderer/package-lock.json old mode 100644 new mode 100755 diff --git a/build/hosted-renderer/package.json b/build/hosted-renderer/package.json old mode 100644 new mode 100755 diff --git a/build/hosted-renderer/src/index.ts b/build/hosted-renderer/src/index.ts old mode 100644 new mode 100755 diff --git a/build/hosted-renderer/tsconfig.json b/build/hosted-renderer/tsconfig.json old mode 100644 new mode 100755 diff --git a/build/hosted-renderer/wrangler.toml b/build/hosted-renderer/wrangler.toml old mode 100644 new mode 100755 diff --git a/build/license-header.json b/build/license-header.json old mode 100644 new mode 100755 diff --git a/config.ts b/config.ts old mode 100644 new mode 100755 diff --git a/docs.css b/docs.css old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/.babelrc b/examples/studio-kit-demo/.babelrc old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/.gitignore b/examples/studio-kit-demo/.gitignore old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/.prettierrc.json b/examples/studio-kit-demo/.prettierrc.json old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/Font.ttf b/examples/studio-kit-demo/Font.ttf old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/README.md b/examples/studio-kit-demo/README.md old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/compositor/index.html b/examples/studio-kit-demo/compositor/index.html new file mode 100644 index 00000000..e60655b7 --- /dev/null +++ b/examples/studio-kit-demo/compositor/index.html @@ -0,0 +1,19 @@ + + + + + + + + Studio Kit Demo - Compositor + + +
+ + + diff --git a/examples/studio-kit-demo/config.ts b/examples/studio-kit-demo/config.ts old mode 100644 new mode 100755 index 7bf245b5..c200fd15 --- a/examples/studio-kit-demo/config.ts +++ b/examples/studio-kit-demo/config.ts @@ -6,6 +6,7 @@ type Config = { env: 'stage' | 'prod' logLevel: 'Debug' | 'Info' | 'Warn' | 'Error' recaptchaKey: string + [prop: string]: any } const LOCAL_ENV: Config['env'] = 'stage' @@ -14,4 +15,6 @@ export default { env: location.hostname === 'live.api.stream' ? 'prod' : LOCAL_ENV, logLevel: 'Debug', recaptchaKey: '6Lc0HIUfAAAAAIdsyq7vB_3c3skiVvltzdUTCUSx', + liveblocksKey: + 'pk_dev_yf1qXf_yTDHhEcKr1sdtPnOHYGJeEhgL4x8LwaC1bPQeMC2cazP5RYFUJq7sRcTe', } as Config diff --git a/examples/studio-kit-demo/favicon.ico b/examples/studio-kit-demo/favicon.ico old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/guest/index.html b/examples/studio-kit-demo/guest/index.html old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/index.html b/examples/studio-kit-demo/index.html old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/logo.png b/examples/studio-kit-demo/logo.png old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/package.json b/examples/studio-kit-demo/package.json old mode 100644 new mode 100755 index 192a4184..10d0c60e --- a/examples/studio-kit-demo/package.json +++ b/examples/studio-kit-demo/package.json @@ -9,11 +9,18 @@ }, "dependencies": { "@api.stream/studio-kit": "file:../../", + "@liveblocks/client": "^0.19.1", + "@liveblocks/react": "^0.19.0", "axios": "^0.26.1", + "install": "^0.13.0", + "jsoneditor": "^9.9.2", + "jsoneditor-react": "^3.1.2", "nanoid": "^3.3.3", + "npm": "^9.1.2", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-google-recaptcha": "^2.1.0" + "react-google-recaptcha": "^2.1.0", + "react-json-editor-ajrm": "^2.5.13" }, "devDependencies": { "@rollup/plugin-babel": "^5.3.0", @@ -21,6 +28,7 @@ "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/react-google-recaptcha": "^2.1.5", + "@types/react-json-editor-ajrm": "^2.5.3", "@vitejs/plugin-react": "1.0.8", "babel-plugin-wildcard": "^7.0.0", "csstype": "^3.0.8", diff --git a/examples/studio-kit-demo/public/bg.png b/examples/studio-kit-demo/public/bg.png old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/src/components/MultiScene.tsx b/examples/studio-kit-demo/src/components/MultiScene.tsx new file mode 100644 index 00000000..be433ae1 --- /dev/null +++ b/examples/studio-kit-demo/src/components/MultiScene.tsx @@ -0,0 +1,67 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import React, { useEffect, useRef, useState } from 'react' +import { Components, Sources, SDK, Compositor } from '@api.stream/studio-kit' +import { Column, Flex, Row } from '../ui/Box' +import { Component } from '.' +import { ScenelessProps } from './Sceneless' +import { SourceList } from '../shared/sources' + +export type MultiSceneInterface = Components.MultiScene.Interface + +export const SceneList = ({ + component, +}: { + component: MultiSceneInterface +}) => { + const activeScene = component.getChild(component.props.activeSceneId) + + return ( + + + {component.children.map((x) => ( + { + component.execute.setActiveScene(x.id) + }} + /> + ))} + + + + {activeScene && ( + +
Scene ID: {activeScene.id}
+ +
+ )} +
+
+ ) +} + +export const MultiSceneComponent = ({ + component, +}: { + component: MultiSceneInterface +}) => { + return ( + + + + ) +} diff --git a/examples/studio-kit-demo/src/components/Sceneless.tsx b/examples/studio-kit-demo/src/components/Sceneless.tsx new file mode 100644 index 00000000..764491d0 --- /dev/null +++ b/examples/studio-kit-demo/src/components/Sceneless.tsx @@ -0,0 +1,58 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import React, { useEffect, useRef, useState } from 'react' +import { Components, Sources } from '@api.stream/studio-kit' +import { Column, Flex } from '../ui/Box' +import { BackgroundSelect, BannerSelect, SourceList } from '../shared/sources' +import { Layout } from '../shared/props' + +export type ScenelessInterface = Components.Sceneless.Interface +export type ScenelessProps = Components.Sceneless.Props + +const overlays = [ + { + id: '123', + url: 'https://www.pngmart.com/files/12/Twitch-Stream-Overlay-PNG-Transparent-Picture.png', + }, + { + id: '124', + url: 'https://www.pngmart.com/files/12/Stream-Overlay-Transparent-PNG.png', + }, +] + +const logos = [ + { + id: '128', + url: 'https://www.pngmart.com/files/12/Twitch-Stream-Overlay-PNG-Transparent-Picture.png', + }, + { + id: '129', + url: 'https://www.pngmart.com/files/12/Stream-Overlay-Transparent-PNG.png', + }, +] + +export const ScenelessComponent = ({ + component, +}: { + component: ScenelessInterface +}) => { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/examples/studio-kit-demo/src/components/index.tsx b/examples/studio-kit-demo/src/components/index.tsx new file mode 100644 index 00000000..25b9b93d --- /dev/null +++ b/examples/studio-kit-demo/src/components/index.tsx @@ -0,0 +1,42 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import { Compositor, SDK } from '@api.stream/studio-kit' +import { useEffect, useState } from 'react' +import { useRenderRef, useUpdate } from '../shared/hooks' +import { MultiSceneComponent } from './MultiScene' +import { ScenelessComponent } from './Sceneless' + +const components = { + Sceneless: ScenelessComponent, + MultiScene: MultiSceneComponent, +} + +export const Component = ({ + component, +}: { + component: Compositor.Component.NodeInterface +}) => { + const [updatedComponent, setComponent] = + useState(component) + + useEffect(() => { + if (!component?.id) return + return component.project.useComponent(component.id, setComponent) + }, [component?.id]) + + if (!component) return null + + // @ts-ignore + const Component = components[component.type] + if (!Component) return null + + return +} + +export const Renderer = ({ scene }: { scene: Compositor.Project }) => { + const renderContainer = useRenderRef(scene) + + return
+} diff --git a/examples/studio-kit-demo/src/compositor/Compositor.tsx b/examples/studio-kit-demo/src/compositor/Compositor.tsx new file mode 100644 index 00000000..cb56c816 --- /dev/null +++ b/examples/studio-kit-demo/src/compositor/Compositor.tsx @@ -0,0 +1,218 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import React, { useCallback, useContext, useEffect, useState } from 'react' +import ReactDOM from 'react-dom' +import { + Compositor, + Transforms, + Layouts, + Sources, + Components, +} from '@api.stream/studio-kit' +import logoUrl from '../../logo.png' +import '../index.css' +import '../../Font.ttf' +import { Column, Flex, Row } from '../ui/Box' +import { asArray } from '../shared/logic' +import { dbAdapter } from '../liveblocks-adapter' +import { ProjectProvider, ProjectView } from './CompositorProject' + +const sources = { + Image: [ + { + id: '1', + props: { + src: 'http://localhost:3006/bg.png', + }, + }, + { + id: '2', + props: { + src: 'https://images.unsplash.com/photo-1529778873920-4da4926a72c2?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8Y3V0ZSUyMGNhdHxlbnwwfHwwfHw%3D&w=1000&q=80', + }, + }, + ], + Video: [ + { + id: 'g73rre9mbns00', + props: { + src: 'https://assets.mixkit.co/videos/preview/mixkit-stars-in-space-1610-large.mp4', + }, + }, + { + id: '24c4ou2urlr40', + props: { + src: 'https://assets.mixkit.co/videos/preview/mixkit-curvy-road-on-a-tree-covered-hill-41537-large.mp4', + }, + }, + ], + RoomParticipant: [] as any[], + Banner: [ + { + id: '2tqdp07koke00', + props: { + headerText: 'Header', + bodyText: 'Some banner text', + meta: { + title: 'Short', + }, + }, + }, + { + id: 'n7xp1clnhrk0', + props: { + bodyText: 'This is a banner without a header. It has more text.', + meta: { + title: 'Long', + }, + }, + }, + ], +} + +const Content = () => { + return ( +
+ + +
+ ) +} + +const ProjectsView = () => { + const { compositor } = useApp() + const [projects, setProjects] = useState([]) + const [projectId, setProjectId] = useState() + + const createProject = useCallback(async () => { + const project = await compositor.createProject({ canEdit: true }) + + // TODO: Clean this up / rethink + const projectComponent = compositor.components.createTempComponent( + 'Project', + {}, + sources, + ) + await project.insertRoot({ props: projectComponent.props }) + setProjects(Object.values(compositor.projects)) + }, [compositor]) + + const loadProject = useCallback( + async (projectId) => { + await compositor.loadProject(projectId) + setProjects(Object.values(compositor.projects)) + }, + [compositor], + ) + + useEffect(() => { + // Load initial project from URL + const pageURL = new URL(document.location.toString()) + const projectId = pageURL.searchParams.get('id') + if (projectId) { + loadProject(projectId) + } + }, []) + + useEffect(() => { + if (!projects[0]) return + // Update the URL to reference the project + const url = new URL(window.location.toString()) + url.searchParams.set('id', projects[0].id) + window.history.pushState({ path: url.toString() }, '', url) + }, [projects[0]]) + + return ( + + + setProjectId(e.target.value)} + /> + + + + + + {projects.map((x) => ( + + + + + + ))} + + ) +} + +type AppContext = { + compositor: Compositor.CompositorInstance +} + +const AppContext = React.createContext({} as AppContext) + +const AppProvider = ({ children }: { children: React.ReactChild }) => { + const [compositor] = useState(() => + Compositor.start({ + dbAdapter, + }), + ) + + useEffect(() => { + if (!compositor) return + const getDeclarations = (modules: object) => + Object.values(modules).flatMap((x) => asArray(x.Declaration)) as any[] + + // Register modules (TODO: Rethink) + compositor.sources.registerSource(getDeclarations(Sources)) + compositor.transforms.registerTransform(getDeclarations(Transforms)) + compositor.layouts.registerLayout(getDeclarations(Layouts)) + compositor.components.registerComponent(getDeclarations(Components)) + }, [compositor]) + + // @ts-ignore Debug + window.compositor = compositor + + return ( + + {children} + + ) +} + +export const useApp = () => useContext(AppContext) + +ReactDOM.render( + + + , + document.getElementById('root'), +) diff --git a/examples/studio-kit-demo/src/compositor/CompositorProject.tsx b/examples/studio-kit-demo/src/compositor/CompositorProject.tsx new file mode 100644 index 00000000..03d8c2a5 --- /dev/null +++ b/examples/studio-kit-demo/src/compositor/CompositorProject.tsx @@ -0,0 +1,190 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { Compositor } from '@api.stream/studio-kit' +import { Column, Flex, Row } from '../ui/Box' +import { Renderer } from '../components' +import { NodeEditor, Tree } from './NodeTree' + +export const ProjectView = () => { + const { project, tree, selectedNodes } = useProject() + + const components = project.compositor.components.components + const componentNames = Object.keys(components) + const [selectedComponent, setSelectedComponent] = useState( + componentNames[0], + ) + + const elements = project.compositor.transforms.transforms + const elementNames = Object.keys(elements) + const [selectedElement, setSelectedElement] = useState( + elementNames[0], + ) + + const selectedNode = project.get(Array.from(selectedNodes)[0]) + + // @ts-ignore Debug + window.node = selectedNode + + // @ts-ignore Debug + window.project = project + + if (!project) { + return Loading... + } + + return ( + + + Project ID: + {project.id} + + + + + + + + + + + + + + {tree && } + + + + + + + {Array.from(selectedNodes).map((x) => ( + + ))} + + + ) +} + +type ProjectContext = { + tree: Compositor.SceneNode + project: Compositor.Project + selectedNodes: Set + selectNode: (id: string) => void + deselectNode: (id: string) => void + toggleNodeSelected: (id: string) => void + resetSelectedNodes: (selectedNodes?: string[]) => void + isNodeSelected: (id: string) => boolean + // collapsedNodes: Set +} + +export const ProjectContext = React.createContext( + {} as ProjectContext, +) + +export const ProjectProvider = ({ + children, + project, +}: { + children: React.ReactChild + project: Compositor.Project +}) => { + const [selectedNodes, setSelectedNodes] = useState(new Set()) + const [tree, setTree] = useState() + + // @ts-ignore Debug + window.project = project + + const selectNode = useCallback( + (id) => setSelectedNodes((prev) => new Set(prev).add(id)), + [selectedNodes], + ) + const deselectNode = useCallback( + (id) => + setSelectedNodes((prev) => { + const set = new Set(prev) + set.delete(id) + return set + }), + [selectedNodes], + ) + + useEffect(() => project.useTree(setTree), []) + + useEffect(() => { + document.addEventListener('copy', (e) => { + const selectedNodeId = + document.activeElement?.getAttribute('data-node-id') + if (!selectedNodeId) return + const node = project.get(selectedNodeId) + if (node) { + e.preventDefault() + e.clipboardData.setData('text/plain', node.toString()) + } + }) + document.addEventListener('paste', (e) => { + const selectedNodeId = + document.activeElement?.getAttribute('data-node-id') + if (!selectedNodeId) return + try { + const tree = JSON.parse(e.clipboardData.getData('text')) + if (tree.id && tree.children && tree.props) { + const node = project.get(selectedNodeId) + node.insertTree(tree) + } + e.preventDefault() + } catch (e) {} + }) + }, []) + + if (!project) return null + + return ( + { + if (selectedNodes.has(id)) { + deselectNode(id) + } else { + selectNode(id) + } + }, + resetSelectedNodes: (ids = []) => setSelectedNodes(new Set(ids)), + isNodeSelected: (id) => selectedNodes.has(id), + }} + > + {children} + + ) +} + +export const useProject = () => useContext(ProjectContext) diff --git a/examples/studio-kit-demo/src/compositor/Icon.tsx b/examples/studio-kit-demo/src/compositor/Icon.tsx new file mode 100644 index 00000000..d2a8fa54 --- /dev/null +++ b/examples/studio-kit-demo/src/compositor/Icon.tsx @@ -0,0 +1,59 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import React, { CSSProperties } from 'react' +import { Flex } from '../ui/Box' +import * as IconMap from './icons' + +export type IconName = keyof typeof IconMap + +const nudge = (props: Partial): CSSProperties => { + if ( + !(props.nudgeUp || props.nudgeDown || props.nudgeRight || props.nudgeLeft) + ) + return + + return { + position: 'relative', + top: props.nudgeDown, + left: props.nudgeRight, + right: props.nudgeLeft, + bottom: props.nudgeUp, + } +} + +type IconProps = { + name: IconName + + width?: number | string + height?: number | string + + nudgeUp?: number + nudgeDown?: number + nudgeLeft?: number + nudgeRight?: number + + color?: string +} + +const Icon = ({ name, width, height, color, ...props }: IconProps) => { + return ( + + {IconMap[name]} + + ) +} + +export default Icon diff --git a/examples/studio-kit-demo/src/compositor/NodeTree.tsx b/examples/studio-kit-demo/src/compositor/NodeTree.tsx new file mode 100644 index 00000000..2694910c --- /dev/null +++ b/examples/studio-kit-demo/src/compositor/NodeTree.tsx @@ -0,0 +1,355 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import { Column, Flex, Row } from '../ui/Box' +import { useApp } from './Compositor' +import { useProject } from './CompositorProject' +import { Compositor } from '@api.stream/studio-kit' +import { useState } from 'react' +import { Layout } from '../shared/props' +import Icon from './Icon' +import JSONInput from 'react-json-editor-ajrm/index' + +// @ts-ignore +import locale from 'react-json-editor-ajrm/locale/en' + +export const NodeView = (props: { node: Compositor.SceneNode }) => { + // const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isEditingName, setIsEditingName] = useState(false) + const { project, isNodeSelected } = useProject() + const isSelected = isNodeSelected(props.node.id) + const node = project.get(props.node.id) + + // const isCollapsed = !editorState.expandedNodes.has(props.node.id) + // const canExpand = Boolean( + // props.node.children.length > 0, + // ) + + return ( +
{ + setIsEditingName(true) + }} + className={[ + 'node-item', + // isCollapsed && 'collapsed', + isSelected && 'active', + ].join(' ')} + // data-collapsed={isCollapsed} + data-active={isSelected} + style={{ + ...(isSelected && { + fontWeight: 'bold', + }), + padding: '3px 6px 3px 3px', + alignItems: 'stretch', + display: 'flex', + flexGrow: 1, + // opacity: props.node.hidden ? 0.3 : 1, + }} + data-node-id={node.id} + tabIndex={1} + > + + + {/* {canExpand && ( + +
+ ) +} + +export const Tree = (props: { node: Compositor.SceneNode }) => { + const { project, isNodeSelected, resetSelectedNodes, toggleNodeSelected } = + useProject() + const isSelected = isNodeSelected(props.node.id) + const node = project.get(props.node.id) + const [isDragging, setIsDragging] = useState(false) + const [isDropping, setIsDropping] = useState(false) + const [isDroppingChildren, setIsDroppingChildren] = useState(false) + // const isHidden = props.node.hidden + // const isCollapsed = !editorState.expandedNodes.has(props.node.id) + + return ( + { + e.stopPropagation() + if (e.ctrlKey) { + toggleNodeSelected(props.node.id) + } else { + resetSelectedNodes([props.node.id]) + } + }} + > +
{ + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + setIsDropping(true) + }} + onDragLeave={(e) => { + e.preventDefault() + e.stopPropagation() + setIsDropping(false) + }} + onDrop={(e) => { + e.preventDefault() + e.stopPropagation() + setIsDropping(false) + const dragNodeId = e.dataTransfer.getData('text/plain') + if (e.ctrlKey) { + project.get(dragNodeId).move(node.id) + } else { + project.get(dragNodeId).swap(node.id) + } + }} + onDragStart={(e) => { + e.dataTransfer.setData('text/plain', node.id) + e.dataTransfer.dropEffect = 'move' + e.dataTransfer.setDragImage(dragImage, 90, 15) + setIsDragging(true) + }} + onDragEnd={(e) => { + setIsDragging(false) + }} + style={{ + height: '100%', + ...(isDropping && { outline: '1px solid white' }), + ...(isDragging && { background: 'rgba(255,255,255,0.4)' }), + opacity: isDragging ? 0.4 : 1, + }} + > + +
+ { + // !isHidden && + // !isCollapsed && + !isDragging && ( +
0 + ? { paddingTop: 2, paddingBottom: 6 } + : {}), + }} + onDragOver={(e) => { + // TODO: Outline when drag over / unoutline when leave + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + setIsDroppingChildren(true) + }} + onDragLeave={(e) => { + e.preventDefault() + e.stopPropagation() + setIsDroppingChildren(false) + }} + onDrop={(e) => { + e.preventDefault() + e.stopPropagation() + setIsDroppingChildren(false) + const dragNodeId = e.dataTransfer.getData('text/plain') + project.get(dragNodeId).move(node.id) + }} + > + {(node.children || []).map((x, i) => ( + + ))} +
+ ) + } +
+ ) +} + +export const NodeEditor = ({ nodeId }: { nodeId: string }) => { + const node = useProject().project.get(nodeId) + + return ( + + + {node.type} - {node.id} + + + + + + + + + node.update(x.jsObject)} + /> + + + + ) +} + +const dragImageSvg = ` + + + ` + +let dragImage: HTMLImageElement +const loadDragImage = () => { + if (dragImage) return dragImage + dragImage = new Image() + dragImage.src = URL.createObjectURL( + new Blob([dragImageSvg], { + type: 'image/svg+xml', + }), + ) + return dragImage +} +loadDragImage() diff --git a/examples/studio-kit-demo/src/compositor/icons.tsx b/examples/studio-kit-demo/src/compositor/icons.tsx new file mode 100644 index 00000000..19ce0c1d --- /dev/null +++ b/examples/studio-kit-demo/src/compositor/icons.tsx @@ -0,0 +1,356 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import React from 'react' + +export const CirclePlus = ( + + + +) + +export const ArrowCircleLeft = ( + + + +) + +export const ArrowLeft = ( + + + +) + +export const ArrowRight = ( + + + +) + +export const ArrowRightLong = ( + + + +) + +export const CaretDown = ( + + + +) + +export const CaretRight = ( + + + +) + +export const Check = ( + + + +) + +export const CheckCircle = ( + + + +) + +export const ExclamationCircle = ( + + + +) + +export const ExclamationTriangle = ( + + + +) + +export const InfoCircle = ( + + + +) + +export const Circle = ( + + + +) + +export const Star = ( + + + +) + +export const X = ( + + + +) + +export const Lock = ( + + + +) + +export const Trash = ( + + + +) + +export const Laptop = ( + + + +) + +export const Xbox = ( + + + +) + +export const Playstation = ( + + + +) + +export const Mixer = ( + + + +) + +export const MixerFull = ( + + + + + + + + + +) + +export const Facebook = ( + + + +) + +export const FacebookFull = ( + + + + + + + +) + +export const YouTube = ( + + + +) + +export const YouTubeFull = ( + + + + + + + + + + + + +) + +export const Twitch = ( + + + + + + + + + +) + +export const TwitchFull = ( + + + + + + + +) + +export const RTMP = ( + + + +) + +export const Lightstream = ( + + + + + + +) + +export const Pencil = ( + + + +) + +export const Plus = ( + + + +) + +export const Copy = ( + + + +) + +export const Nvidia = ( + + + +) + +export const AccountSettings = ( + + + +) + +export const WizardHat = ( + + + +) + +export const Book = ( + + + +) + +export const Logout = ( + + + +) + +export const Lightning = ( + + + +) + +export const LightningFilled = ( + + + +) + +export const Gear = ( + + + +) + +export const SpinnerDots = ( + + + +) + +export const ExternalLink = ( + + + +) + +export const Pause = ( + + + +) + +export const Play = ( + + + +) + +export const Folder = ( + + + +) + +export const CaretCircleDown = ( + + + +) + +export const CaretDownLight = ( + + + +) + +export const CaretRightLight = ( + + + +) + +export const CaretCircleRight = ( + + + +) + +export const Menu = ( + + + +) diff --git a/examples/studio-kit-demo/src/globals.d.ts b/examples/studio-kit-demo/src/globals.d.ts old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/src/guest-main.tsx b/examples/studio-kit-demo/src/guest-main.tsx index 3935d8d4..1628fbaf 100644 --- a/examples/studio-kit-demo/src/guest-main.tsx +++ b/examples/studio-kit-demo/src/guest-main.tsx @@ -26,7 +26,7 @@ const Content = () => {

Guest View

- + diff --git a/examples/studio-kit-demo/src/guest/guest.tsx b/examples/studio-kit-demo/src/guest/guest.tsx index 5465c73c..7b0f9c77 100644 --- a/examples/studio-kit-demo/src/guest/guest.tsx +++ b/examples/studio-kit-demo/src/guest/guest.tsx @@ -5,19 +5,24 @@ import { useContext, useEffect, useRef, useState } from 'react' import { init, Helpers, SDK } from '@api.stream/studio-kit' import { ControlPanel, DeviceSelection } from '../shared/control-panel' -import { Participant } from '../shared/participant' import Style from '../shared/shared.module.css' import { Chat } from '../shared/chat' import config from '../../config' +import { Column, Row } from '../ui/Box' +import { Renderer } from '../components' const { Room } = Helpers const { useStudio } = Helpers.React const DEFAULT_GUEST_NAME = 'Guest-' + Math.floor(Math.random() * 1e4) -const Project = () => { - const { studio, project, room } = useStudio() - const renderContainer = useRef() +const Top = ({ + studio, + project, +}: { + studio: SDK.Studio + project: SDK.Project +}) => { const [isLive, setIsLive] = useState(false) useEffect(() => { @@ -35,26 +40,10 @@ const Project = () => { }) }, []) - useEffect(()=>{ - if(room){ - room.sendData({type : "UserJoined"}); - } - },[room]) - useEffect(() => { - studio.render({ - containerEl: renderContainer.current, - projectId: project.id, - dragAndDrop: false, // Disable controls for guests - }) - }, [renderContainer.current]) - - if (!room) return null - return ( -
-
+ {isLive &&
You're live!
} -
+
{ >
-
-
+ + ) } @@ -73,19 +62,12 @@ export const GuestView = () => { const { studio, project, room, setStudio, setProject, setRoom } = useStudio() const [joining, setJoining] = useState(false) const [displayName, setDisplayName] = useState(DEFAULT_GUEST_NAME) - const [participant, setParticipant] = useState() const [error, setError] = useState() const [inRoom, setInRoom] = useState(false) // Store as a global for debugging in console window.SDK = useStudio() - // Listen for room participants - useEffect(() => { - if (!room) return - return room.useParticipant(room.participantId, setParticipant) - }, [room]) - // Initialize studio useEffect(() => { if (!joining) return @@ -176,17 +158,12 @@ export const GuestView = () => { } return ( -
-
- {participant && } -
- -
+ + + + -
-
+ + ) } diff --git a/examples/studio-kit-demo/src/host-main.tsx b/examples/studio-kit-demo/src/host-main.tsx index 70338ff0..31a5d303 100644 --- a/examples/studio-kit-demo/src/host-main.tsx +++ b/examples/studio-kit-demo/src/host-main.tsx @@ -6,7 +6,7 @@ import React from 'react' import ReactDOM from 'react-dom' import { HostView } from './host/host' import { AppProvider } from './shared/context' -import { Helpers } from '../../../' +import { Helpers } from '@api.stream/studio-kit' import url from '../logo.png' import './index.css' import '../Font.ttf' @@ -26,7 +26,7 @@ const Content = () => { - + diff --git a/examples/studio-kit-demo/src/host/host.tsx b/examples/studio-kit-demo/src/host/host.tsx index ff8f3aab..e84562a8 100644 --- a/examples/studio-kit-demo/src/host/host.tsx +++ b/examples/studio-kit-demo/src/host/host.tsx @@ -3,10 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. * -------------------------------------------------------------------------------------------- */ import React, { useEffect, useRef, useState } from 'react' -import { init, Helpers } from '../../../../' -import { Participants } from '../shared/participant' +// TODO: Change import to @api.stream/studio-kit +import { + init, + Helpers, + SDK, + Sources, + Components, + Compositor, +} from '@api.stream/studio-kit' import { ControlPanel, DeviceSelection } from '../shared/control-panel' -import { DEFAULT_LAYOUT, getLayout, layouts } from './layout-examples' +import { DEFAULT_LAYOUT, getLayout, layouts } from '../layout-examples' import { Chat } from '../shared/chat' import Style from '../shared/shared.module.css' import config from '../../config' @@ -14,16 +21,14 @@ import config from '../../config' import ReCAPTCHA from 'react-google-recaptcha' import axios from 'axios' import { nanoid } from 'nanoid' -import { Banner } from '../../../../types/src/helpers/sceneless-project' +import { Column, Flex, Row } from '../ui/Box' +import { Component, Renderer } from '../components' +import { BackgroundSelect, BannerSelect, SourceList } from '../shared/sources' -const { ScenelessProject } = Helpers const { useStudio } = Helpers.React const getUrl = () => - window.location.protocol + - '//' + - window.location.host + - window.location.pathname + window.location.protocol + '//' + window.location.host + '/' // Determine whether this is running on API.stream const isLiveURL = () => { @@ -36,13 +41,70 @@ const storage = { userName: localStorage.getItem('userName') || '', } +export const sources = { + Image: [ + { + src: getUrl() + 'bg.png', + }, + ], + Video: [ + { + src: 'https://assets.mixkit.co/videos/preview/mixkit-stars-in-space-1610-large.mp4', + }, + { + src: 'https://assets.mixkit.co/videos/preview/mixkit-curvy-road-on-a-tree-covered-hill-41537-large.mp4', + }, + ], + Banner: [ + { + headerText: 'Header', + bodyText: 'Some banner text', + meta: { + title: 'Short', + }, + }, + { + bodyText: 'This is a banner without a header. It has more text.', + meta: { + title: 'Long', + }, + }, + ], +} + +export const projects = { + Sceneless: { + settings: { + type: 'Sceneless', + props: { + layout: getLayout(DEFAULT_LAYOUT).layout, + layoutProps: getLayout(DEFAULT_LAYOUT).props, + }, + sources, + }, + }, + MultiScene: { + settings: { + type: 'MultiScene', + props: {}, + sources, + }, + }, +} + export const generateId = () => (Math.random() * 1e20).toString(36) const Login = (props: { - onLogin: ({ token, userName }: { token: string; userName: string }) => void + onLogin: (result: { + token: string + userName: string + projectType: keyof typeof projects + }) => void }) => { const { onLogin } = props const [userName, setUserName] = useState(storage.userName) + const [projectType, setProjectType] = + useState('Sceneless') const [recaptchaToken, setRecaptchaToken] = useState() const login = async (e: any) => { @@ -89,7 +151,7 @@ const Login = (props: { token = res.data.accessToken as string } - onLogin({ token, userName }) + onLogin({ token, userName, projectType }) } return ( @@ -109,6 +171,18 @@ const Login = (props: { setUserName(e.target.value) }} /> + +
{ - const { studio, project, room, projectCommands } = useStudio() - const renderContainer = useRef() +const Top = ({ + studio, + project, +}: { + studio: SDK.Studio + project: SDK.Project +}) => { const destination = project.destinations[0] const destinationAddress = destination?.address.rtmpPush - const { Command } = studio - const [rtmpUrl, setRtmpUrl] = useState(destinationAddress?.url) const [streamKey, setStreamKey] = useState(destinationAddress?.key) - const [previewUrl, setPreviewUrl] = useState('') - const [guestUrl, setGuestUrl] = useState('') const [isLive, setIsLive] = useState(false) - const [selectedVideo, setSelectedVideo] = useState(null) - const [selectedImage, setSelectedImage] = useState(null) - const [banners, setBanners] = React.useState( - studio.compositor.getSources('Banner'), - ) - - const [projectedLoaded, setProjectedLoaded] = useState(false) - // Get custom layout name from metadata we store - const layout = project.props.layout - const background = projectCommands.getBackgroundMedia() - const overlay = projectCommands.getImageOverlay() - - const overlays = [ - { - id: '123', - url: 'https://www.pngmart.com/files/12/Twitch-Stream-Overlay-PNG-Transparent-Picture.png', - }, - { - id: '124', - url: 'https://www.pngmart.com/files/12/Stream-Overlay-Transparent-PNG.png', - }, - ] - - const logos = [ - { - id: '128', - url: 'https://www.pngmart.com/files/12/Twitch-Stream-Overlay-PNG-Transparent-Picture.png', - }, - { - id: '129', - url: 'https://www.pngmart.com/files/12/Stream-Overlay-Transparent-PNG.png', - }, - ] + const [guestUrl, setGuestUrl] = useState('') - const videooverlays = [ - { - id: '125', - url: 'https://assets.mixkit.co/videos/preview/mixkit-stars-in-space-1610-large.mp4', - }, - { - id: '126', - url: 'https://assets.mixkit.co/videos/preview/mixkit-curvy-road-on-a-tree-covered-hill-41537-large.mp4', - }, - { - id: '127', - url: 'https://assets.mixkit.co/videos/preview/mixkit-curvy-road-on-a-tree-covered-hill-41537-large.mp4', - }, - ] + // Generate project links + useEffect(() => { + studio.createGuestLink(getUrl() + 'guest/').then(setGuestUrl) + }, []) // Listen for project events useEffect(() => { @@ -202,38 +234,8 @@ const Project = () => { }) }, []) - useEffect(() => { - studio.compositor.subscribe((event, payload) => { - if (event === 'VideoTimeUpdate') { - console.log(payload) - } - }) - }, []) - - React.useEffect(() => studio.compositor.useSources('Banner', setBanners), []) - // Generate project links - useEffect(() => { - studio.createPreviewLink().then(setPreviewUrl) - studio.createGuestLink(getUrl() + 'guest/').then(setGuestUrl) - }, []) - - useEffect(() => { - studio.render({ - containerEl: renderContainer.current, - projectId: project.id, - dragAndDrop: true, - }) - }, [renderContainer.current]) - - function randomIntFromInterval(min: number, max: number) { - // min and max included - return Math.floor(Math.random() * (max - min + 1) + min) - } - - if (!room) return null - return ( -
+
Logged in as {localStorage.userName}
@@ -248,277 +250,52 @@ const Project = () => {
-
- - { - setRtmpUrl(e.target.value) - }} - /> - - { - setStreamKey(e.target.value) - }} - /> -
- {!isLive ? ( - - ) : ( - - )} -
-
-
- - Overlays -
    - {overlays.map((overlay) => ( -
  • { - if (selectedImage !== overlay.id) { - setSelectedImage(overlay.id) - projectCommands.addImageOverlay2(overlay.id, { - src: overlay.url, - }) - } else { - projectCommands.removeImageOverlay2(selectedImage) - setSelectedImage(null) - } - }} - > - -
  • - ))} -
-
-
-
- - Video clips -
    - {videooverlays.map((overlay) => ( -
  • { - if (selectedVideo !== overlay.id) { - setSelectedVideo(overlay.id) - projectCommands.addVideoOverlay2(overlay.id, { - src: overlay.url, - loop: true, - }) - } else { - projectCommands.removeVideoOverlay2(selectedVideo) - setSelectedVideo(null) - } - }} - > -
  • - ))} -
-
-
-
- - Logos -
    - {logos.map((logo) => ( -
  • { - if (selectedImage !== logo.id) { - setSelectedImage(logo.id) - projectCommands.addLogo(logo.id, { - src: logo.url, - }) - } else { - projectCommands.removeLogo(selectedImage) - setSelectedImage(null) - } - }} - > - -
  • - ))} -
-
-
-
-
+ + { + setRtmpUrl(e.target.value) + }} + /> + + { + setStreamKey(e.target.value) + }} + />
- -
-
- - { - if (/\.(gif|jpe?g|tiff?|png|webp|bmp)$/i.test(e.target.value)) { - // projectCommands.setBackgroundImage(e.target.value) - projectCommands.setBackgroundImage2(generateId(), { - src: e.target.value, - }) - } else { - // projectCommands.setBackgroundVideo(e.target.value) - // projectCommands.setBackgroundVideo2(generateId(), { - // src: e.target.value, - // }) - projectCommands.addCustomOverlay(generateId(), { - src: e.target.value, - width: '1920px', - height: '1080px', - }) - } - }} - /> -
-
- - { - projectCommands.addChatOverlay(generateId(), { - message: JSON.parse( - '[{"type":"text","text":"is now live! Streaming Mobile Legends: Bang Bang: My Stream "},{"type":"emoticon","text":"SirUwU","data":{"type":"direct","url":"https://static-cdn.jtvnw.net/emoticons/v2/301544927/default/light/2.0"}},{"type":"text","text":" Hey hey hey!!! this is going live "},{"type":"emoticon","text":"WutFace","data":{"type":"direct","url":"https://static-cdn.jtvnw.net/emoticons/v2/28087/default/light/2.0"}},{"type":"text","text":" , so lets go"}]', - ), - username: 'Maddygoround', - metadata: { - platform : "twitch", - variant: 0, - avatar: - 'https://inf2userdata0wus.blob.core.windows.net/content/62cc383fec1b480054cc2fde/resources/video/EchoBG.mp4/medium.jpg', - }, - }) - }} - /> -
-
- - { - projectCommands.addBanner({ - bodyText: `hey hey hey ${Date.now()}`, - }) - }} - /> -
-
- - { - const randomIndex = randomIntFromInterval(0, banners.length - 1) - projectCommands.setActiveBanner(banners[randomIndex].id) - }} - /> -
-
- - -
-
-
- -
- -
-
-
-
- -
-
-
- - e.target.select()} - value={previewUrl} - readOnly={true} - style={{ width: 630 }} - /> + {!isLive ? ( + + ) : ( + + )}
@@ -530,25 +307,39 @@ const Project = () => { style={{ width: 630 }} />
-
+ + ) +} + +const Bottom = () => { + return ( + + +
+ +
+
) } export const HostView = () => { - const { - studio, - project, - projectCommands, - room, - setProject, - setRoom, - setStudio, - } = useStudio() const [token, setToken] = useState(localStorage['token']) const [failure, setFailure] = useState(null) + const [projectType, setProjectType] = useState( + localStorage['projectType'], + ) + const root = project?.scene.getRoot() + + // @ts-ignore Debug helper + window.project = project - // Store as a global for debugging in console - window.SDK = useStudio() + // @ts-ignore Debug helper + window.component = root useEffect(() => { init({ @@ -570,66 +361,43 @@ export const HostView = () => { setProject(studio.initialProject) }, [studio]) - useEffect(() => { - if (!token || !studio || project) return - // Log in - studio - .load(token) - .then(async (user) => { - // If there's a project, return it - otherwise create one - let project = user.projects[0] - if (!project) { - const { layout, props } = getLayout(DEFAULT_LAYOUT) - project = await ScenelessProject.create( - { - backgroundImage: getUrl() + 'bg.png', - layout, - layoutProps: props, - }, - - // Store our custom layout in metadata for future reference - { layout: DEFAULT_LAYOUT }, - ) - } - const activeProject = await studio.Command.setActiveProject({ - projectId: project.id, - }) - const room = await activeProject.joinRoom({ - displayName: localStorage.userName, - }) - - setRoom(room) - setProject(activeProject) - }) - .catch((e) => { - console.warn(e) - setToken(null) - localStorage.removeItem('token') - }) - }, [studio, token, project]) - useEffect(() => { if (room) { room.sendData({ type: 'UserJoined' }) } }, [room]) - useEffect(() => { - if (!projectCommands || !room) return - // Prune non-existent participants from the project - projectCommands.pruneParticipants() - }, [projectCommands, room]) - - if (project && room) { - return + if (project && room && root) { + return ( + + + + + {root.children[0] && } + + + + + + + + + + + + + ) } + if (studio && !token) { return ( { + onLogin={({ userName, token, projectType }) => { + setProjectType(projectType) setToken(token) localStorage.setItem('userName', userName) localStorage.setItem('token', token) + localStorage.setItem('projectType', projectType) }} /> ) diff --git a/examples/studio-kit-demo/src/index.css b/examples/studio-kit-demo/src/index.css old mode 100644 new mode 100755 index d0352c2b..d3d237a0 --- a/examples/studio-kit-demo/src/index.css +++ b/examples/studio-kit-demo/src/index.css @@ -123,3 +123,55 @@ button[disabled] { margin-bottom: 10px; border-bottom: 1px solid var(--medium); } + +.node-tree { + position: relative; + margin-left: 16px; +} +#node-tree-Game { + margin-left: 0; +} +.node-tree:not(.collapsed):after { + content: ''; + position: absolute; + top: 28px; + left: 6px; + bottom: 0; + width: 1px; + background: white; + opacity: 0.3; + pointer-events: none; +} +.node-tree.active:after { + opacity: 1; +} +.node-tree:not(.collapsed):after { + content: ''; + position: absolute; + top: 22px; + left: 6px; + bottom: 8px; + width: 1px; + background: white; + pointer-events: none; +} +.node-item { + cursor: default; + height: 20px; +} +.node-item:hover .node-remove { + opacity: 0.3; + pointer-events: all; +} +.node-menu, +.node-remove { + display: none; + margin-left: 10px; + opacity: 0; + pointer-events: none; +} +.node-item:hover .node-menu:hover, +.node-item:hover .node-remove:hover { + display: block; + opacity: 0.7; +} diff --git a/examples/studio-kit-demo/src/host/layout-examples.tsx b/examples/studio-kit-demo/src/layout-examples.tsx old mode 100644 new mode 100755 similarity index 100% rename from examples/studio-kit-demo/src/host/layout-examples.tsx rename to examples/studio-kit-demo/src/layout-examples.tsx diff --git a/examples/studio-kit-demo/src/liveblocks-adapter.ts b/examples/studio-kit-demo/src/liveblocks-adapter.ts new file mode 100644 index 00000000..164d3431 --- /dev/null +++ b/examples/studio-kit-demo/src/liveblocks-adapter.ts @@ -0,0 +1,209 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import { Compositor, SDK } from '@api.stream/studio-kit' +import { + createClient, + LiveList, + LiveMap, + LiveObject, + Room, +} from '@liveblocks/client' +import config from '../config' +import { generateId } from './host/host' + +const client = createClient({ + publicApiKey: config.liveblocksKey, +}) + +type LiveNode = LiveObject<{ + id: string + props: LiveObject<{ + [prop: string]: any + }> + children: LiveList +}> +type Root = LiveObject<{ + nodes: LiveMap + node: LiveNode +}> + +const nodeIndex = {} as { + [id: string]: LiveNode +} +const parentIdIndex = {} as { + [id: string]: string +} +let room: ReturnType +let root: Root + +export const dbAdapter: Compositor.DBAdapter = { + db: (project) => { + return { + async insert(props = {}, parentId, index, id = generateId()) { + // Create the node + let node = new LiveObject({ + id, + props: new LiveObject(props), + children: new LiveList(), + }) as LiveNode + + // Index the node + nodeIndex[id] = node + parentIdIndex[id] = parentId + + // Add the node to its parent + if (!parentId) { + root.set('node', node) + } else { + const parent = nodeIndex[parentId] + parent.get('children').insert(node, index) + } + + return id + }, + async update(id, props = {}, replaceAll = false) { + const node = nodeIndex[id] + node.get('props').update(props) + }, + async remove(id) { + const parent = nodeIndex[parentIdIndex[id]] + parent + .get('children') + .delete(parent.get('children').findIndex((x) => x.get('id') === id)) + }, + async reorder(id, childIds) { + const parent = nodeIndex[parentIdIndex[id]] + const children = parent.get('children') + const childArray = children.toArray() + + childIds.forEach((id, i) => { + children.set( + i, + childArray.find((x) => x.get('id') === id), + ) + }) + }, + } + }, + createProject: () => { + const id = generateId() + return id + }, + loadProject: async (id) => { + // @ts-ignore + room = window.room = client.enter(id, { + initialPresence: {}, + initialStorage: { + nodes: new LiveMap(), + }, + }) + const storage = await room.getStorage() + root = storage.root as Root + + const getChildrenDifference = ( + previous: string[] = [], + next: string[] = [], + ) => { + return { + added: next.filter((item) => !previous.some((x) => item === x)), + removed: previous.filter((item) => !next.some((x) => item === x)), + } + } + + const indexDataTree = (node: LiveNode, parentId?: string) => { + // Index all nodes as LiveObject + nodeIndex[node.get('id')] = node + parentIdIndex[node.get('id')] = parentId + + // TODO: Receive actual project reference + const project = window.project as Compositor.Project + + node.get('children').forEach((x) => indexDataTree(x, node.get('id'))) + + // const subscribeToNode = (subscribeNode: LiveNode) => { + // room.subscribe(subscribeNode.get('props'), () => { + // project.local.update( + // subscribeNode.get('id'), + // subscribeNode.get('props').toObject(), + // ) + // }) + // room.subscribe(subscribeNode.get('children'), (next) => { + // const { added, removed } = getChildrenDifference( + // project.get(subscribeNode.get('id')).children.map((x) => x.id), + // next.map((x) => x.get('id')), + // ) + // added.forEach((id) => { + // const node = next.find((x) => x.get('id') === id) + // indexDataTree(node, subscribeNode.get('id')) + // // NOTE: In theory, only one node will be inserted at once. + // // If multiple nodes are inserted at once, we'll run into problems + // // inserting nodes at the correct index within parent. + // project.local.insert( + // { + // id: node.get('id'), + // props: node.get('props').toObject(), + // // @ts-ignore TODO: Resolve readonly issues + // // TODO: Create custom function to map all children toObject() + // children: node.get('children').toImmutable(), + // }, + // parentId, + // next.indexOf(node), + // ) + // }) + // removed.forEach((x) => {}) + // }) + // } + + // subscribeToNode(node) + } + + const toSceneTree = (root: LiveNode): Compositor.SceneNode => { + if (!root) return null + + const obj = root.toObject() + + return { + id: obj.id, + props: obj.props.toObject(), + children: root + .get('children') + .toArray() + .map((x) => toSceneTree(x)) + .filter(Boolean), + } + } + + if (!root.get('node')) return null + + room.subscribe(root.get('node').get('props'), () => { + // TODO: Receive actual project reference + window.setTimeout(() => { + window.project.local.update( + root.get('node').get('id'), + root.get('node').get('props').toObject(), + ) + }) + }) + room.subscribe(root.get('node').get('children'), () => { + // TODO: Receive actual project reference + window.setTimeout(() => { + window.project.local.update( + root.get('node').get('id'), + root.get('node').get('props').toObject(), + root + .get('node') + .get('children') + .toArray() + .map((x) => x.toObject()), + ) + }) + }) + + indexDataTree(root.get('node')) + const tree = toSceneTree(root.get('node')) + console.log('Loaded project tree', tree) + return tree + }, +} diff --git a/examples/studio-kit-demo/src/shared/chat.module.css b/examples/studio-kit-demo/src/shared/chat.module.css old mode 100644 new mode 100755 index 7c48423c..895de941 --- a/examples/studio-kit-demo/src/shared/chat.module.css +++ b/examples/studio-kit-demo/src/shared/chat.module.css @@ -47,7 +47,6 @@ flex-direction: column; justify-content: stretch; align-items: flex-end; - height: 100%; width: 300px; } diff --git a/examples/studio-kit-demo/src/shared/context.tsx b/examples/studio-kit-demo/src/shared/context.tsx old mode 100644 new mode 100755 index 9f1c5745..e264c92e --- a/examples/studio-kit-demo/src/shared/context.tsx +++ b/examples/studio-kit-demo/src/shared/context.tsx @@ -2,15 +2,21 @@ * Copyright (c) Infiniscene, Inc. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. * -------------------------------------------------------------------------------------------- */ -import React from 'react' +import { Compositor, init, SDK } from '@api.stream/studio-kit' +import React, { useContext, useEffect, useState } from 'react' +import config from '../../config' +import { projects } from '../host/host' -type Context = { +type AppContext = { isHost: boolean + setToken: (token: string) => void + studio: SDK.Studio + project: SDK.Project } -export const AppContext = React.createContext({ +export const AppContext = React.createContext({ isHost: false, -}) +} as AppContext) export const AppProvider = ({ isHost, @@ -19,13 +25,76 @@ export const AppProvider = ({ isHost: boolean children: React.ReactChild }) => { + const [studio, setStudio] = useState() + const [project, setProject] = useState() + const [room, setRoom] = useState() + const [token, setToken] = useState() + + useEffect(() => { + init({ + env: config.env, + logLevel: config.logLevel, + }).then(setStudio) + }, []) + + useEffect(() => { + if (!studio) return + + // If the SDK detects a token in the URL, it will return the project + // associated with it (e.g. guest view) + setProject(studio.initialProject) + }, [studio]) + + useEffect(() => { + if (!token || !studio || project) return + // Log in + studio + .load(token) + .then(async (user) => { + // If there's a project, return it - otherwise create one + let project = user.projects[0] + if (!project) { + project = await studio.Command.createProject({ + settings: projects[projectType].settings, + }) + } + const activeProject = await studio.Command.setActiveProject({ + projectId: project.id, + }) + const room = await activeProject.joinRoom({ + displayName: localStorage.userName, + }) + + setRoom(room) + setProject(activeProject) + }) + .catch((e) => { + console.warn(e) + setToken(null) + localStorage.removeItem('token') + }) + }, [studio, token]) + + if (!studio) { + return
Loading SDK...
+ } + + if (!project) { + return
Loading project...
+ } + return ( {children} ) } + +export const useApp = () => useContext(AppContext) diff --git a/examples/studio-kit-demo/src/shared/control-panel.tsx b/examples/studio-kit-demo/src/shared/control-panel.tsx index 3c17867b..5ed70c20 100644 --- a/examples/studio-kit-demo/src/shared/control-panel.tsx +++ b/examples/studio-kit-demo/src/shared/control-panel.tsx @@ -56,14 +56,8 @@ export const DeviceSelection = () => { ) } -export const ControlPanel = ({ - room, - projectCommands, -}: { - room: any - projectCommands - :any}) => { - +export const ControlPanel = () => { + const { room } = useStudio() const [isSharingScreen, setIsSharingScreen] = useState(false) const [participant, setParticipant] = useState() @@ -79,7 +73,7 @@ export const ControlPanel = ({ (x) => room.getTrack(x)?.type === 'screen_share', ) if (!screenshare) { - projectCommands.removeParticipant(room.participantId, 'screen') + // projectCommands.removeParticipant(room.participantId, 'screen') } setIsSharingScreen(Boolean(screenshare)) }, [participant?.trackIds]) diff --git a/examples/studio-kit-demo/src/shared/hooks.tsx b/examples/studio-kit-demo/src/shared/hooks.tsx new file mode 100644 index 00000000..38774556 --- /dev/null +++ b/examples/studio-kit-demo/src/shared/hooks.tsx @@ -0,0 +1,65 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import { useCallback, useEffect, useReducer, useState } from 'react' +import { Compositor } from '@api.stream/studio-kit' + +type NodeInterface = Compositor.Component.NodeInterface +type Source = Compositor.Source.Source + +export function useSources( + component: NodeInterface, + sourceType: string, +): S[] { + const [sources, setSources] = useState([]) + + useEffect(() => { + return component.sources.useAll(sourceType, setSources) + }, []) + + return sources +} + +export function useRenderRef(project: Compositor.Project) { + const renderContainer = useCallback((node) => { + project.render({ + containerEl: node, + }) + }, []) + return renderContainer +} + +export function useUpdate(nodeId: string, project: Compositor.Project) { + const [_, forceUpdate] = useReducer((x) => x + 1, 0) + useEffect(() => project.useUpdate(nodeId, forceUpdate), [nodeId]) +} + +type CopiedValue = string | null +type CopyFn = (text: string) => Promise // Return success + +export function useCopyToClipboard(): [CopiedValue, CopyFn] { + const [copiedText, setCopiedText] = useState(null) + + const copy: CopyFn = async (text) => { + if (!navigator?.clipboard) { + console.warn('Clipboard not supported') + return false + } + + // Try to save to clipboard then save it in the state if worked + try { + await navigator.clipboard.writeText(text) + setCopiedText(text) + return true + } catch (error) { + console.warn('Copy failed', error) + setCopiedText(null) + return false + } + } + + return [copiedText, copy] +} + +export default useCopyToClipboard diff --git a/examples/studio-kit-demo/src/shared/logic.ts b/examples/studio-kit-demo/src/shared/logic.ts new file mode 100644 index 00000000..a72d6be1 --- /dev/null +++ b/examples/studio-kit-demo/src/shared/logic.ts @@ -0,0 +1,8 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +// Ensure an item is iterable as an array +export const asArray = (x: T | T[]): T[] => { + return Array.isArray(x) ? (x as T[]) : [x as T] +} diff --git a/examples/studio-kit-demo/src/shared/participant.tsx b/examples/studio-kit-demo/src/shared/participant.tsx deleted file mode 100644 index 88e77bf2..00000000 --- a/examples/studio-kit-demo/src/shared/participant.tsx +++ /dev/null @@ -1,367 +0,0 @@ -/* --------------------------------------------------------------------------------------------- - * Copyright (c) Infiniscene, Inc. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - * -------------------------------------------------------------------------------------------- */ -import { Helpers, SDK } from '@api.stream/studio-kit' -import { useContext, useEffect, useMemo, useRef, useState } from 'react' -import { AppContext } from './context' -import Style from './shared.module.css' - -const { Room } = Helpers -const { useStudio } = Helpers.React - - -export const Participants = ({room , projectCommands ,studio} : {room:any,projectCommands:any ,studio:any}) => { - - const { isHost } = useContext(AppContext) - const [participants, setParticipants] = useState([]) - - // Listen for room participants - useEffect(() => { - if (!room) return - return room.useParticipants((participants:any) => { - setParticipants(participants) - // Prune non-existent guests from the project - if (isHost) projectCommands.pruneParticipants() - }) - }, [room]) - - - - - return ( -
- {participants.map((x) => ( -
- -
- ))} -
- ) -} - -type ParticipantProps = { - participant: SDK.Participant - room?: any - projectCommands?:any - studio?: any -} -export const ParticipantCamera = ({ - participant, - webcam, - microphone, - projectCommands, - room -}: ParticipantProps & { webcam: SDK.Track; microphone: SDK.Track }) => { - const { isHost } = useContext(AppContext) - const { id, displayName } = participant - const ref = useRef() - const [srcObject] = useState(new MediaStream([])) - const isEnabled = webcam?.mediaStreamTrack && !webcam?.isMuted - - useEffect(() => { - // Replace the tracks on the existing MediaStream - Room.updateMediaStreamTracks(srcObject, { - video: webcam?.mediaStreamTrack, - audio: microphone?.mediaStreamTrack, - }) - }, [webcam?.mediaStreamTrack, microphone?.mediaStreamTrack]) - - useEffect(() => { - if (ref.current) { - ref.current.srcObject = srcObject - } - }, [ref?.current, srcObject, isEnabled]) - - return ( -
-
{displayName}
-
-
- {isEnabled && ( -
- {isHost && } -
- ) -} - -export const ParticipantScreenshare = ({ - participant, - screenshare, -}: ParticipantProps & { screenshare: SDK.Track }) => { - const { isHost } = useContext(AppContext) - const { projectCommands } = useStudio() - const { id, displayName } = participant - const ref = useRef() - const [srcObject] = useState(new MediaStream([])) - - useEffect(() => { - // Replace the tracks on the existing MediaStream - Room.updateMediaStreamTracks(srcObject, { - video: screenshare?.mediaStreamTrack, - }) - }, [screenshare?.mediaStreamTrack]) - - useEffect(() => { - if (ref.current) { - ref.current.srcObject = srcObject - } - }, [ref?.current, srcObject]) - - return ( -
-
{displayName} (Screen)
-
-
- {isHost && } -
- ) -} - -export const Participant = ({ participant , room , projectCommands}: ParticipantProps) => { - const [tracks, setTracks] = useState([]) - const screenshare = tracks.find((x) => x.type === 'screen_share') - const webcam = tracks.find((x) => x.type === 'camera') - const microphone = tracks.find((x) => x.type === 'microphone') - - useEffect(() => { - if (!room) return - setTracks(participant.trackIds.map(room.getTrack).filter(Boolean)) - }, [participant?.trackIds, room]) - - return ( - <> - - {screenshare && ( -
- -
- )} - - ) -} - -const HostControls = ({ - participant, - type, - projectCommands, - room -}: ParticipantProps & { type: 'screen' | 'camera' }) => { - const { id } = participant - - // Get the initial props in case the participant is on stream - const projectParticipant = useMemo( - () => projectCommands.getParticipantState(id, type), - [], - ) - - - const [onStream, setOnStream] = useState(Boolean(projectParticipant)) - const [isMuted, setIsMuted] = useState(projectParticipant?.isMuted ?? false) - const [volume, setVolume] = useState(projectParticipant?.volume ?? 1) - const [isShowcase, setIsShowcase] = useState(false) - - // Monitor whether the participant has been removed from the stream - // from some other means (e.g. dragged off canvas by host) - useEffect(() => { - return projectCommands.useParticipantState( - id, - (x:any) => { - setOnStream(Boolean(x)) - }, - type, - ) - }, []) - - // Monitor the project's showcase to determine whether this - // participant/type is active - useEffect( - () => - projectCommands.useShowcase((showcase:any) => { - setIsShowcase(showcase.participantId === id && showcase.type === type) - }), - - [], - ) - - return ( -
-
- {/*
- - {type === 'camera' && ( - <> - { - const value = Number(e.target.value) - projectCommands.setParticipantVolume(id, value) - setVolume(value) - - // Unmute when user changes the volume slider - if (isMuted) { - projectCommands.setParticipantMuted(id, false) - setIsMuted(false) - } - }} - /> - { - const checked = e.target.checked - projectCommands.setParticipantMuted(id, !checked) - setIsMuted(!checked) - }} - /> - - )} - -
- ) -} diff --git a/examples/studio-kit-demo/src/shared/props.tsx b/examples/studio-kit-demo/src/shared/props.tsx new file mode 100644 index 00000000..79dbb410 --- /dev/null +++ b/examples/studio-kit-demo/src/shared/props.tsx @@ -0,0 +1,34 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import React from 'react' +import { Compositor } from '@api.stream/studio-kit' + +export const Layout = ({ + component, +}: { + component: Compositor.Component.NodeInterface +}) => { + const layout = component.nodeProps.layout + const layouts = component.compositor.layouts.layouts + + return ( + + ) +} diff --git a/examples/studio-kit-demo/src/shared/shared.module.css b/examples/studio-kit-demo/src/shared/shared.module.css old mode 100644 new mode 100755 diff --git a/examples/studio-kit-demo/src/shared/sources.tsx b/examples/studio-kit-demo/src/shared/sources.tsx new file mode 100644 index 00000000..7638333a --- /dev/null +++ b/examples/studio-kit-demo/src/shared/sources.tsx @@ -0,0 +1,342 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Infiniscene, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * -------------------------------------------------------------------------------------------- */ +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react' +import { + Sources, + Components, + Compositor, + Elements, +} from '@api.stream/studio-kit' +import { ScenelessInterface } from '../components/Sceneless' +import { Column, Flex, Row } from '../ui/Box' +import { AppContext } from './context' +import { useSources } from './hooks' + +type Participant = Sources.WebRTC.RoomParticipantSource +type Image = Sources.Image.ImageSource +type Video = Sources.Video.VideoSource +type Banner = Sources.Banner.BannerSource + +type NodeInterface = Compositor.Component.NodeInterface +type ParticipantElementProps = Elements.WebRTC.Props +type Source = Compositor.Source.Source + +type ParticipantNode = + Compositor.Component.NodeInterface +type Project = Components.Project.Interface + +export function SourceList({ + component, + sourceType, +}: // childTarget, +{ + component: NodeInterface + sourceType: keyof typeof items + // childTarget: string +}) { + const sources = useSources(component, sourceType) + const Component = items[sourceType] + return ( + <> + {sources + .filter((x) => x.isActive) + .map((x) => ( + + ))} + + ) +} + +export const BackgroundSelect = ({ + component, +}: { + component: NodeInterface +}) => { + const images = useSources(component, 'Image') + const videos = useSources