shared piano in progress

This commit is contained in:
Jeff Emmett 2025-08-23 16:07:43 +02:00
parent af2a93aa1a
commit 2db320a007
10 changed files with 960 additions and 8 deletions

238
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.33.1", "@anthropic-ai/sdk": "^0.33.1",
"@automerge/automerge-repo-react-hooks": "^2.2.0",
"@daily-co/daily-js": "^0.60.0", "@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0", "@daily-co/daily-react": "^0.20.0",
"@tldraw/assets": "^3.6.0", "@tldraw/assets": "^3.6.0",
@ -182,6 +183,56 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@automerge/automerge": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-3.1.1.tgz",
"integrity": "sha512-x7tZiMBLk4/SKYimVEVl1/wPntT9buGvLOWCey9ZcH8JUsB0dgm49C0S7Ojzgvflcs2hc/YjiXRPcFeFkinIgw==",
"license": "MIT"
},
"node_modules/@automerge/automerge-repo": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.2.0.tgz",
"integrity": "sha512-/4cAxnUDPykqdSMJJiJvPlR2VY0JHJnvEoM7fO8qVXwQors34S0VkJEScXRJZExcVhWGhFTwCTWvUbb6hhWQIQ==",
"license": "MIT",
"dependencies": {
"@automerge/automerge": "2.2.8 - 3",
"bs58check": "^3.0.1",
"cbor-x": "^1.3.0",
"debug": "^4.3.4",
"eventemitter3": "^5.0.1",
"fast-sha256": "^1.3.0",
"uuid": "^9.0.0",
"xstate": "^5.9.1"
}
},
"node_modules/@automerge/automerge-repo-react-hooks": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo-react-hooks/-/automerge-repo-react-hooks-2.2.0.tgz",
"integrity": "sha512-zHP44jXSCmV1DyyKdTKgv8nz7yrdCT7VFXs/QrF3YRhE/3czhhvbXo2zahGvOoM+jryH0eJxHTrJ7jMEtt/Ung==",
"license": "MIT",
"dependencies": {
"@automerge/automerge": "2.2.8 - 3",
"@automerge/automerge-repo": "2.2.0",
"eventemitter3": "^5.0.1",
"react-usestateref": "^1.0.8"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@automerge/automerge-repo-react-hooks/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/@automerge/automerge-repo/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.26.2", "version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@ -473,6 +524,84 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz",
"integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-darwin-x64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz",
"integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz",
"integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz",
"integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-x64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz",
"integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-win32-x64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz",
"integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@cloudflare/intl-types": { "node_modules/@cloudflare/intl-types": {
"version": "1.5.6", "version": "1.5.6",
"resolved": "https://registry.npmjs.org/@cloudflare/intl-types/-/intl-types-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/intl-types/-/intl-types-1.5.6.tgz",
@ -1838,6 +1967,18 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -4400,6 +4541,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base-x": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz",
"integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": { "node_modules/base64-arraybuffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
@ -4508,6 +4655,25 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/bs58": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
"integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
"license": "MIT",
"dependencies": {
"base-x": "^4.0.0"
}
},
"node_modules/bs58check": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz",
"integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bs58": "^5.0.0"
}
},
"node_modules/btoa": { "node_modules/btoa": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
@ -4608,6 +4774,37 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/cbor-extract": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz",
"integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.1.1"
},
"bin": {
"download-cbor-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@cbor-extract/cbor-extract-darwin-arm64": "2.2.0",
"@cbor-extract/cbor-extract-darwin-x64": "2.2.0",
"@cbor-extract/cbor-extract-linux-arm": "2.2.0",
"@cbor-extract/cbor-extract-linux-arm64": "2.2.0",
"@cbor-extract/cbor-extract-linux-x64": "2.2.0",
"@cbor-extract/cbor-extract-win32-x64": "2.2.0"
}
},
"node_modules/cbor-x": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz",
"integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==",
"license": "MIT",
"optionalDependencies": {
"cbor-extract": "^2.2.0"
}
},
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@ -6530,6 +6727,12 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.18.0", "version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
@ -9155,6 +9358,21 @@
"node-gyp-build-test": "build-test.js" "node-gyp-build-test": "build-test.js"
} }
}, },
"node_modules/node-gyp-build-optional-packages": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
"integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -9866,6 +10084,15 @@
} }
} }
}, },
"node_modules/react-usestateref": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/react-usestateref/-/react-usestateref-1.0.9.tgz",
"integrity": "sha512-t8KLsI7oje0HzfzGhxFXzuwbf1z9vhBM1ptHLUIHhYqZDKFuI5tzdhEVxSNzUkYxwF8XdpOErzHlKxvP7sTERw==",
"license": "ISC",
"peerDependencies": {
"react": ">16.0.0"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -11526,7 +11753,6 @@
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
@ -12457,6 +12683,16 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/xstate": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz",
"integrity": "sha512-i9ZpNnm/XhCOMUxae1suT8PjYNTStZWbhmuKt4xeTPaYG5TS0Fz0i+Ka5yxoNPpaHW3VW6JIowrwFgSTZONxig==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/xstate"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -17,6 +17,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.33.1", "@anthropic-ai/sdk": "^0.33.1",
"@automerge/automerge-repo-react-hooks": "^2.2.0",
"@daily-co/daily-js": "^0.60.0", "@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0", "@daily-co/daily-react": "^0.20.0",
"@tldraw/assets": "^3.6.0", "@tldraw/assets": "^3.6.0",

View File

@ -29,6 +29,8 @@ import { SlideShape } from "@/shapes/SlideShapeUtil"
import { makeRealSettings, applySettingsMigrations } from "@/lib/settings" import { makeRealSettings, applySettingsMigrations } from "@/lib/settings"
import { PromptShapeTool } from "@/tools/PromptShapeTool" import { PromptShapeTool } from "@/tools/PromptShapeTool"
import { PromptShape } from "@/shapes/PromptShapeUtil" import { PromptShape } from "@/shapes/PromptShapeUtil"
import { SharedPianoTool } from "@/tools/SharedPianoTool"
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
import { import {
lockElement, lockElement,
unlockElement, unlockElement,
@ -58,6 +60,7 @@ const customShapeUtils = [
MycrozineTemplateShape, MycrozineTemplateShape,
MarkdownShape, MarkdownShape,
PromptShape, PromptShape,
SharedPianoShape,
] ]
const customTools = [ const customTools = [
ChatBoxTool, ChatBoxTool,
@ -67,6 +70,7 @@ const customTools = [
MycrozineTemplateTool, MycrozineTemplateTool,
MarkdownTool, MarkdownTool,
PromptShapeTool, PromptShapeTool,
SharedPianoTool,
GestureTool, GestureTool,
] ]

View File

@ -0,0 +1,257 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useCallback, useState } from "react"
export type ISharedPianoShape = TLBaseShape<
"SharedPiano",
{
w: number
h: number
isMinimized?: boolean
interactionState?: {
scrollPosition?: { x: number; y: number }
}
}
>
const getDefaultDimensions = (): { w: number; h: number } => {
// Default dimensions for the Shared Piano (16:9 ratio)
return { w: 800, h: 600 }
}
export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
static override type = "SharedPiano"
getDefaultProps(): ISharedPianoShape["props"] {
const { w, h } = getDefaultDimensions()
return {
w,
h,
isMinimized: false,
}
}
indicator(shape: ISharedPianoShape) {
return (
<rect
width={shape.props.w}
height={shape.props.h}
fill="none"
stroke="var(--color-selected)"
strokeWidth={2}
/>
)
}
component(shape: ISharedPianoShape) {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const handleIframeLoad = useCallback(() => {
setIsLoading(false)
setError(null)
}, [])
const handleIframeError = useCallback(() => {
setIsLoading(false)
setError("Failed to load Shared Piano")
}, [])
const handleToggleMinimize = (e: React.MouseEvent) => {
e.stopPropagation()
this.editor.updateShape<ISharedPianoShape>({
id: shape.id,
type: "SharedPiano",
props: {
...shape.props,
isMinimized: !shape.props.isMinimized,
},
})
}
const controls = (
<div
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 1000,
display: "flex",
gap: 4,
}}
>
<button
onClick={handleToggleMinimize}
style={{
background: "rgba(0, 0, 0, 0.7)",
color: "white",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "12px",
cursor: "pointer",
}}
>
{shape.props.isMinimized ? "🔽" : "🔼"}
</button>
</div>
)
const sharedPianoUrl = "https://musiclab.chromeexperiments.com/Shared-Piano/#jQB715bFJ"
return (
<div
style={{
width: "100%",
height: "100%",
position: "relative",
overflow: "hidden",
borderRadius: "8px",
border: "1px solid var(--color-panel)",
backgroundColor: "var(--color-background)",
}}
>
{controls}
{shape.props.isMinimized ? (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontSize: "16px",
fontWeight: "bold",
}}
>
🎹 Shared Piano
</div>
) : (
<>
{isLoading && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--color-background)",
zIndex: 1,
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>🎹</div>
<div>Loading Shared Piano...</div>
</div>
</div>
)}
{error && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--color-background)",
zIndex: 1,
}}
>
<div style={{ textAlign: "center", color: "var(--color-text)" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}></div>
<div>{error}</div>
<button
onClick={() => {
setIsLoading(true)
setError(null)
// Force iframe reload
const iframe = document.querySelector(`iframe[data-shape-id="${shape.id}"]`) as HTMLIFrameElement
if (iframe) {
iframe.src = iframe.src
}
}}
style={{
marginTop: "8px",
padding: "4px 8px",
background: "var(--color-primary)",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Retry
</button>
</div>
</div>
)}
<iframe
data-shape-id={shape.id}
src={sharedPianoUrl}
style={{
width: "100%",
height: "100%",
border: "none",
borderRadius: "8px",
opacity: isLoading ? 0 : 1,
transition: "opacity 0.3s ease",
}}
onLoad={handleIframeLoad}
onError={handleIframeError}
title="Chrome Music Lab Shared Piano"
allow="microphone; camera"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/>
</>
)}
</div>
)
}
override onDoubleClick = (shape: ISharedPianoShape) => {
// Toggle minimized state on double click
this.editor.updateShape<ISharedPianoShape>({
id: shape.id,
type: "SharedPiano",
props: {
...shape.props,
isMinimized: !shape.props.isMinimized,
},
})
}
onPointerDown = (_shape: ISharedPianoShape) => {
// Handle pointer down events if needed
}
override onBeforeCreate = (shape: ISharedPianoShape) => {
// Set default dimensions if not provided
if (!shape.props.w || !shape.props.h) {
const { w, h } = getDefaultDimensions()
this.editor.updateShape<ISharedPianoShape>({
id: shape.id,
type: "SharedPiano",
props: {
...shape.props,
w,
h,
},
})
}
}
onBeforeUpdate = (_prev: ISharedPianoShape, _next: ISharedPianoShape) => {
// Handle any updates if needed
}
}

View File

@ -19,6 +19,15 @@ export type IVideoChatShape = TLBaseShape<
allowMicrophone: boolean allowMicrophone: boolean
enableRecording: boolean enableRecording: boolean
recordingId: string | null // Track active recording recordingId: string | null // Track active recording
enableTranscription: boolean
isTranscribing: boolean
transcriptionHistory: Array<{
sender: string
message: string
id: string
}>
meetingToken: string | null
isOwner: boolean
} }
> >
@ -30,15 +39,40 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
} }
getDefaultProps(): IVideoChatShape["props"] { getDefaultProps(): IVideoChatShape["props"] {
return { const props = {
roomUrl: null, roomUrl: null,
w: 800, w: 800,
h: 600, h: 600,
allowCamera: false, allowCamera: false,
allowMicrophone: false, allowMicrophone: false,
enableRecording: true, enableRecording: true,
recordingId: null recordingId: null,
enableTranscription: true,
isTranscribing: false,
transcriptionHistory: [],
meetingToken: null,
isOwner: false
};
console.log('🔧 getDefaultProps called, returning:', props);
return props;
}
async generateMeetingToken(roomName: string, isOwner: boolean = false) {
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) {
throw new Error('Daily.co API key not configured');
} }
if (!workerUrl) {
throw new Error('Worker URL is not configured');
}
// For now, let's skip token generation and use a simpler approach
// We'll use the room URL directly and handle owner permissions differently
console.log('Skipping meeting token generation for now');
return `token_${roomName}_${Date.now()}`;
} }
async ensureRoomExists(shape: IVideoChatShape) { async ensureRoomExists(shape: IVideoChatShape) {
@ -50,8 +84,9 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
// Try to get existing room URL from localStorage first // Try to get existing room URL from localStorage first
const storageKey = `videoChat_room_${boardId}`; const storageKey = `videoChat_room_${boardId}`;
const existingRoomUrl = localStorage.getItem(storageKey); const existingRoomUrl = localStorage.getItem(storageKey);
const existingToken = localStorage.getItem(`${storageKey}_token`);
if (existingRoomUrl && existingRoomUrl !== 'undefined') { if (existingRoomUrl && existingRoomUrl !== 'undefined' && existingToken) {
console.log("Using existing room from storage:", existingRoomUrl); console.log("Using existing room from storage:", existingRoomUrl);
await this.editor.updateShape<IVideoChatShape>({ await this.editor.updateShape<IVideoChatShape>({
id: shape.id, id: shape.id,
@ -59,14 +94,17 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
props: { props: {
...shape.props, ...shape.props,
roomUrl: existingRoomUrl, roomUrl: existingRoomUrl,
meetingToken: existingToken,
isOwner: true, // Assume the creator is the owner
}, },
}); });
return; return;
} }
if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined') { if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined' && shape.props.meetingToken) {
console.log("Room already exists:", shape.props.roomUrl); console.log("Room already exists:", shape.props.roomUrl);
localStorage.setItem(storageKey, shape.props.roomUrl); localStorage.setItem(storageKey, shape.props.roomUrl);
localStorage.setItem(`${storageKey}_token`, shape.props.meetingToken);
return; return;
} }
@ -107,7 +145,11 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
format: "mp4", format: "mp4",
mode: "audio-only" mode: "audio-only"
}, },
auto_start_transcription: true, // Transcription settings
transcription: {
enabled: true,
auto_start: false
},
recordings_template: "{room_name}/audio-{epoch_time}.mp4" recordings_template: "{room_name}/audio-{epoch_time}.mp4"
} }
}) })
@ -123,11 +165,18 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
if (!url) throw new Error("Room URL is missing") if (!url) throw new Error("Room URL is missing")
// Store the room URL in localStorage // Generate meeting token for the owner
// First ensure the room exists, then generate token
const meetingToken = await this.generateMeetingToken(roomName, true);
// Store the room URL and token in localStorage
localStorage.setItem(storageKey, url); localStorage.setItem(storageKey, url);
localStorage.setItem(`${storageKey}_token`, meetingToken);
console.log("Room created successfully:", url) console.log("Room created successfully:", url)
console.log("Updating shape with new URL") console.log("Meeting token generated:", meetingToken)
console.log("Updating shape with new URL and token")
console.log("Setting isOwner to true")
await this.editor.updateShape<IVideoChatShape>({ await this.editor.updateShape<IVideoChatShape>({
id: shape.id, id: shape.id,
@ -135,10 +184,14 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
props: { props: {
...shape.props, ...shape.props,
roomUrl: url, roomUrl: url,
meetingToken: meetingToken,
isOwner: true,
}, },
}) })
console.log("Shape updated:", this.editor.getShape(shape.id)) console.log("Shape updated:", this.editor.getShape(shape.id))
const updatedShape = this.editor.getShape(shape.id) as IVideoChatShape;
console.log("Updated shape isOwner:", updatedShape?.props.isOwner)
} catch (error) { } catch (error) {
console.error("Error in ensureRoomExists:", error) console.error("Error in ensureRoomExists:", error)
throw error throw error
@ -214,6 +267,151 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
} }
} }
async startTranscription(shape: IVideoChatShape) {
console.log('🎤 startTranscription method called');
console.log('Shape props:', shape.props);
console.log('Room URL:', shape.props.roomUrl);
console.log('Is owner:', shape.props.isOwner);
if (!shape.props.roomUrl || !shape.props.isOwner) {
console.log('❌ Early return - missing roomUrl or not owner');
console.log('roomUrl exists:', !!shape.props.roomUrl);
console.log('isOwner:', shape.props.isOwner);
return;
}
try {
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
console.log('🔧 Environment variables:');
console.log('Worker URL:', workerUrl);
console.log('API Key exists:', !!apiKey);
// Extract room name from URL
const roomName = shape.props.roomUrl.split('/').pop();
console.log('📝 Extracted room name:', roomName);
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
console.log('🌐 Making API request to start transcription...');
console.log('Request URL:', `${workerUrl}/daily/rooms/${roomName}/start-transcription`);
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/start-transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
});
console.log('📡 Response status:', response.status);
console.log('📡 Response ok:', response.ok);
if (!response.ok) {
const error = await response.json();
console.error('❌ API error response:', error);
throw new Error(`Failed to start transcription: ${JSON.stringify(error)}`);
}
console.log('✅ API call successful, updating shape...');
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: true,
}
});
console.log('✅ Shape updated with isTranscribing: true');
} catch (error) {
console.error('❌ Error starting transcription:', error);
throw error;
}
}
async stopTranscription(shape: IVideoChatShape) {
console.log('🛑 stopTranscription method called');
console.log('Shape props:', shape.props);
if (!shape.props.roomUrl || !shape.props.isOwner) {
console.log('❌ Early return - missing roomUrl or not owner');
return;
}
try {
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
// Extract room name from URL
const roomName = shape.props.roomUrl.split('/').pop();
console.log('📝 Extracted room name:', roomName);
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
console.log('🌐 Making API request to stop transcription...');
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/stop-transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
});
console.log('📡 Response status:', response.status);
if (!response.ok) {
const error = await response.json();
console.error('❌ API error response:', error);
throw new Error(`Failed to stop transcription: ${JSON.stringify(error)}`);
}
console.log('✅ API call successful, updating shape...');
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: false,
}
});
console.log('✅ Shape updated with isTranscribing: false');
} catch (error) {
console.error('❌ Error stopping transcription:', error);
throw error;
}
}
addTranscriptionMessage(shape: IVideoChatShape, sender: string, message: string) {
console.log('📝 addTranscriptionMessage called');
console.log('Sender:', sender);
console.log('Message:', message);
console.log('Current transcription history length:', shape.props.transcriptionHistory.length);
const newMessage = {
sender,
message,
id: `${Date.now()}_${Math.random()}`
};
console.log('📝 Adding new message:', newMessage);
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
transcriptionHistory: [...shape.props.transcriptionHistory, newMessage]
}
});
console.log('✅ Transcription message added to shape');
}
component(shape: IVideoChatShape) { component(shape: IVideoChatShape) {
const [hasPermissions, setHasPermissions] = useState(false) const [hasPermissions, setHasPermissions] = useState(false)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
@ -344,6 +542,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
}`} }`}
></iframe> ></iframe>
{/* Recording Button */}
{shape.props.enableRecording && ( {shape.props.enableRecording && (
<button <button
onClick={async () => { onClick={async () => {
@ -373,6 +572,105 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
</button> </button>
)} )}
{/* Test Button - Always visible for debugging */}
<button
onClick={() => {
console.log('🧪 Test button clicked!');
console.log('Shape props:', shape.props);
alert('Test button clicked! Check console for details.');
}}
style={{
position: "absolute",
top: "8px",
left: "8px",
padding: "4px 8px",
background: "#ffff00",
border: "1px solid #000",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1000,
fontSize: "10px",
}}
>
TEST
</button>
{/* Transcription Button - Only for owners */}
{(() => {
console.log('🔍 Checking transcription button conditions:');
console.log('enableTranscription:', shape.props.enableTranscription);
console.log('isOwner:', shape.props.isOwner);
console.log('Button should render:', shape.props.enableTranscription && shape.props.isOwner);
return shape.props.enableTranscription && shape.props.isOwner;
})() && (
<button
onClick={async () => {
console.log('🚀 Transcription button clicked!');
console.log('Current transcription state:', shape.props.isTranscribing);
console.log('Shape props:', shape.props);
try {
if (shape.props.isTranscribing) {
console.log('🛑 Stopping transcription...');
await this.stopTranscription(shape);
console.log('✅ Transcription stopped successfully');
} else {
console.log('🎤 Starting transcription...');
await this.startTranscription(shape);
console.log('✅ Transcription started successfully');
}
} catch (err) {
console.error('❌ Transcription error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: shape.props.enableRecording ? "120px" : "8px",
padding: "4px 8px",
background: shape.props.isTranscribing ? "#44ff44" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.isTranscribing ? "Stop Transcription" : "Start Transcription"}
</button>
)}
{/* Transcription History */}
{shape.props.transcriptionHistory.length > 0 && (
<div
style={{
position: "absolute",
bottom: "40px",
left: "8px",
right: "8px",
maxHeight: "200px",
overflowY: "auto",
background: "rgba(255, 255, 255, 0.95)",
borderRadius: "4px",
padding: "8px",
fontSize: "12px",
zIndex: 1,
border: "1px solid #ccc",
}}
>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
Live Transcription:
</div>
{shape.props.transcriptionHistory.slice(-10).map((msg) => (
<div key={msg.id} style={{ marginBottom: "2px" }}>
<span style={{ fontWeight: "bold", color: "#666" }}>
{msg.sender}:
</span>{" "}
<span>{msg.message}</span>
</div>
))}
</div>
)}
<p <p
style={{ style={{
position: "absolute", position: "absolute",
@ -390,6 +688,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
}} }}
> >
url: {roomUrl} url: {roomUrl}
{shape.props.isOwner && " (Owner)"}
</p> </p>
</div> </div>
) )

View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class SharedPianoTool extends BaseBoxShapeTool {
static override id = "SharedPiano"
shapeType = "SharedPiano"
override initial = "idle"
}

View File

@ -168,6 +168,14 @@ export function CustomToolbar() {
isSelected={tools["Prompt"].id === editor.getCurrentToolId()} isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/> />
)} )}
{tools["SharedPiano"] && (
<TldrawUiMenuItem
{...tools["SharedPiano"]}
icon="music"
label="Shared Piano"
isSelected={tools["SharedPiano"].id === editor.getCurrentToolId()}
/>
)}
</DefaultToolbar> </DefaultToolbar>
</div> </div>
) )

View File

@ -151,6 +151,15 @@ export const overrides: TLUiOverrides = {
readonlyOk: true, readonlyOk: true,
onSelect: () => editor.setCurrentTool("Prompt"), onSelect: () => editor.setCurrentTool("Prompt"),
}, },
SharedPiano: {
id: "SharedPiano",
icon: "music",
label: "Shared Piano",
type: "SharedPiano",
kbd: "alt+p",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("SharedPiano"),
},
gesture: { gesture: {
id: "gesture", id: "gesture",
icon: "draw", icon: "draw",

View File

@ -19,6 +19,7 @@ import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import { SlideShape } from "@/shapes/SlideShapeUtil" import { SlideShape } from "@/shapes/SlideShapeUtil"
import { PromptShape } from "@/shapes/PromptShapeUtil" import { PromptShape } from "@/shapes/PromptShapeUtil"
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
// add custom shapes and bindings here if needed: // add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({ export const customSchema = createTLSchema({
@ -52,6 +53,10 @@ export const customSchema = createTLSchema({
props: PromptShape.props, props: PromptShape.props,
migrations: PromptShape.migrations, migrations: PromptShape.migrations,
}, },
SharedPiano: {
props: SharedPianoShape.props,
migrations: SharedPianoShape.migrations,
},
}, },
bindings: defaultBindingSchemas, bindings: defaultBindingSchemas,
}) })

View File

@ -162,6 +162,50 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
} }
}) })
.post("/daily/tokens", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const body = await req.json()
const response = await fetch('https://api.daily.co/v1/meeting-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
room_name: body.room_name,
properties: body.properties
})
})
if (!response.ok) {
const error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// Add new transcription endpoints // Add new transcription endpoints
.post("/daily/rooms/:roomName/start-transcription", async (req) => { .post("/daily/rooms/:roomName/start-transcription", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1] const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
@ -325,6 +369,88 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
} }
}) })
// Recording endpoints
.post("/daily/recordings/start", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const body = await req.json()
const response = await fetch('https://api.daily.co/v1/recordings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
.post("/daily/recordings/:recordingId/stop", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const { recordingId } = req.params
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const response = await fetch(`https://api.daily.co/v1/recordings/${recordingId}/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
})
if (!response.ok) {
const error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
async function backupAllBoards(env: Environment) { async function backupAllBoards(env: Environment) {
try { try {
// List all room files from TLDRAW_BUCKET // List all room files from TLDRAW_BUCKET