@sansavision/create-pulse 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +58 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +115 -0
  4. package/package.json +32 -0
  5. package/src/index.ts +114 -0
  6. package/templates/react-all-features/index.html +12 -0
  7. package/templates/react-all-features/package-lock.json +2683 -0
  8. package/templates/react-all-features/package.json +27 -0
  9. package/templates/react-all-features/src/App.tsx +201 -0
  10. package/templates/react-all-features/src/components/VideoPlayer.tsx +113 -0
  11. package/templates/react-all-features/src/hooks/usePulse.ts +113 -0
  12. package/templates/react-all-features/src/index.css +38 -0
  13. package/templates/react-all-features/src/main.tsx +10 -0
  14. package/templates/react-all-features/tailwind.config.js +55 -0
  15. package/templates/react-all-features/tsconfig.json +25 -0
  16. package/templates/react-all-features/tsconfig.node.json +11 -0
  17. package/templates/react-all-features/tsconfig.node.tsbuildinfo +1 -0
  18. package/templates/react-all-features/tsconfig.tsbuildinfo +1 -0
  19. package/templates/react-all-features/vite.config.d.ts +2 -0
  20. package/templates/react-all-features/vite.config.js +6 -0
  21. package/templates/react-all-features/vite.config.ts +7 -0
  22. package/templates/react-watch-together/index.html +12 -0
  23. package/templates/react-watch-together/package-lock.json +2683 -0
  24. package/templates/react-watch-together/package.json +27 -0
  25. package/templates/react-watch-together/src/App.tsx +165 -0
  26. package/templates/react-watch-together/src/components/VideoPlayer.tsx +113 -0
  27. package/templates/react-watch-together/src/hooks/usePulse.ts +113 -0
  28. package/templates/react-watch-together/src/index.css +38 -0
  29. package/templates/react-watch-together/src/main.tsx +10 -0
  30. package/templates/react-watch-together/tailwind.config.js +55 -0
  31. package/templates/react-watch-together/tsconfig.json +25 -0
  32. package/templates/react-watch-together/tsconfig.node.json +11 -0
  33. package/templates/react-watch-together/tsconfig.node.tsbuildinfo +1 -0
  34. package/templates/react-watch-together/tsconfig.tsbuildinfo +1 -0
  35. package/templates/react-watch-together/vite.config.d.ts +2 -0
  36. package/templates/react-watch-together/vite.config.js +6 -0
  37. package/templates/react-watch-together/vite.config.ts +7 -0
  38. package/tsconfig.json +13 -0
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "pulse-react-watch-together",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@sansavision/pulse-sdk": "latest",
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1",
15
+ "lucide-react": "^0.412.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.3.3",
19
+ "@types/react-dom": "^18.3.0",
20
+ "@vitejs/plugin-react": "^4.3.1",
21
+ "autoprefixer": "^10.4.19",
22
+ "postcss": "^8.4.39",
23
+ "tailwindcss": "^3.4.6",
24
+ "typescript": "^5.5.3",
25
+ "vite": "^5.3.4"
26
+ }
27
+ }
@@ -0,0 +1,165 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { usePulse } from './hooks/usePulse';
3
+ import { VideoPlayer } from './components/VideoPlayer';
4
+ import { Activity, Settings2, Users } from 'lucide-react';
5
+
6
+ const VIDEO_URL = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
7
+ const STREAM_NAME = "watch-together-demo";
8
+
9
+ export default function App() {
10
+ const { isConnected, isConnecting, metrics, useChannel } = usePulse({
11
+ relayUrl: 'ws://localhost:4001',
12
+ apiKey: 'demo'
13
+ });
14
+
15
+ const [hostTime, setHostTime] = useState(0);
16
+ const [peerTime, setPeerTime] = useState(0);
17
+ const [isPlaying, setIsPlaying] = useState(false);
18
+ const [events, setEvents] = useState<{ id: number, action: string, time: string }[]>([]);
19
+
20
+ const logEvent = useCallback((action: string) => {
21
+ setEvents(prev => [...prev.slice(-4), {
22
+ id: Date.now(),
23
+ action,
24
+ time: new Date().toISOString().substring(11, 23)
25
+ }]);
26
+ }, []);
27
+
28
+ const handleSyncMessage = useCallback((msg: any) => {
29
+ logEvent(`RECV: ${msg.action}`);
30
+
31
+ switch (msg.action) {
32
+ case 'play':
33
+ setIsPlaying(true);
34
+ if (msg.time !== undefined) setPeerTime(msg.time);
35
+ break;
36
+ case 'pause':
37
+ setIsPlaying(false);
38
+ if (msg.time !== undefined) setPeerTime(msg.time);
39
+ break;
40
+ case 'seek':
41
+ if (msg.time !== undefined) setPeerTime(msg.time);
42
+ break;
43
+ case 'tick':
44
+ if (msg.time !== undefined) setPeerTime(msg.time);
45
+ break;
46
+ }
47
+ }, [logEvent]);
48
+
49
+ // Connect to the synchronized channel
50
+ const { broadcast } = useChannel(STREAM_NAME, handleSyncMessage);
51
+
52
+ const broadcastAction = useCallback((action: string, time: number) => {
53
+ logEvent(`SEND: ${action}`);
54
+ broadcast({ action, time, ts: Date.now() });
55
+ }, [broadcast, logEvent]);
56
+
57
+ return (
58
+ <div className="max-w-6xl mx-auto p-6 space-y-8">
59
+ {/* Header */}
60
+ <header className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 pb-6 border-b">
61
+ <div className="flex items-center gap-3">
62
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
63
+ <Activity className="text-white w-6 h-6" />
64
+ </div>
65
+ <div>
66
+ <h1 className="text-2xl font-bold tracking-tight">Watch Together</h1>
67
+ <p className="text-sm text-muted-foreground">Synchronized React Video Player via Pulse Relay</p>
68
+ </div>
69
+ </div>
70
+
71
+ <div className="flex gap-4 text-sm font-mono items-center bg-card px-4 py-2 rounded-lg border shadow-sm">
72
+ <div className="flex items-center gap-2">
73
+ <div className={`w-2.5 h-2.5 rounded-full ${isConnected ? 'bg-emerald-500' : isConnecting ? 'bg-amber-500' : 'bg-red-500'}`} />
74
+ <span className={isConnected ? 'text-emerald-500' : 'text-muted-foreground'}>
75
+ {isConnected ? 'Relay Connected' : isConnecting ? 'Connecting...' : 'Disconnected'}
76
+ </span>
77
+ </div>
78
+ {metrics && (
79
+ <div className="flex items-center gap-4 border-l pl-4 border-border/50 text-muted-foreground hidden sm:flex">
80
+ <span>RTT: <span className="text-foreground">{Math.round(metrics.rtt)}ms</span></span>
81
+ <span>Loss: <span className="text-foreground">{metrics.packetLoss}%</span></span>
82
+ </div>
83
+ )}
84
+ </div>
85
+ </header>
86
+
87
+ <main className="grid grid-cols-1 lg:grid-cols-3 gap-8">
88
+
89
+ {/* Main Viewing Area */}
90
+ <div className="lg:col-span-2 space-y-8">
91
+ <VideoPlayer
92
+ role="host"
93
+ label="Host Player (You control this)"
94
+ src={VIDEO_URL}
95
+ currentTime={hostTime}
96
+ isPlaying={isPlaying}
97
+ onPlay={() => { setIsPlaying(true); broadcastAction('play', hostTime); }}
98
+ onPause={() => { setIsPlaying(false); broadcastAction('pause', hostTime); }}
99
+ onSeek={(t) => { setHostTime(t); broadcastAction('seek', t); }}
100
+ onTick={(t) => { setHostTime(t); broadcastAction('tick', t); }}
101
+ accentColor="#8B5CF6" // Purple
102
+ />
103
+
104
+ <VideoPlayer
105
+ role="peer"
106
+ label="Peer Viewer (Remote representation)"
107
+ src={VIDEO_URL}
108
+ currentTime={peerTime}
109
+ isPlaying={isPlaying}
110
+ accentColor="#06B6D4" // Cyan
111
+ />
112
+ </div>
113
+
114
+ {/* Sidebar Debug Control / Info */}
115
+ <aside className="space-y-6">
116
+ <div className="p-5 rounded-xl border bg-card shadow-sm space-y-4">
117
+ <div className="flex items-center gap-2 font-medium text-foreground tracking-tight border-b pb-3">
118
+ <Settings2 className="w-4 h-4 text-primary" />
119
+ Sync Telemetry
120
+ </div>
121
+
122
+ <div className="space-y-3 font-mono text-xs">
123
+ <div className="flex justify-between items-center p-2 rounded bg-background border">
124
+ <span className="text-muted-foreground">Host Time</span>
125
+ <span className="text-foreground">{hostTime.toFixed(2)}s</span>
126
+ </div>
127
+ <div className="flex justify-between items-center p-2 rounded bg-background border">
128
+ <span className="text-muted-foreground">Peer Time</span>
129
+ <span className="text-foreground">{peerTime.toFixed(2)}s</span>
130
+ </div>
131
+ <div className="flex justify-between items-center p-2 rounded bg-background border">
132
+ <span className="text-muted-foreground">Drift</span>
133
+ <span className={`font-semibold ${Math.abs(hostTime - peerTime) > 0.15 ? 'text-amber-500' : 'text-emerald-500'}`}>
134
+ {Math.abs(hostTime - peerTime).toFixed(3)}s
135
+ </span>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <div className="p-5 rounded-xl border bg-card shadow-sm space-y-4">
141
+ <div className="flex items-center gap-2 font-medium text-foreground tracking-tight border-b pb-3">
142
+ <Users className="w-4 h-4 text-primary" />
143
+ Pulse Event Log
144
+ </div>
145
+
146
+ <div className="flex flex-col gap-2 font-mono text-[11px]">
147
+ {events.length === 0 ? (
148
+ <div className="text-muted-foreground italic p-2">No events yet. Press play!</div>
149
+ ) : events.map(ev => (
150
+ <div key={ev.id} className="flex gap-3 items-center p-2 rounded bg-background border">
151
+ <span className="text-muted-foreground shrink-0">{ev.time}</span>
152
+ <span className={`px-1.5 py-0.5 rounded font-bold uppercase tracking-wider ${ev.action.includes('SEND') ? 'bg-primary/20 text-primary' : 'bg-cyan-500/20 text-cyan-400'}`}>
153
+ {ev.action.split(':')[0]}
154
+ </span>
155
+ <span className="text-foreground truncate opacity-80">{ev.action.split(':')[1]}</span>
156
+ </div>
157
+ ))}
158
+ </div>
159
+ </div>
160
+ </aside>
161
+
162
+ </main>
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1,113 @@
1
+ import { useRef, useEffect } from 'react';
2
+
3
+ interface VideoPlayerProps {
4
+ label: string;
5
+ role: 'host' | 'peer';
6
+ src: string;
7
+ currentTime: number;
8
+ isPlaying: boolean;
9
+ onPlay?: () => void;
10
+ onPause?: () => void;
11
+ onSeek?: (time: number) => void;
12
+ onTick?: (time: number) => void;
13
+ accentColor: string;
14
+ }
15
+
16
+ export function VideoPlayer({
17
+ label,
18
+ role,
19
+ src,
20
+ currentTime,
21
+ isPlaying,
22
+ onPlay,
23
+ onPause,
24
+ onSeek,
25
+ onTick,
26
+ accentColor
27
+ }: VideoPlayerProps) {
28
+ const videoRef = useRef<HTMLVideoElement>(null);
29
+
30
+ // Handle incoming remote state changes
31
+ useEffect(() => {
32
+ const video = videoRef.current;
33
+ if (!video) return;
34
+
35
+ if (role === 'peer') {
36
+ const drift = Math.abs(video.currentTime - currentTime);
37
+ // Only force seek if drift > 150ms to avoid audio stuttering
38
+ if (drift > 0.150) {
39
+ video.currentTime = currentTime;
40
+ }
41
+
42
+ if (isPlaying && video.paused) {
43
+ video.play().catch(console.error);
44
+ } else if (!isPlaying && !video.paused) {
45
+ video.pause();
46
+ }
47
+ }
48
+ }, [currentTime, isPlaying, role]);
49
+
50
+ // Handle local user interactions (Host only)
51
+ useEffect(() => {
52
+ const video = videoRef.current;
53
+ if (!video || role !== 'host') return;
54
+
55
+ const handlePlay = () => onPlay?.();
56
+ const handlePause = () => onPause?.();
57
+ const handleSeek = () => {
58
+ // Small timeout allows the video time to update before broadcasting
59
+ setTimeout(() => onSeek?.(video.currentTime), 0);
60
+ };
61
+
62
+ video.addEventListener('play', handlePlay);
63
+ video.addEventListener('pause', handlePause);
64
+ video.addEventListener('seeked', handleSeek);
65
+
66
+ return () => {
67
+ video.removeEventListener('play', handlePlay);
68
+ video.removeEventListener('pause', handlePause);
69
+ video.removeEventListener('seeked', handleSeek);
70
+ };
71
+ }, [role, onPlay, onPause, onSeek]);
72
+
73
+ // Periodic tick for Host to sync peers
74
+ useEffect(() => {
75
+ const video = videoRef.current;
76
+ if (!video || role !== 'host' || !onTick) return;
77
+
78
+ const interval = setInterval(() => {
79
+ if (!video.paused) {
80
+ onTick(video.currentTime);
81
+ }
82
+ }, 500);
83
+
84
+ return () => clearInterval(interval);
85
+ }, [role, onTick]);
86
+
87
+ return (
88
+ <div className="flex flex-col gap-2">
89
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
90
+ <div
91
+ className="w-2.5 h-2.5 rounded-full"
92
+ style={{ backgroundColor: accentColor }}
93
+ />
94
+ {label}
95
+ {role === 'peer' && (
96
+ <span className="ml-auto px-2 mx-2 py-0.5 rounded-full border border-cyan-500/20 bg-cyan-500/10 text-cyan-400 text-xs font-mono">
97
+ Synced via Pulse
98
+ </span>
99
+ )}
100
+ </div>
101
+
102
+ <div className="relative rounded-lg overflow-hidden border bg-black shadow-sm h-64 sm:h-80 md:h-96">
103
+ <video
104
+ ref={videoRef}
105
+ controls={role === 'host'}
106
+ className={`w-full h-full object-cover ${role === 'peer' ? 'pointer-events-none' : ''}`}
107
+ src={src}
108
+ crossOrigin="anonymous"
109
+ />
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,113 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { Pulse, PulseConnection, PulseStream, ConnectionMetrics } from '@sansavision/pulse-sdk';
3
+
4
+ interface UsePulseOptions {
5
+ relayUrl?: string;
6
+ apiKey?: string;
7
+ }
8
+
9
+ export function usePulse(options: UsePulseOptions = {}) {
10
+ const {
11
+ relayUrl = 'ws://localhost:4001',
12
+ apiKey = 'demo'
13
+ } = options;
14
+
15
+ const [isConnected, setIsConnected] = useState(false);
16
+ const [metrics, setMetrics] = useState<ConnectionMetrics | null>(null);
17
+ const [error, setError] = useState<Error | null>(null);
18
+ const [isConnecting, setIsConnecting] = useState(true);
19
+
20
+ const connectionRef = useRef<PulseConnection | null>(null);
21
+
22
+ // Establish connection on mount
23
+ useEffect(() => {
24
+ let mounted = true;
25
+ const pulse = new Pulse({ apiKey });
26
+
27
+ setIsConnecting(true);
28
+
29
+ pulse.connect(relayUrl)
30
+ .then(conn => {
31
+ if (!mounted) {
32
+ conn.disconnect();
33
+ return;
34
+ }
35
+
36
+ connectionRef.current = conn;
37
+ setIsConnected(true);
38
+ setIsConnecting(false);
39
+
40
+ conn.on('metrics', (m) => {
41
+ if (mounted) setMetrics(m);
42
+ });
43
+
44
+ conn.on('disconnect', () => {
45
+ if (mounted) setIsConnected(false);
46
+ });
47
+ })
48
+ .catch(err => {
49
+ if (mounted) {
50
+ setError(err);
51
+ setIsConnecting(false);
52
+ }
53
+ });
54
+
55
+ return () => {
56
+ mounted = false;
57
+ if (connectionRef.current) {
58
+ connectionRef.current.disconnect();
59
+ connectionRef.current = null;
60
+ }
61
+ };
62
+ }, [relayUrl, apiKey]);
63
+
64
+ // Helper to open a pub/sub or RPC stream and manage its lifecycle
65
+ const useChannel = useCallback((channelName: string, onMessage?: (data: any) => void) => {
66
+ const [stream, setStream] = useState<PulseStream | null>(null);
67
+
68
+ useEffect(() => {
69
+ if (!isConnected || !connectionRef.current) return;
70
+
71
+ const newStream = connectionRef.current.stream(channelName, {
72
+ qos: 'interactive-data' // Best for low-latency sync
73
+ });
74
+
75
+ setStream(newStream);
76
+
77
+ const handleData = (buf: Uint8Array) => {
78
+ if (!onMessage) return;
79
+ try {
80
+ const str = new TextDecoder().decode(buf);
81
+ onMessage(JSON.parse(str));
82
+ } catch (err) {
83
+ console.error("Failed to decode channel message", err);
84
+ }
85
+ };
86
+
87
+ newStream.on('data', handleData);
88
+
89
+ return () => {
90
+ newStream.off('data', handleData);
91
+ newStream.close();
92
+ };
93
+ }, [isConnected, channelName, onMessage]);
94
+
95
+ const broadcast = useCallback((data: any) => {
96
+ if (stream) {
97
+ const payload = new TextEncoder().encode(JSON.stringify(data));
98
+ stream.send(payload);
99
+ }
100
+ }, [stream]);
101
+
102
+ return { stream, broadcast };
103
+ }, [isConnected]);
104
+
105
+ return {
106
+ isConnected,
107
+ isConnecting,
108
+ metrics,
109
+ error,
110
+ connection: connectionRef.current,
111
+ useChannel
112
+ };
113
+ }
@@ -0,0 +1,38 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 240 10% 4%;
8
+ --foreground: 0 0% 98%;
9
+ --card: 240 10% 6%;
10
+ --card-foreground: 0 0% 98%;
11
+ --popover: 240 10% 4%;
12
+ --popover-foreground: 0 0% 98%;
13
+ --primary: 262 83% 58%;
14
+ --primary-foreground: 210 40% 98%;
15
+ --secondary: 240 4% 16%;
16
+ --secondary-foreground: 0 0% 98%;
17
+ --muted: 240 5% 26%;
18
+ --muted-foreground: 240 5% 65%;
19
+ --accent: 262 83% 58%;
20
+ --accent-foreground: 0 0% 98%;
21
+ --destructive: 0 62.8% 30.6%;
22
+ --destructive-foreground: 0 0% 98%;
23
+ --border: 240 6% 15%;
24
+ --input: 240 6% 15%;
25
+ --ring: 240 5% 65%;
26
+ --radius: 0.5rem;
27
+ }
28
+ }
29
+
30
+ @layer base {
31
+ * {
32
+ @apply border-border;
33
+ }
34
+ body {
35
+ @apply bg-background text-foreground bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/20 via-background to-background min-h-screen text-sm;
36
+ font-feature-settings: "rlig" 1, "calt" 1;
37
+ }
38
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.tsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
@@ -0,0 +1,55 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: ["class"],
4
+ content: [
5
+ './pages/**/*.{ts,tsx}',
6
+ './components/**/*.{ts,tsx}',
7
+ './app/**/*.{ts,tsx}',
8
+ './src/**/*.{ts,tsx}',
9
+ ],
10
+ theme: {
11
+ extend: {
12
+ colors: {
13
+ border: "hsl(var(--border))",
14
+ input: "hsl(var(--input))",
15
+ ring: "hsl(var(--ring))",
16
+ background: "hsl(var(--background))",
17
+ foreground: "hsl(var(--foreground))",
18
+ primary: {
19
+ DEFAULT: "hsl(var(--primary))",
20
+ foreground: "hsl(var(--primary-foreground))",
21
+ },
22
+ secondary: {
23
+ DEFAULT: "hsl(var(--secondary))",
24
+ foreground: "hsl(var(--secondary-foreground))",
25
+ },
26
+ destructive: {
27
+ DEFAULT: "hsl(var(--destructive))",
28
+ foreground: "hsl(var(--destructive-foreground))",
29
+ },
30
+ muted: {
31
+ DEFAULT: "hsl(var(--muted))",
32
+ foreground: "hsl(var(--muted-foreground))",
33
+ },
34
+ accent: {
35
+ DEFAULT: "hsl(var(--accent))",
36
+ foreground: "hsl(var(--accent-foreground))",
37
+ },
38
+ popover: {
39
+ DEFAULT: "hsl(var(--popover))",
40
+ foreground: "hsl(var(--popover-foreground))",
41
+ },
42
+ card: {
43
+ DEFAULT: "hsl(var(--card))",
44
+ foreground: "hsl(var(--card-foreground))",
45
+ },
46
+ },
47
+ borderRadius: {
48
+ lg: "var(--radius)",
49
+ md: "calc(var(--radius) - 2px)",
50
+ sm: "calc(var(--radius) - 4px)",
51
+ },
52
+ },
53
+ },
54
+ plugins: [],
55
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noFallthroughCasesInSwitch": true
22
+ },
23
+ "include": ["src"],
24
+ "references": [{ "path": "./tsconfig.node.json" }]
25
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true
9
+ },
10
+ "include": ["vite.config.ts"]
11
+ }