diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..d997789 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +#VITE_CHROMECAST_APP_ID=0D9CC4B6 +VITE_CHROMECAST_APP_ID=AB5F6AA2 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..8153404 --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_CHROMECAST_APP_ID=0D9CC4B6 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a16850..95acbec 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +maps public/maps/* # Editor directories and files diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..860aeff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:18 AS build + +WORKDIR /srv + +COPY ./package.json ./package-lock.json /srv/ +RUN npm install +COPY tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts /srv/ + +COPY src /srv/src +COPY public /srv/public +COPY map /srv/map +COPY ./index.html /srv/ + +RUN npm run build + +FROM nginx:latest + +COPY --from=build /srv/dist /usr/share/nginx/html +COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 \ No newline at end of file diff --git a/README.md b/README.md index 33895ab..714fc1c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ -# Vue 3 + TypeScript + Vite +# Battle caster -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/docker-compose.yml b/docker-compose.yml index 084f69e..dd785e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,4 +9,5 @@ services: - "8080:80" volumes: - ./public/maps:/usr/share/nginx/html/maps + - ./public/soundfx:/usr/share/nginx/html/soundfx - ./nginx/default.conf:/etc/nginx/conf.d/default.conf diff --git a/index.html b/index.html index 7145fd6..b356270 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + Vue + TS + BattleCaster
diff --git a/map/index.html b/map/index.html index 9b142c1..0687c77 100644 --- a/map/index.html +++ b/map/index.html @@ -4,7 +4,7 @@ - Vite + Vue + TS + MAP | BattleCaster
diff --git a/nginx/default.conf b/nginx/default.conf index 047dfe6..266d0b4 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -5,10 +5,23 @@ server { root /usr/share/nginx/html; location / { - index index.html index.htm; - } - - location /maps { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS'; + add_header 'Access-Control-Allow-Private-Network' 'true'; + add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; + add_header 'Access-Control-Max-Age' 1728000; + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS'; + add_header 'Access-Control-Allow-Private-Network' 'true'; + add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + add_header 'Content-Length' 0; + return 204; + } autoindex on; autoindex_format json; } diff --git a/public/soundfx/sword slash/52752_438046_438045_Medieval_Weapons_-_Metal_Weapon_Swing-_Whoosh_normal.aac b/public/soundfx/sword slash/52752_438046_438045_Medieval_Weapons_-_Metal_Weapon_Swing-_Whoosh_normal.aac new file mode 100644 index 0000000..9e10240 Binary files /dev/null and b/public/soundfx/sword slash/52752_438046_438045_Medieval_Weapons_-_Metal_Weapon_Swing-_Whoosh_normal.aac differ diff --git a/public/soundfx/sword slash/52753_438048_438047_Medieval_Weapons_-_Metal_Weapon_Swing_normal.aac b/public/soundfx/sword slash/52753_438048_438047_Medieval_Weapons_-_Metal_Weapon_Swing_normal.aac new file mode 100644 index 0000000..a9c501f Binary files /dev/null and b/public/soundfx/sword slash/52753_438048_438047_Medieval_Weapons_-_Metal_Weapon_Swing_normal.aac differ diff --git a/public/soundfx/sword slash/762456_762424_Shinobi_Vol_2_-_Sword_Slash_01_-_MASTERED_-_2496.aac b/public/soundfx/sword slash/762456_762424_Shinobi_Vol_2_-_Sword_Slash_01_-_MASTERED_-_2496.aac new file mode 100644 index 0000000..ec238cb Binary files /dev/null and b/public/soundfx/sword slash/762456_762424_Shinobi_Vol_2_-_Sword_Slash_01_-_MASTERED_-_2496.aac differ diff --git a/public/soundfx/sword slash/99157_719601_Throwing_Weapon_Hits_Body (1).aac b/public/soundfx/sword slash/99157_719601_Throwing_Weapon_Hits_Body (1).aac new file mode 100644 index 0000000..e074141 Binary files /dev/null and b/public/soundfx/sword slash/99157_719601_Throwing_Weapon_Hits_Body (1).aac differ diff --git a/public/soundfx/sword slash/99157_719601_Throwing_Weapon_Hits_Body.aac b/public/soundfx/sword slash/99157_719601_Throwing_Weapon_Hits_Body.aac new file mode 100644 index 0000000..e074141 Binary files /dev/null and b/public/soundfx/sword slash/99157_719601_Throwing_Weapon_Hits_Body.aac differ diff --git a/src/assets/main.css b/src/assets/main.css new file mode 100644 index 0000000..6c0055c --- /dev/null +++ b/src/assets/main.css @@ -0,0 +1,35 @@ +@import './theme.css'; + +@layer base { + + :root { + --border-radius-sm: 4px; + --border-radius: 6px; + --border-radius-lg: 8px; + --border-radius-xl: 12px; + + --s-0_5: 0.125rem; + --s-1: 0.25rem; + --s-2: 0.5rem; + --s-3: 0.75rem; + --s-4: 1rem; + --s-5: 1.25rem; + --s-6: 1.5rem; + --s-7: 1.75rem; + --s-8: 2rem; + --s-9: 2.25rem; + --s-10: 2.5rem; + --s-11: 2.75rem; + --s-12: 3rem; + } + + .frosted { + --color-bg: var(--color-theme-700); + --frosted-box-shadow: inset 10px -10px 30px 5px rgba(from var(--color-theme-400) r g b / 0.1); + --frosted-gradient: linear-gradient(15deg, var(--color-theme-700), var(--color-theme-800)); + border: 1px solid var(--color-contrast-50); + background: var(--frosted-gradient) var(--color-bg); + box-shadow: var(--frosted-box-shadow); + color: var(--contrast-100); + } +} diff --git a/src/assets/theme.css b/src/assets/theme.css new file mode 100644 index 0000000..60efc15 --- /dev/null +++ b/src/assets/theme.css @@ -0,0 +1,174 @@ +@layer base { + + + + :root { + --color-slate: #64748b; + --color-gray: #6b7280; + --color-zinc: #71717a; + --color-neutral: #737373; + --color-stone: #78716c; + --color-red: #ef4444; + --color-orange: #f97316; + --color-amber: #f59e0b; + --color-yellow: #eab308; + --color-lime: #84cc16; + --color-green: #22c55e; + --color-emerald: #10b981; + --color-teal: #14b8a6; + --color-cyan: #06b6d4; + --color-sky: #0ea5e9; + --color-blue: #3b82f6; + --color-indigo: #6366f1; + --color-violet: #8b5cf6; + --color-purple: #a855f7; + --color-fuchsia: #d946ef; + --color-pink: #ec4899; + --color-rose: #f43f5e; + } + + .theme { + --color-theme-50: color-mix(in oklab, var(--color-theme), #fff 90%); + --color-theme-100: color-mix(in oklab, var(--color-theme), #fff 80%); + --color-theme-200: color-mix(in oklab, var(--color-theme), #fff 60%); + --color-theme-300: color-mix(in oklab, var(--color-theme), #fff 40%); + --color-theme-400: color-mix(in oklab, var(--color-theme), #fff 20%); + --color-theme-500: var(--color-theme); + --color-theme-600: color-mix(in oklab, var(--color-theme), #000 20%); + --color-theme-700: color-mix(in oklab, var(--color-theme), #000 40%); + --color-theme-800: color-mix(in oklab, var(--color-theme), #000 60%); + --color-theme-900: color-mix(in oklab, var(--color-theme), #000 80%); + --color-theme-950: color-mix(in oklab, var(--color-theme), #000 90%); + } + + .contrast { + --color-contrast-base: rgb( + from var(--color-bg) + clamp(0, calc((r/255*0.2126 + g/255*0.7152 + b/255*0.0722 - 0.5) * -64000), 255) + clamp(0, calc((r/255*0.2126 + g/255*0.7152 + b/255*0.0722 - 0.5) * -64000), 255) + clamp(0, calc((r/255*0.2126 + g/255*0.7152 + b/255*0.0722 - 0.5) * -64000), 255) + ); + --color-contrast-50: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 5%) h calc(s * 1) l); + --color-contrast-100: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 10%) h calc(s * 1.5) l); + --color-contrast-200: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 20%) h calc(s * 2) l); + --color-contrast-300: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 30%) h calc(s * 3) l); + --color-contrast-400: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 40%) h calc(s * 4) l); + --color-contrast-500: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 50%) h calc(s * 5) l); + --color-contrast-600: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 60%) h calc(s*6) l); + --color-contrast-700: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 70%) h calc(s*7) l); + --color-contrast-800: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 80%) h calc(s*8) l); + --color-contrast-900: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 90%) h calc(s*9) l); + --color-contrast-950: hsl(from color-mix(in oklab, var(--color-bg), var(--color-contrast-base) 95%) h calc(s*9.5) l); + } + + .text-constrast-50 { + color: var(--color-contrast-50); + --color-text: var(--color-contrast-50); + } + + .text-constrast-100 { + color: var(--color-contrast-100); + --color-text: var(--color-contrast-100); + } + + .text-constrast-200 { + color: var(--color-contrast-200); + --color-text: var(--color-contrast-200); + } + + .text-constrast-300 { + color: var(--color-contrast-300); + --color-text: var(--color-contrast-300); + } + + .text-constrast-400 { + color: var(--color-contrast-400); + --color-text: var(--color-contrast-400); + } + + .text-constrast-500 { + color: var(--color-contrast-500); + --color-text: var(--color-contrast-500); + } + + .text-constrast-600 { + color: var(--color-contrast-600); + --color-text: var(--color-contrast-600); + } + + .text-constrast-700 { + color: var(--color-contrast-700); + --color-text: var(--color-contrast-700); + } + + .text-constrast-800 { + color: var(--color-contrast-800); + --color-text: var(--color-contrast-800); + } + + .text-constrast-900 { + color: var(--color-contrast-900); + --color-text: var(--color-contrast-900); + } + + .text-constrast-950 { + color: var(--color-contrast-950); + --color-text: var(--color-contrast-950); + } + + .bg-theme-50 { + background-color: var(--color-theme-50); + --color-bg: var(--color-theme-50); + } + + .bg-theme-100 { + background-color: var(--color-theme-100); + --color-bg: var(--color-theme-100); + } + + .bg-theme-200 { + background-color: var(--color-theme-200); + --color-bg: var(--color-theme-200); + } + + .bg-theme-300 { + background-color: var(--color-theme-300); + --color-bg: var(--color-theme-300); + } + + .bg-theme-400 { + background-color: var(--color-theme-400); + --color-bg: var(--color-theme-400); + } + + .bg-theme-500 { + background-color: var(--color-theme-500); + --color-bg: var(--color-theme-500); + } + + .bg-theme-600 { + background-color: var(--color-theme-600); + --color-bg: var(--color-theme-600); + } + + .bg-theme-700 { + background-color: var(--color-theme-700); + --color-bg: var(--color-theme-700); + } + + .bg-theme-800 { + background-color: var(--color-theme-800); + --color-bg: var(--color-theme-800); + } + + .bg-theme-900 { + background-color: var(--color-theme-900); + --color-bg: var(--color-theme-900); + } + + .bg-theme-950 { + background-color: var(--color-theme-950); + --color-bg: var(--color-theme-950); + } + +} \ No newline at end of file diff --git a/src/chromecast.ts b/src/chromecast.ts new file mode 100644 index 0000000..1bf7683 --- /dev/null +++ b/src/chromecast.ts @@ -0,0 +1,23 @@ +import { ChromecastChannelCaster } from './model/Remote/Channels/ChromcastCaster' +import { PostMessageChannel } from './model/Remote/Channels/PostMessage'; +import './style.css' + +const searchParams = new URLSearchParams(window.location.search); + +const chromecast = new ChromecastChannelCaster(searchParams.get('ws') ?? undefined); +const postMessage = new PostMessageChannel(window); +chromecast.initialize(); +chromecast.on((msg) => { + console.log('CHROMECAST MESSAGE', msg); + console.log(msg); + postMessage.send(msg); +}) +postMessage.on((msg) => { + console.log('POST MESSAGE', msg); + console.log(msg); + chromecast.send(msg); +}); + +document.getElementById('hail')?.addEventListener('click', () => { + chromecast.hail(); +}) \ No newline at end of file diff --git a/src/components/Audio/AudioCard.vue b/src/components/Audio/AudioCard.vue new file mode 100644 index 0000000..69d3926 --- /dev/null +++ b/src/components/Audio/AudioCard.vue @@ -0,0 +1,237 @@ + + + \ No newline at end of file diff --git a/src/components/Audio/DeviceVolume.vue b/src/components/Audio/DeviceVolume.vue new file mode 100644 index 0000000..742700a --- /dev/null +++ b/src/components/Audio/DeviceVolume.vue @@ -0,0 +1,78 @@ + + + \ No newline at end of file diff --git a/src/components/Audio/Playlist.vue b/src/components/Audio/Playlist.vue new file mode 100644 index 0000000..e2bd8f2 --- /dev/null +++ b/src/components/Audio/Playlist.vue @@ -0,0 +1,95 @@ + + + \ No newline at end of file diff --git a/src/components/Audio/Sfx.vue b/src/components/Audio/Sfx.vue new file mode 100644 index 0000000..29d19e5 --- /dev/null +++ b/src/components/Audio/Sfx.vue @@ -0,0 +1,58 @@ + + + \ No newline at end of file diff --git a/src/components/Audio/SfxSound.vue b/src/components/Audio/SfxSound.vue new file mode 100644 index 0000000..f17bb3f --- /dev/null +++ b/src/components/Audio/SfxSound.vue @@ -0,0 +1,129 @@ + + + \ No newline at end of file diff --git a/src/components/Audio/Soundboard.vue b/src/components/Audio/Soundboard.vue new file mode 100644 index 0000000..6fa9de5 --- /dev/null +++ b/src/components/Audio/Soundboard.vue @@ -0,0 +1,120 @@ + + + + diff --git a/src/components/Audio/TabSource.vue b/src/components/Audio/TabSource.vue new file mode 100644 index 0000000..6e0d9f9 --- /dev/null +++ b/src/components/Audio/TabSource.vue @@ -0,0 +1,21 @@ + + + + diff --git a/src/components/Audio/modal.vue b/src/components/Audio/modal.vue new file mode 100644 index 0000000..4445741 --- /dev/null +++ b/src/components/Audio/modal.vue @@ -0,0 +1,69 @@ + + + \ No newline at end of file diff --git a/src/components/FileManager/FileManager.vue b/src/components/FileManager/FileManager.vue index 286b773..51467cc 100644 --- a/src/components/FileManager/FileManager.vue +++ b/src/components/FileManager/FileManager.vue @@ -1,19 +1,77 @@ \ No newline at end of file diff --git a/src/components/Files/FilesAside.vue b/src/components/Files/FilesAside.vue new file mode 100644 index 0000000..97c128f --- /dev/null +++ b/src/components/Files/FilesAside.vue @@ -0,0 +1,100 @@ + + + + + \ No newline at end of file diff --git a/src/components/Icons/Backward.vue b/src/components/Icons/Backward.vue new file mode 100644 index 0000000..ed5e8cb --- /dev/null +++ b/src/components/Icons/Backward.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/Icons/Cog.vue b/src/components/Icons/Cog.vue new file mode 100644 index 0000000..0ed1978 --- /dev/null +++ b/src/components/Icons/Cog.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/Icons/Forward.vue b/src/components/Icons/Forward.vue new file mode 100644 index 0000000..1bdafb4 --- /dev/null +++ b/src/components/Icons/Forward.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/Icons/Mute.vue b/src/components/Icons/Mute.vue new file mode 100644 index 0000000..ef314e8 --- /dev/null +++ b/src/components/Icons/Mute.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/Icons/Pause.vue b/src/components/Icons/Pause.vue new file mode 100644 index 0000000..652de01 --- /dev/null +++ b/src/components/Icons/Pause.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/Icons/Play.vue b/src/components/Icons/Play.vue new file mode 100644 index 0000000..05ca625 --- /dev/null +++ b/src/components/Icons/Play.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/Icons/Speaker.vue b/src/components/Icons/Speaker.vue new file mode 100644 index 0000000..cccf736 --- /dev/null +++ b/src/components/Icons/Speaker.vue @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/src/components/Icons/Stop.vue b/src/components/Icons/Stop.vue new file mode 100644 index 0000000..187e9c3 --- /dev/null +++ b/src/components/Icons/Stop.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/RemoteDevices/RemoteDevices.vue b/src/components/RemoteDevices/RemoteDevices.vue new file mode 100644 index 0000000..a1711f2 --- /dev/null +++ b/src/components/RemoteDevices/RemoteDevices.vue @@ -0,0 +1,64 @@ + + + \ No newline at end of file diff --git a/src/components/ScreenManager/RemoteScreen.vue b/src/components/ScreenManager/RemoteScreen.vue new file mode 100644 index 0000000..d09306b --- /dev/null +++ b/src/components/ScreenManager/RemoteScreen.vue @@ -0,0 +1,192 @@ + + + diff --git a/src/components/ScreenManager/ScreenManager.vue b/src/components/ScreenManager/ScreenManager.vue new file mode 100644 index 0000000..8f12f8c --- /dev/null +++ b/src/components/ScreenManager/ScreenManager.vue @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/src/components/Soundboard.vue b/src/components/Soundboard.vue new file mode 100644 index 0000000..2ec3c43 --- /dev/null +++ b/src/components/Soundboard.vue @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/src/components/Tabs.vue b/src/components/Tabs.vue new file mode 100644 index 0000000..0c148d7 --- /dev/null +++ b/src/components/Tabs.vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/src/helpers/immediate.ts b/src/helpers/immediate.ts new file mode 100644 index 0000000..345e63c --- /dev/null +++ b/src/helpers/immediate.ts @@ -0,0 +1,4 @@ +export function immediate any>(fn: T):T { + fn(); + return fn; +} \ No newline at end of file diff --git a/src/helpers/loadScript.ts b/src/helpers/loadScript.ts new file mode 100644 index 0000000..2a3334a --- /dev/null +++ b/src/helpers/loadScript.ts @@ -0,0 +1,9 @@ +export function loadScript(src: string) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load script')); + document.head.appendChild(script); + }); +} \ No newline at end of file diff --git a/src/helpers/mouseDragging.ts b/src/helpers/mouseDragging.ts new file mode 100644 index 0000000..e54d371 --- /dev/null +++ b/src/helpers/mouseDragging.ts @@ -0,0 +1,34 @@ +import { onBeforeUnmount, ref } from "vue"; + +export const useMouseDragging = () => { + const dragging = ref(false); + const x = ref(0); + const y = ref(0); + const initialPosition = { x: 0, y: 0 }; + + function onMouseDown(event) { + dragging.value = true; + initialPosition.x = event.clientX - x.value; + initialPosition.y = event.clientY - y.value; + + window.addEventListener('mouseup', () => { + dragging.value = false; + }, { once: true }); + } + + function onMouseMove(event) { + if (dragging.value) { + x.value = event.clientX - initialPosition.x; + y.value = event.clientY - initialPosition.y; + } + } + + + window.addEventListener('mousemove', onMouseMove); + + onBeforeUnmount(() => { + window.removeEventListener('mousemove', onMouseMove); + }); + + return {onMouseDown, x, y}; +} \ No newline at end of file diff --git a/src/helpers/withLoading.ts b/src/helpers/withLoading.ts new file mode 100644 index 0000000..ad1348b --- /dev/null +++ b/src/helpers/withLoading.ts @@ -0,0 +1,16 @@ +import { computed, ref } from "vue"; + + +export const useLoading = () => { + const loadingCount = ref(0); + const isLoading = computed(() => loadingCount.value > 0); + const withLoading = async (fn: () => Promise) => { + loadingCount.value++; + try { + return await fn(); + } finally { + loadingCount.value--; + } + } + return { isLoading, withLoading }; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 4fcbf17..3ea13c5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,21 +1,58 @@ -import { createApp } from 'vue' +import './assets/main.css' +import { createApp, reactive } from 'vue' import './style.css' import ControlApp from './modules/controls/ControlApp.vue' -import { FileManager } from './model/FileManager' -import { FileManagerSymbol, SenderSymbol } from './model/injectionSymbols' -import { BroadcastSender } from './model/BroadcastListener'; -import { MultiSender } from './model/MultiListener' -import { ChromecastSender } from './model/ChromecastListener' +import { AudioContextSymbol, MountedFsSymbol, RemoteDeviceManagerSymbol } from './model/injectionSymbols' +import { MountedFs } from './model/FileSystem/MountedFs' +import { RemoteDeviceManager } from './model/Remote/RemoteDeviceManager' +import { ChromecastChannelCaster } from './model/Remote/Channels/ChromcastCaster' +import { WsSignalingCaster } from './model/Remote/Channels/WsSignaling' const app = createApp(ControlApp); +const chromecastChannelCaster = new ChromecastChannelCaster(); +chromecastChannelCaster.initialize(); +console.log('chromecastChannelCaster', chromecastChannelCaster); + +const audioContext = new AudioContext(); + app - .provide(SenderSymbol, new MultiSender([ - new BroadcastSender(), - new ChromecastSender(), + // .provide(SenderSymbol, new MultiSender([ + // new BroadcastSender(), + // new ChromecastSender(), + // ])) + // .provide(FileManagerSymbol, new FileManager()) + .provide(RemoteDeviceManagerSymbol, reactive(RemoteDeviceManager.restore({audioContext}))) + .provide(AudioContextSymbol, audioContext) + .provide(MountedFsSymbol, new MountedFs([ + /*{ + name: 'Maps', + type: 'nginx', + rootUrl: 'http://localhost:8080/maps/', + auth: undefined, + }, + { + name: 'SFX', + type: 'nginx', + rootUrl: 'http://localhost:8080/soundfx/', + auth: undefined, + },*/ + { + name: 'Maps', + type: 'filestash', + domain: 'https://files.fisoft.eu', + key: '426pBJgSyfmgJAaUmNjk', + share: 'nCKcVyl', + }, + { + name: 'Audio', + type: 'filestash', + domain: 'https://files.fisoft.eu', + key: '426pBJgSyfmgJAaUmNjk', + share: '74uqzNZ', + } ])) - .provide(FileManagerSymbol, new FileManager()) .mount('#app') diff --git a/src/model/Audio/AudioNodesManager.ts b/src/model/Audio/AudioNodesManager.ts new file mode 100644 index 0000000..f445eec --- /dev/null +++ b/src/model/Audio/AudioNodesManager.ts @@ -0,0 +1,44 @@ +import { PlaylistGroupNode, PlaylistGroupNodeOptions } from "./PlaylistGroupNode"; +import { SfxGroupNode, SfxGroupNodeOptions } from "./SfxGroupNode"; +import { SoundGroupNode } from "./SoundGroupNode"; +import { TabSourceNode, TabSourceNodeOptions } from "./TabSourceNode"; +import { SoundboardContext } from "./types"; + + +type SerializedNode> = ReturnType +export type SupportedDefinitions = SerializedNode | SerializedNode | SerializedNode + + +export class AudioNodeManager { + + deserializeNodes(definition: string, ctx: SoundboardContext) { + const definitions = JSON.parse(definition) as SupportedDefinitions[]; + return definitions.map((definition) => this.deserializeNode(definition, ctx)); + } + + deserializeNode(definition: SupportedDefinitions, ctx: SoundboardContext) { + if (definition.type === 'playlist') { + return new PlaylistGroupNode(definition, ctx); + } else if (definition.type === 'sfx') { + return new SfxGroupNode(definition, ctx); + } else if (definition.type === 'tabSource') { + return new TabSourceNode(definition, ctx); + } else { + throw new Error('Invalid node type'); + } + } + + restore(ctx: SoundboardContext) { + const nodes = localStorage.getItem('audioNodes'); + if (nodes) { + return this.deserializeNodes(nodes, ctx); + } + return []; + } + + store(nodes: SoundGroupNode[]) { + const definitions = nodes.map(node => node.serializeOptions()); + localStorage.setItem('audioNodes', JSON.stringify(definitions)); + } + +} \ No newline at end of file diff --git a/src/model/Audio/PlaylistGroupNode.ts b/src/model/Audio/PlaylistGroupNode.ts new file mode 100644 index 0000000..d7f7831 --- /dev/null +++ b/src/model/Audio/PlaylistGroupNode.ts @@ -0,0 +1,68 @@ +import { VirtualFile } from "../FileSystem/types"; +import { RemoteDevice } from "../Remote/types"; +import { SoundGroupNode, SoundGroupNodeOptions } from "./SoundGroupNode"; +import { MediaElementSoundNode } from "./SoundNode"; + +export interface PlaylistGroupNodeOptions extends SoundGroupNodeOptions { + currentTrack: number; + currentTrackTime: number; +} + +export class PlaylistGroupNode extends SoundGroupNode { + + public readonly type = 'playlist'; + + changeTrack(track: number) { + this.currentTrack.stop(); + (this.currentTrack as MediaElementSoundNode)?.seek(0); + this.soundNodes[this.options.currentTrack]?.stop(); + this.options.currentTrack = track; + this.options.currentTrackTime = 0; + this.play(); + } + + get currentTrack() { + return this.soundNodes[this.options.currentTrack]; + } + + get isPlaying() { + return this.soundNodes.some(sound => sound.isPlaying); + } + + play() { + this.currentTrack?.play(); + } + + togglePlay() { + if (this.isPlaying) { + this.currentTrack?.stop(); + } else { + this.play(); + } + } + + prev() { + this.changeTrack(((this.options.currentTrack ?? 0) + this.soundNodes.length - 1) % this.soundNodes.length); + } + + next() { + this.changeTrack(((this.options.currentTrack ?? 0) + 1) % this.soundNodes.length); + } + + serializeOptions() { + return { + ...super.serializeOptions(), + currentTrack: this.options.currentTrack ?? 0, + currentTrackTime: this.options.currentTrackTime ?? 0, + type: this.type, + } as const; + } + + addSound(vFile: VirtualFile) { + this.addSoundNode({ + type: 'mediaElement', + vFile, + volume: 100, + }); + } +} \ No newline at end of file diff --git a/src/model/Audio/README.md b/src/model/Audio/README.md new file mode 100644 index 0000000..a4a4ada --- /dev/null +++ b/src/model/Audio/README.md @@ -0,0 +1,4 @@ +# AudioTrack + +represents one audio file + diff --git a/src/model/Audio/SfxGroupNode.ts b/src/model/Audio/SfxGroupNode.ts new file mode 100644 index 0000000..e6f5b8f --- /dev/null +++ b/src/model/Audio/SfxGroupNode.ts @@ -0,0 +1,41 @@ +import { VirtualFile } from "../FileSystem/types"; +import { SoundGroupNode, SoundGroupNodeOptions } from "./SoundGroupNode"; +import { BufferedSoundNode } from "./SoundNode"; + +export interface SfxGroupNodeOptions extends SoundGroupNodeOptions { + +} + +export class SfxGroupNode extends SoundGroupNode { + public readonly type = 'sfx'; + + get activeSounds() { + return this.soundNodes.filter(sound => sound.isPlaying); + } + + play() { + return; + } + + togglePlay() { + if (this.isPlaying) { + this.soundNodes.forEach(sound => sound.stop()); + } + } + + addSound(vFile: VirtualFile) { + this.addSoundNode({ + type: 'buffered', + vFile, + volume: 100, + }); + } + + serializeOptions() { + return { + ...super.serializeOptions(), + type: this.type, + } as const; + } + +} \ No newline at end of file diff --git a/src/model/Audio/SoundGroupNode.ts b/src/model/Audio/SoundGroupNode.ts new file mode 100644 index 0000000..8477cf3 --- /dev/null +++ b/src/model/Audio/SoundGroupNode.ts @@ -0,0 +1,146 @@ +import { VirtualFile } from "../FileSystem/types"; +import { RemoteDevice } from "../Remote/types"; +import { BufferedSoundNode, MediaElementSoundNode, PartiaSoundNodeOptions, SoundNode } from "./SoundNode"; +import { SoundboardContext } from "./types"; + +export interface SoundGroupNodeOptions { + volume: number, + isMuted: boolean, + sounds: PartiaSoundNodeOptions[] +} + +export class DeviceSource { + + public isConnected = false; + + constructor(public device: RemoteDevice, public gainNode: GainNode) { + this.device = device; + this.gainNode = gainNode; + } + + connect() { + this.gainNode.connect(this.device.audioSink); + this.isConnected = true; + } + + diconnect() { + this.gainNode.disconnect(this.device.audioSink); + this.isConnected = false; + } +} + +export abstract class SoundGroupNode { + + public abstract readonly type: string; + public deviceSources: Map = new Map(); + protected options: Omit + protected gainNode: GainNode; + protected soundNodes: SoundNode[] = []; + + constructor( + options: T, + protected ctx: SoundboardContext + ) { + this.options = options; + this.gainNode = this.ctx.audioContext.createGain(); + this.gainNode.gain.value = (options.volume / 100); + for (const sound of options.sounds) { + this.addSoundNode(sound); + } + } + + addSound(vFile: VirtualFile) { + // implement in subclass + } + + addSoundNode(options: PartiaSoundNodeOptions) { + if (options.type === 'buffered') { + const sound = new BufferedSoundNode(options, this.ctx); + this.soundNodes.push(sound); + sound.connect(this.gainNode); + } else if (options.type === 'mediaElement') { + const sound = new MediaElementSoundNode(options, this.ctx); + this.soundNodes.push(sound); + sound.connect(this.gainNode); + } + } + + sounds() { + return this.soundNodes; + } + + get isPlaying() { + return this.soundNodes.some(sound => sound.isPlaying); + } + + abstract play(): void + abstract togglePlay(): void + + + connect(destination: AudioNode) { + console.log('connecting', this, destination); + this.gainNode.connect(destination); + } + + disconnect(destinationNode?: AudioNode) { + if (destinationNode) { + this.gainNode.disconnect(destinationNode); + } else { + this.gainNode.disconnect(); + } + } + + get volume() { + + return this.options.volume; + } + + set volume(val) { + this.options.volume = val; + this.gainNode.gain.value = val / 100; + } + + get isMuted() { + return this.options.isMuted; + } + + mute() { + this.options.isMuted = true; + this.gainNode.gain.value = 0; + } + + unmute() { + this.options.isMuted = false; + this.gainNode.gain.value = this.options.volume / 100; + } + + toggleMute() { + if (this.isMuted) { + this.unmute(); + } else { + this.mute(); + } + } + + getDeviceSource(remoteDevice: RemoteDevice) { + if (!this.deviceSources.has(remoteDevice)) { + const gain = this.ctx.audioContext.createGain(); + this.gainNode.connect(gain); + const deviceSource = new DeviceSource(remoteDevice, gain); + this.deviceSources.set(remoteDevice, deviceSource); + deviceSource.connect(); + } + return this.deviceSources.get(remoteDevice)!; + } + + serializeOptions() { + return { + ...this.options, + volume: this.volume, + isMuted: this.options.isMuted, + sounds: this.soundNodes.map(soundNode => soundNode.options), + type: this.type, + } + } + +} \ No newline at end of file diff --git a/src/model/Audio/SoundNode.ts b/src/model/Audio/SoundNode.ts new file mode 100644 index 0000000..930cbc1 --- /dev/null +++ b/src/model/Audio/SoundNode.ts @@ -0,0 +1,177 @@ +import { MountedFs } from "../FileSystem/MountedFs"; +import { FileSystemSource, VirtualFile } from "../FileSystem/types"; +import { SoundboardContext } from "./types"; + + +export interface PartiaSoundNodeOptions { + vFile: VirtualFile; + volume?: number; + type: 'buffered' | 'mediaElement'; +} + +export interface SoundNodeOptions extends PartiaSoundNodeOptions { + volume: number; +} + +export abstract class SoundNode { + + protected gainNode: GainNode; + protected file?: File; + public options: SoundNodeOptions; + public readonly numberOfInputs = 0; + public readonly numberOfOutputs = 1; + protected activeSources: AudioBufferSourceNode[] = []; + protected audioContext: AudioContext; + protected readonly fs: MountedFs; + + constructor( + options: PartiaSoundNodeOptions, + ctx: SoundboardContext, +) { + this.options = { + ...options, + volume: options.volume ?? 100 + } + this.audioContext = ctx.audioContext; + this.fs = ctx.fs; + this.gainNode = this.audioContext.createGain(); + this.gainNode.gain.value = (this.options.volume / 100); + } + + get isPlaying() { + return this.activeSources.length > 0; + } + + get name() { + return this.options.vFile.name.replace(/\.[^/.]+$/, ""); + } + + get key() { + return this.options.vFile.path.join('/'); + } + + protected async preload() { + if (!this.file) { + this.file = await this.fs.download(this.options.vFile); + } + } + + connect(destination: AudioNode) { + console.log('connecting', this, destination); + this.gainNode.connect(destination); + } + + disconnect(destinationNode?: AudioNode) { + if (destinationNode) { + this.gainNode.disconnect(destinationNode); + } else { + this.gainNode.disconnect(); + } + } + + setVolume(volume: number) { + this.gainNode.gain.value = volume / 100; + } + + getVolume() { + return this.gainNode.gain.value * 100; + } + + abstract play(): Promise; + abstract stop(): Promise; +} + +export class BufferedSoundNode extends SoundNode { + private buffer?: AudioBuffer; + + async preload() { + await super.preload(); + if (!this.buffer) { + const arrayBuffer = await this.file!.arrayBuffer(); + this.buffer = await this.audioContext.decodeAudioData(arrayBuffer); + } + } + + async play() { + await this.preload(); + const source = this.audioContext.createBufferSource(); + source.buffer = this.buffer!; + source.connect(this.gainNode); + source.start(); + this.audioContext.resume(); + this.activeSources.push(source); + source.addEventListener('ended', () => { + const index = this.activeSources.indexOf(source); + if (index !== -1) { + this.activeSources.splice(index, 1); + } + }); + } + + async stop() { + this.activeSources.forEach((source) => source.stop()); + this.activeSources = []; + } +} + +export class MediaElementSoundNode extends SoundNode { + private mediaElement?: HTMLMediaElement; + private source?: MediaElementAudioSourceNode; + private _isPlaying = false; + + async preload() { + await super.preload(); + if (!this.mediaElement) { + this.mediaElement = document.createElement('audio'); + this.mediaElement.src = URL.createObjectURL(this.file!); + this.source = this.audioContext.createMediaElementSource(this.mediaElement); + this.source.connect(this.gainNode); + this.audioContext.resume(); + this.mediaElement.addEventListener('ended', ()=> { + this._isPlaying = false; + }); + this.mediaElement.addEventListener('playing', (event) => { + console.log(event); + this._isPlaying = true; + }); + this.mediaElement.addEventListener('pause', () => { + this._isPlaying = false; + }); + } + } + + get isPlaying() { + return this._isPlaying; + } + + get currentTime() { + return this.mediaElement!.currentTime; + } + + get duration() { + return this.mediaElement!.duration; + } + + async play() { + await this.preload(); + await this.mediaElement?.play(); + } + + async stop() { + await this.mediaElement?.pause(); + } + + pause() { + this.mediaElement?.pause(); + } + + resume() { + this.mediaElement?.play(); + } + + seek(time: number) { + if (this.mediaElement) { + this.mediaElement.currentTime = time; + } + } +} \ No newline at end of file diff --git a/src/model/Audio/TabSourceNode.ts b/src/model/Audio/TabSourceNode.ts new file mode 100644 index 0000000..c5567bd --- /dev/null +++ b/src/model/Audio/TabSourceNode.ts @@ -0,0 +1,76 @@ +import { SoundGroupNode, SoundGroupNodeOptions } from "./SoundGroupNode"; + +export interface TabSourceNodeOptions extends SoundGroupNodeOptions { +} + +export class TabSourceNode extends SoundGroupNode { + + public readonly type = 'tabSource'; + + private stream?: MediaStream; + private source?: MediaStreamAudioSourceNode; + + + play() { + if (!this.isPlaying) { + this.setup() + this.ctx.audioContext.resume(); + } + } + + private async setup() { + try { + this.stream = await navigator.mediaDevices.getDisplayMedia({ + video: { + displaySurface: "browser", + frameRate: {ideal: 1} + }, + audio: { + suppressLocalAudioPlayback: false, + echoCancellation: false, + noiseSuppression: false, + sampleRate: 44100, + }, + preferCurrentTab: false, + selfBrowserSurface: "exclude", + systemAudio: "exclude", + surfaceSwitching: "include", + monitorTypeSurfaces: "include", + }); + console.log(this.stream); + this.source = this.ctx.audioContext.createMediaStreamSource(this.stream); + this.source.connect(this.gainNode); + } catch (err) { + console.error(err); + } + } + + stop() { + this.stream?.getTracks().forEach(track => { + track.stop(); + }); + this.source?.disconnect(); + this.source = undefined; + this.stream = undefined; + } + + get isPlaying(): boolean { + return !!(this.stream && this.source); + } + + togglePlay(): void { + if (this.isPlaying) { + this.stop(); + } else { + this.play(); + } + } + + serializeOptions() { + return { + ...super.serializeOptions(), + type: this.type, + } as const; + } + +} \ No newline at end of file diff --git a/src/model/Audio/types.ts b/src/model/Audio/types.ts new file mode 100644 index 0000000..d8df8fa --- /dev/null +++ b/src/model/Audio/types.ts @@ -0,0 +1,7 @@ +import { MountedFs } from "../FileSystem/MountedFs"; +import { FileSystemSource } from "../FileSystem/types"; + +export type SoundboardContext = { + audioContext: AudioContext; + fs: MountedFs, +} \ No newline at end of file diff --git a/src/model/ChromecastListener.ts b/src/model/ChromecastListener.ts index 8b7acba..686be3e 100644 --- a/src/model/ChromecastListener.ts +++ b/src/model/ChromecastListener.ts @@ -1,3 +1,4 @@ +import { loadScript } from "../helpers/loadScript"; import { Message, Sender, Receiver, SendMesssageOptions } from "./CommunicationChannel"; let logCounter = 0; @@ -24,15 +25,7 @@ function fixResolution() { document.body.style.height = `${height}px`; } -function loadScript(src: string) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.onload = () => resolve(); - script.onerror = () => reject(new Error('Failed to load script')); - document.head.appendChild(script); - }); -} + diff --git a/src/model/FileMeta/ExtensionMimeType.ts b/src/model/FileMeta/ExtensionMimeType.ts new file mode 100644 index 0000000..bd7a770 --- /dev/null +++ b/src/model/FileMeta/ExtensionMimeType.ts @@ -0,0 +1,21 @@ +import { VirtualFile } from "../FileSystem/types"; + +export function extensionMimeType(fileName: string) { + if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.jpe')) { + return 'image/jpeg'; + } else if (fileName.endsWith('.png')) { + return 'image/png'; + } else if (fileName.endsWith('.webp')) { + return 'image/webp'; + } else if (fileName.endsWith('.mp4')) { + return 'video/mp4'; + } else if (fileName.endsWith('.webm')) { + return 'video/webm'; + } else if (fileName.endsWith('.ogg')) { + return 'audio/ogg'; + } else if (fileName.endsWith('.mp3')) { + return 'audio/mpeg'; + } else { + return 'application/octet-stream'; + } +} \ No newline at end of file diff --git a/src/model/FileMeta/MapMeta.ts b/src/model/FileMeta/MapMeta.ts new file mode 100644 index 0000000..0f8c375 --- /dev/null +++ b/src/model/FileMeta/MapMeta.ts @@ -0,0 +1,50 @@ +import { VirtualFile } from "../FileSystem/types"; + +export type MapMeta = { + width: number | undefined; + height: number | undefined; + animated?: boolean; + valid: boolean; +} + +function extractSize(file: VirtualFile): MapMeta { + { // czepeku format [${width}x${height}] + const match = [...file.path, file.name].join('/').match(/(\d+)x(\d+)/); + if (match) { + return { + width: parseInt(match[1]), + height: parseInt(match[2]), + } + } + } + + { // width format [${width}w] + const match = [...file.path, file.name].join('/').match(/(\d+)w/); + if (match) { + return { + width: parseInt(match[1]), + height: undefined, + } + } + } + + return { + width: undefined, + height: undefined, + } +} + +export function addMapMeta(file: T): T & { map: MapMeta } { + const { width, height } = extractSize(file); + + + return { + ...file, + map: { + width, + height, + valid: file.mimeType.startsWith('image/') || file.mimeType.startsWith('video/'), + animated: file.mimeType.startsWith('video/'), + }, + } +} \ No newline at end of file diff --git a/src/model/FileSystem/FileStashFs.ts b/src/model/FileSystem/FileStashFs.ts new file mode 100644 index 0000000..2764b17 --- /dev/null +++ b/src/model/FileSystem/FileStashFs.ts @@ -0,0 +1,100 @@ +import { extensionMimeType } from "../FileMeta/ExtensionMimeType"; +import { CachePolicy, FsCache } from "./FsCache"; +import { VirtualDirectory, VirtualFile, FileSystemSource, ListDirOptions } from "./types"; + +export type FileStashFsConfig = { + name: string; + domain: string; + share: string, + key: string; +} + +interface LsResult { + results: Array<{ + name: string, + size: number, + time: number, + type: 'file' | 'directory', + }> + status: "ok" +} + +export class FileStashFs implements FileSystemSource { + + private fsCache: FsCache; + + constructor(private options: FileStashFsConfig) { + this.fsCache = new FsCache(options.name); + } + + get name() { + return this.options.name; + } + + get root(): VirtualDirectory { + return { + type: 'directory', + source: this.options.name, + path: [], + name: this.options.name + } + } + + private createUrl(type: 'cat' | 'ls', path: string[]) { + const url = new URL(this.options.domain); + url.pathname = `/api/files/${type}`; + const params = new URLSearchParams(); + params.set('share', this.options.share); + params.set('key', this.options.key); + params.set('path', `/${path.join('/')}`); + url.search = params.toString(); + return url; + } + + async listDir(dir: VirtualDirectory, {cache = CachePolicy.useNetwork}: ListDirOptions = {}): Promise<(VirtualDirectory|VirtualFile)[]> { + const request = new Request(this.createUrl('ls', dir.path)); + const response = await this.fsCache.get(request, cache); + if (!response.ok) { + throw new Error(`Failed to list directory ${dir.path.join('/')}`); + } + const data = await response.json() as LsResult; + if (data.status !== 'ok') { + throw new Error(`Failed to list directory ${dir.path.join('/')}`); + } + return data.results.map((result) => { + if (result.type === 'file') { + return { + type: 'file', + source: this.options.name, + name: result.name, + mimeType: extensionMimeType(result.name), + path: [...dir.path, result.name], + } + } else if (result.type === 'directory') { + return { + type: 'directory', + source: this.options.name, + name: result.name, + path: [...dir.path, result.name], + } + } else { + throw new Error(`Unknown type ${result.type}`); + } + }); + } + + async download(file: VirtualFile): Promise { + const response = await fetch(this.createUrl('cat', file.path)); + if (!response.ok) { + throw new Error(`Failed to download file ${file.path.join('/')}`); + } + return new File([await response.blob()], file.name); + } + + getConfig() { + return { + ...this.options, + type: 'filestash', + } as const; + } +} \ No newline at end of file diff --git a/src/model/FileSystem/FsCache.ts b/src/model/FileSystem/FsCache.ts new file mode 100644 index 0000000..e390ff2 --- /dev/null +++ b/src/model/FileSystem/FsCache.ts @@ -0,0 +1,52 @@ + +export const CachePolicy = { + useCache: 'use-cache', + useNetwork: 'use-network', + onlyCache: 'only-cache', +} as const; +export type CachePolicy = typeof CachePolicy[keyof typeof CachePolicy]; + +export class CacheError extends Error {} +export class NoCacheEntryError extends CacheError {} + +export class FsCache { + + private _cache?: Cache; + + constructor(public readonly name: string) {} + + public async cache() { + if (!this._cache) { + this._cache = await caches.open(this.name); + } + return this._cache; + } + + public async fetch(request: RequestInfo): Promise { + const response = await fetch(request); + if (response.ok) { + const cache = await this.cache() + cache.put(request, response.clone()); + } + return response; + } + + public async get(request: RequestInfo, policy: CachePolicy): Promise { + if (policy === CachePolicy.useNetwork) { + return this.fetch(request); + } + const cache = await this.cache(); + const response = await cache.match(request); + if (response && policy === CachePolicy.useCache) { + return response; + } else if (policy === CachePolicy.onlyCache) { + if (!response) { + throw new NoCacheEntryError('No cache entry found'); + } + return response; + } else { + return this.fetch(request); + } + } + +} \ No newline at end of file diff --git a/src/model/FileSystem/MountedFs.ts b/src/model/FileSystem/MountedFs.ts new file mode 100644 index 0000000..6154dff --- /dev/null +++ b/src/model/FileSystem/MountedFs.ts @@ -0,0 +1,129 @@ +import { FileSystemSource, ListDirOptions, VirtualDirectory, VirtualFile } from "./types"; +import { NginxFS } from "./NginxFS"; +import { FileStashFs } from "./FileStashFs"; +import { CachePolicy, NoCacheEntryError } from "./FsCache"; + +type Config = T extends FileSystemSource ? ReturnType : never; + +const CACHE = Symbol('Cache'); +export interface Fs { + get root(): VirtualDirectory[]; + download(file: VirtualFile): Promise; + listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>; +} + +export class MountedFs implements Fs { + private filesystems: Map> = new Map(); + public readonly name: string = 'root'; + + constructor(filesystems: (Config | Config)[]) { + for (const fsOptions of filesystems) { + if (fsOptions.type === 'nginx') { + this.filesystems.set(fsOptions.name, new NginxFS(fsOptions)); + } else if (fsOptions.type === 'filestash') { + this.filesystems.set(fsOptions.name, new FileStashFs(fsOptions)); + } + } + } + + get root() { + return [...this.filesystems.values()].map(fs => fs.root); + } + + download(file: VirtualFile) { + const fs = this.filesystems.get(file.source); + if (!fs) { + throw new Error(`Filesystem ${file.source} not found`); + } + return fs.download(file); + } + + async listDir(dir: VirtualDirectory, options?: ListDirOptions) { + + const fs = this.filesystems.get(dir.source); + if (!fs) { + throw new Error(`Filesystem ${dir.source} not found`); + } + const result = await fs.listDir(dir, options); + + return result; + } + + async *search(query: string, cancelToken?: AbortSignal) { + query = query.toLocaleLowerCase(); + for (const fs of this.filesystems.values()) { + const dirQueue = [fs.root]; + while (dirQueue.length > 0) { + const dir = dirQueue.shift()!; + try { + const children = await this.listDir(dir, {cache: CachePolicy.onlyCache}); + for (const child of children) { + if (cancelToken?.aborted) { + return; + } + if (child.type === 'directory') { + dirQueue.push(child); + } else if (child.name.toLocaleLowerCase().includes(query)) { + yield child; + } + } + } catch (err) { + if (!(err instanceof NoCacheEntryError)) { + console.warn(`Failed to list directory ${dir.path.join('/')}`, err); + } + continue; + } + } + } + } + + async *buildIndex() { + let counter = 0; + for (const fs of this.filesystems.values()) { + const dirQueue = [fs.root]; + while (dirQueue.length > 0) { + const dir = dirQueue.shift()!; + for (const child of await this.listDir(dir)) { + if (child.type === 'directory') { + dirQueue.push(child); + } + counter++; + } + yield counter + } + } + } +} + + +// export class SearchableFilesystem implements Fs { +// public allFiles: VirtualFile[] = []; + +// constructor(private fs: MountedFs) {} + +// async precacheDir(dir: VirtualDirectory) { +// for (const child of await this.fs.listDir(dir, {cache: CachePolicy.useCache})) { +// if (child.type === 'file') { +// this.allFiles.push(child); +// } else { +// await this.precacheDir(child as VirtualDirectory); +// } +// } +// } + +// async search(query: string) { +// await Promise.all(this.fs.root.map((root) => this.precacheDir(root))); +// } + +// get root() { +// return this.fs.root; +// } + +// async listDir(dir: VirtualDirectory) { +// return this.fs.listDir(dir); +// } + +// async download(file: VirtualFile) { +// return this.fs.download(file); +// } +// } \ No newline at end of file diff --git a/src/model/FileSystem/NginxFS.ts b/src/model/FileSystem/NginxFS.ts new file mode 100644 index 0000000..79837f9 --- /dev/null +++ b/src/model/FileSystem/NginxFS.ts @@ -0,0 +1,102 @@ +import { extensionMimeType } from "../FileMeta/ExtensionMimeType"; +import { FileSystemSource, VirtualFile, VirtualDirectory } from "./types"; + +export type NginxFsConfig = { + name: string; + rootUrl: string; + auth?: { + username: string; + password: string; + }; +} + +type DateString = string & {}; + +interface NginxFile { + name: string; + type: 'file' + mtime: DateString; + size: number; +} + +interface NginxDirectory { + name: string; + type: 'directory'; + mtime: DateString; +} + +export class NginxFS implements FileSystemSource { + constructor(private options: NginxFsConfig) {} + + get name() { + return this.options.name; + } + + get root():VirtualDirectory { + return { + type: 'directory', + source: this.options.name, + path: [], + name: this.options.name + } + } + + private get sanitizedRootUrl() { + return this.options.rootUrl.endsWith("/") ? this.options.rootUrl : this.options.rootUrl + "/"; + } + + private get authHeaders(): {Authorization: string}|{} { + return this.options.auth ? { + Authorization: `Basic ${btoa(`${this.options.auth.username}:${this.options.auth.password}`)}` + } : {} + } + + async listDir(dir: VirtualDirectory): Promise<(VirtualDirectory|VirtualFile)[]> { + + const response = await fetch(`${this.sanitizedRootUrl}${dir.path.map((x) => `${x}/`).join("")}`, { + headers: { + ...this.authHeaders, + } + }); + if (!response.ok) { + console.error("Failed to list directory", response); + return []; + } + + const data = await response.json() as Array; + return data.map((item) => { + if (item.type === "directory") { + return { + type: "directory", + source: this.options.name, + path: [...dir.path, item.name], + name: item.name + } + } else { + return { + type: "file", + source: this.options.name, + path: [...dir.path, item.name], + name: item.name, + mimeType: extensionMimeType(item.name), + } + } + }); + } + + async download(file: VirtualFile): Promise { + const response = await fetch(`${this.sanitizedRootUrl}${file.path.join("/")}`); + if (!response.ok) { + throw new Error("Failed to download file"); + } + return new File([await response.blob()], file.name, { type: file.mimeType }); + } + + getConfig() { + return { + ...this.options, + type: "nginx", + } as const; + } +} + diff --git a/src/model/FileSystem/types.ts b/src/model/FileSystem/types.ts new file mode 100644 index 0000000..9f97268 --- /dev/null +++ b/src/model/FileSystem/types.ts @@ -0,0 +1,27 @@ +import {CachePolicy} from './FsCache'; +export interface FileSystemSource { + name: string; + root: VirtualDirectory; + listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>; + download(file: VirtualFile): Promise; + getConfig(): TOptions & { type: string }; +} + +export interface ListDirOptions { + cache?: CachePolicy +} + +export interface VirtualDirectory { + type: 'directory'; + source: string; + path: string[]; + name: string; +} + +export interface VirtualFile { + type: 'file'; + source: string; + path: string[]; + name: string; + mimeType: string; +} diff --git a/src/model/Remote/Channels/ChromcastCaster.ts b/src/model/Remote/Channels/ChromcastCaster.ts new file mode 100644 index 0000000..744c7e4 --- /dev/null +++ b/src/model/Remote/Channels/ChromcastCaster.ts @@ -0,0 +1,111 @@ +import { loadScript } from "../../../helpers/loadScript"; +import { JsonChannelCaster, Message, MessageEmitter } from "./type"; + +const CHANNEL = 'urn:x-cast:eu.fisoft.battlecaster.custom'; + +export class ChromecastChannelCaster extends MessageEmitter implements JsonChannelCaster { + + private context?: cast.framework.CastContext; + private sessionStarted = false; + + constructor(private wsUuid?: string) { + super(); + } + + async initialize() { + window['__onGCastApiAvailable'] = (isAvailable) => { + console.log('__onGCastApiAvailable', isAvailable); + if (isAvailable) { + console.log('cast available'); + this.initializeCastApi(); + } + }; + if (typeof window.cast?.framework?.CastContext === 'undefined') { + loadScript('//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1') + } + } + + initializeCastApi() { + this.context = cast.framework.CastContext.getInstance(); + this.context.setOptions({ + receiverApplicationId: import.meta.env.VITE_CHROMECAST_APP_ID, + androidReceiverCompatible: true, + autoJoinPolicy: chrome.cast.AutoJoinPolicy.CUSTOM_CONTROLLER_SCOPED + }); + this.context.addEventListener( + cast.framework.CastContextEventType.SESSION_STATE_CHANGED, + this.onSessionStateChange.bind(this) + ); + } + + + private onSessionStateChange(event: cast.framework.SessionStateEventData) { + console.log('onSessionStateChange', event); + if (event.sessionState === cast.framework.SessionState.SESSION_STARTED) { + this.sessionStarted = true; + console.log('session started'); + const session = this.context?.getCurrentSession(); + // @ts-ignore + // this.sendMessage({ type: 'INIT', data: 'Hello, receiver!' }); + session?.addMessageListener(CHANNEL, (namespace, message) => { + console.log('Message received', namespace, message); + const msg = JSON.parse(message); + console.log('Message received', session, namespace, msg); + this.emit(msg); + }); + this.send({ + type: 'hello', + name: session?.getCastDevice().friendlyName + }); + if (this.wsUuid) { + this.send({ + type: 'RtcSignalingConnect', + uuid: this.wsUuid + }); + } + + console.log('session', session); + + // const heartbeat = () => { + // const session = this.context?.getCurrentSession(); + // if (session) { + // setTimeout(heartbeat, 10000); + // session?.sendMessage(CHANNEL, { type: 'HEARTBEAT' }) + // .then(() => console.log('Heartbeat sent')) + // .catch(error => console.error('Error sending heartbeat:', error)); + // } + // }; + // heartbeat(); + } + } + + hail() { + const session = this.context?.getCurrentSession(); + this.send({ + type: 'hello', + name: session?.getCastDevice().friendlyName ?? 'Chromecast' + }); + if (this.wsUuid) { + this.send({ + type: 'RtcSignalingConnect', + uuid: this.wsUuid + }); + } + } + + send(data: Message): void { + if (!this.sessionStarted) { + console.error('No session started'); + return; + } + const session = this.context?.getCurrentSession(); + if (!session) { + console.error('No session'); + return; + } + + this.context?.getCurrentSession()?.sendMessage(CHANNEL, data) + .then(() => console.log('Message sent successfully')) + .catch(error => console.error('Error sending message:', error)); + } +} \ No newline at end of file diff --git a/src/model/Remote/Channels/ChromcastSummon.ts b/src/model/Remote/Channels/ChromcastSummon.ts new file mode 100644 index 0000000..633b7f9 --- /dev/null +++ b/src/model/Remote/Channels/ChromcastSummon.ts @@ -0,0 +1,63 @@ +import { loadScript } from "../../../helpers/loadScript"; +import { JsonChannelCaster, Message, MessageEmitter } from "./type"; +import type {CastReceiverContext, CastReceiverOptions} from '@types/chromecast-caf-receiver/cast.framework'; +import { WsSignalingSummon } from "./WsSignaling"; + +const CHANNEL = 'urn:x-cast:eu.fisoft.battlecaster.custom'; + +export class ChromecastChannelSummon extends MessageEmitter implements JsonChannelSummon { + + constructor() { + super(); + } + + log(...data: any[]) { + // @ts-ignore + this.emit({ type: 'log', data: data }); + } + + async initialize() { + this.log('loading cast_receiver_framework.js'); + await loadScript('//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js') + this.initializeCastApi(); + } + + initializeCastApi() { + this.log('initializeCastApi'); + // @ts-ignore + const context: CastReceiverContext = cast.framework.CastReceiverContext.getInstance(); + // @ts-ignore + const options: CastReceiverOptions = new cast.framework.CastReceiverOptions(); + + context.addCustomMessageListener(CHANNEL, async (event: any) => { + try { + this.log('Message received', event.senderId, event.data); + const msg = event.data; + if (msg.type === 'hello') { + this.log('Chromecast summoned', event.senderId); + this.send({type: 'summoned', name: msg.name ?? 'Chromecast'}); + } + this.emit(msg); + } catch (err) { + this.log('error', err); + } + }); + options.customNamespaces = { + // @ts-ignore + [CHANNEL]: cast.framework.system.MessageType.JSON, + } + options.disableIdleTimeout = true; + options.maxInactivity = 3600; // Development only + context.start(options); + } + + send(data: Message): void { + try { + // @ts-ignore + const context: CastReceiverContext = cast.framework.CastReceiverContext.getInstance() + context.sendCustomMessage(CHANNEL, undefined, data); + } catch (err) { + this.log('send error', err); + } + } +} \ No newline at end of file diff --git a/src/model/Remote/Channels/PostMessage.ts b/src/model/Remote/Channels/PostMessage.ts new file mode 100644 index 0000000..eb2ef44 --- /dev/null +++ b/src/model/Remote/Channels/PostMessage.ts @@ -0,0 +1,25 @@ +import { JsonChannelCaster, JsonChannelSummon, Message, MessageEmitter } from "./type"; + + + +export class PostMessageChannel extends MessageEmitter implements JsonChannelCaster, JsonChannelSummon { + + constructor(private win: WindowProxy) { + super(); + win.addEventListener('message', this.onMessage); + } + + private onMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data) as Message; + this.emit(data); + } + + send(data: Message) { + this.win.postMessage(JSON.stringify(data), '*'); + } + + disconnect() { + super.disconnect(); + this.win.removeEventListener('message', this.onMessage); + } +} \ No newline at end of file diff --git a/src/model/Remote/Channels/WebRTCChannel.ts b/src/model/Remote/Channels/WebRTCChannel.ts new file mode 100644 index 0000000..643352c --- /dev/null +++ b/src/model/Remote/Channels/WebRTCChannel.ts @@ -0,0 +1,172 @@ +import { VirtualFile } from "../../FileSystem/types"; +import { RemoteDeviceManagerCtx } from "../RemoteDeviceManager"; +import { JsonChannelCaster, JsonChannelSummon, Message, MessageEmitter } from "./type"; + + +export class WebRtcChannelCaster extends MessageEmitter implements JsonChannelCaster { + + private peer = new RTCPeerConnection() + private offerSent = false; + private dataChannel = this.peer.createDataChannel('json'); + public readonly sink: MediaStreamAudioDestinationNode; + + constructor( + private channel: JsonChannelCaster, + {onOpen}:Partial<{onOpen: Function}> = {}, + private ctx: RemoteDeviceManagerCtx + ) { + super(); + this.sink = ctx.audioContext.createMediaStreamDestination(); + this.channel.on(this.onJsonMessage); + this.dataChannel.addEventListener('close', () => { + this.emit({ type: 'disconnected' }); + }); + this.dataChannel.addEventListener('open', () => { + onOpen?.(); + this.emit({ type: 'connected' }); + }); + this.dataChannel.addEventListener('message', this.onMessage); + + this.peer.addTrack(this.sink.stream.getAudioTracks()[0], this.sink.stream); + this.peer.onicecandidate = (event) => { + if (event.candidate) { + this.channel.send({ + type: 'RtcIce', + candidate: event.candidate + }); + } + } + } + + private onMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data) as Message; + this.emit(data); + } + + private onJsonMessage = (data: Message) => { + if (data.type === 'summoned') { + if (!this.offerSent) { + this.peer.createOffer().then(offer => { + this.peer.setLocalDescription(offer); + this.channel.send({ + type: 'RtcOffer', + offer + }); + }); + this.offerSent = true; + } + } else if (data.type === 'RtcAnswer') { + this.peer.setRemoteDescription(new RTCSessionDescription(data.answer)); + } else if (data.type === 'RtcIce') { + this.peer.addIceCandidate(new RTCIceCandidate(data.candidate)); + } + } + + async sendBinary(file: VirtualFile, data: Blob) { + const channelName = `file:${Math.random()}`; + this.dataChannel.send(JSON.stringify({ + type: 'fileTransfer', + channel: channelName, + size: data.size, + file, + })); + const channel = this.peer.createDataChannel(channelName); + channel.binaryType = 'arraybuffer'; + const buffer = await data.arrayBuffer(); + const chunkSize = 16384; + for (let i = 0; i < buffer.byteLength; i += chunkSize) { + const chunk = buffer.slice(i, i + chunkSize); + channel.send(chunk); + } + channel.close(); + await new Promise((resolve) => channel.addEventListener('close', resolve, { once: true })); + } + + send(data: Message) { + this.dataChannel.send(JSON.stringify(data)); + } + + disconnect() { + super.disconnect(); + } +} + + +export class WebRtcChannelSummon extends MessageEmitter implements JsonChannelSummon { + private peer = new RTCPeerConnection() + private dataChannel?: RTCDataChannel; + private transferedFilesMeta = new Map(); + + constructor(private channel: JsonChannelCaster) { + super(); + this.channel.on(this.onJsonMessage); + this.peer.onicecandidate = (event) => { + if (event.candidate) { + this.channel.send({ + type: 'RtcIce', + candidate: event.candidate + }); + } + } + this.peer.ontrack = (event) => { + console.log('ontrack', event); + const audioElement = document.createElement('audio'); + audioElement.srcObject = event.streams[0]; // Attach the received stream + audioElement.play(); + } + this.peer.ondatachannel = (event) => { + if (event.channel.label === 'json') { + this.dataChannel = event.channel; + this.dataChannel.addEventListener('message', this.onRtcMessage); + this.emit({ type: 'connected' }); + } else { + const channel = event.channel; + const chunks:ArrayBuffer[] = []; + channel.addEventListener('message', (event: MessageEvent) => { + console.log('>', channel.label, event.data); + chunks.push(event.data); + }); + channel.addEventListener('close', () => { + console.log('>', channel.label, 'closed'); + const meta = this.transferedFilesMeta.get(channel.label)!; + // TODO: handle data before meta? + const blob = new Blob(chunks, { type: meta?.mimeType ?? 'application/octet-stream' }); + console.log(meta, blob); + this.emit({ + type: 'fileTransfered', + meta, + blob, + }) + }); + } + } + } + + private onRtcMessage = (event: MessageEvent) => { + const msg = JSON.parse(event.data) as Message; + if (msg.type === 'fileTransfer') { + this.transferedFilesMeta.set(msg.channel, msg.file); + } else { + this.emit(msg); + } + }; + + private onJsonMessage = (data: Message) => { + if (data.type === 'RtcOffer') { + this.peer.setRemoteDescription(new RTCSessionDescription(data.offer)); + this.peer.createAnswer().then(answer => { + this.peer.setLocalDescription(answer); + this.channel.send({ + type: 'RtcAnswer', + answer + }); + }); + } else if (data.type === 'RtcIce') { + this.peer.addIceCandidate(new RTCIceCandidate(data.candidate)); + } + } + + send(data: Message) { + this.dataChannel?.send(JSON.stringify(data)); + } +} diff --git a/src/model/Remote/Channels/WsSignaling.ts b/src/model/Remote/Channels/WsSignaling.ts new file mode 100644 index 0000000..b2237d8 --- /dev/null +++ b/src/model/Remote/Channels/WsSignaling.ts @@ -0,0 +1,92 @@ +import { JsonChannelCaster, JsonChannelSummon, Message, MessageEmitter } from "./type"; + +const BASE_DOMAIN = 'wss://webrtc.worlddominationproject.dev' + +export class WsSignalingSummon extends MessageEmitter implements JsonChannelCaster, JsonChannelSummon { + + private ws?: WebSocket; + + constructor(private uuid?: string) { + super(); + } + + async connect(uuid?: string) { + this.uuid = uuid ?? this.uuid; + return new Promise((resolve, reject) => { + if (!this.uuid) { + reject(new Error('No uuid provided')); + } + this.ws = new WebSocket(`${BASE_DOMAIN}/${this.uuid}`); + this.ws.onmessage = (event: MessageEvent) => { + const msg = JSON.parse(event.data); + this.emit(msg); + } + + this.ws.onerror = (err) => { + reject(err); + } + + this.ws.onopen = () => { + resolve(); + } + }) + } + + disconnect(): void { + try { + this.ws?.close(); + } catch (err) { + console.log('Error closing ws', err); + } + } + + send(data: Message): void { + this.ws?.send(JSON.stringify(data)); + } +} + + + +export class WsSignalingCaster extends MessageEmitter implements JsonChannelCaster { + + private ws?: WebSocket; + constructor() { + super(); + } + + async initialize() { + return new Promise((resolve, reject) => { + this.ws = new WebSocket(BASE_DOMAIN); + this.ws.onmessage = (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'webRtcSignalingId') { + resolve(msg.uuid); + } else { + this.emit(msg); + } + } catch (e) { + console.error('Error parsing message', event.data); + } + }; + + this.ws.onclose = (ev) => { + console.trace('Ws closed', ev); + } + + this.ws.onerror = (err) => { + console.log('Error connecting to ws', err); + reject(err); + } + }) + } + + disconnect(): void { + console.trace('Closing ws'); + this.ws?.close(); + } + + send(data: Message): void { + this.ws?.send(JSON.stringify(data)); + } +} \ No newline at end of file diff --git a/src/model/Remote/Channels/type.ts b/src/model/Remote/Channels/type.ts new file mode 100644 index 0000000..ec4313b --- /dev/null +++ b/src/model/Remote/Channels/type.ts @@ -0,0 +1,131 @@ +import { VirtualFile } from "../../FileSystem/types"; + +export type PingMessage = { + type: 'ping'; + ts: number; +}; +export type PongMessage = { + type: 'pong'; +}; +export type SummonedMeessage = { + type: 'summoned'; + name: string; +}; + +export type ResolutionMessage = { + type: 'resolution'; + width: number; + height: number; +}; + +export type WebRTCSignalingConnect = { + type: 'RtcSignalingConnect'; + uuid: string; +} + +export type WebRTCOfferMessage = { + type: 'RtcOffer'; + offer: RTCSessionDescriptionInit; +}; + +export type WebRTCAnswerMessage = { + type: 'RtcAnswer'; + answer: RTCSessionDescriptionInit; +}; + +export type RtcIceMessage = { + type: 'RtcIce'; + candidate: RTCIceCandidate; +} + +export type FileTransferMessage = { + type: 'fileTransfer'; + channel: string; + size: number; + file: VirtualFile; +} + +export type FileTransferedMessage = { + type: 'fileTransfered'; + meta: VirtualFile; + blob: Blob; +} + +export type FileTransferProgressMessage = { + type: 'transferProgress'; + percent: number; +} + +export type ShowFileMessage = { + type: 'showFile'; + file: VirtualFile; +} + +export type DisconnectedMessage = { + type: 'disconnected'; +} + +export type ConnectedMessage = { + type: 'connected'; +} + +export type HelloMessage = { + type: 'hello'; + name?: string; +} + +export type TransformMessage = { + type: 'transform'; + x: number; + y: number; + scale: number; +} + +export type Message = PingMessage | + PongMessage | + SummonedMeessage | + ResolutionMessage | + WebRTCOfferMessage | + WebRTCAnswerMessage | + RtcIceMessage | + FileTransferMessage | + FileTransferedMessage | + FileTransferProgressMessage | + ShowFileMessage | + DisconnectedMessage | + ConnectedMessage | + HelloMessage | + WebRTCSignalingConnect; + +export type CasterMessage = PingMessage; +export type SummonMessage = PongMessage | SummonedMeessage | ResolutionMessage; + + +export interface JsonChannelCaster { + send(data: Message): void; + on(callback: (data: Message) => void): void; + disconnect(): void; +} + +export interface JsonChannelSummon { + send(data: Message): void; + on(callback: (data: Message) => void): void; +} + +export class MessageEmitter { + private listeners: ((data: Message) => void)[] = []; + + on(callback: (data: Message) => void) { + this.listeners.push(callback); + } + + protected emit(data: Message) { + for (const listener of this.listeners) { + listener(data); + } + } + + disconnect() { + this.listeners = []; + } +} \ No newline at end of file diff --git a/src/model/Remote/ChromecastDevice.ts b/src/model/Remote/ChromecastDevice.ts new file mode 100644 index 0000000..359b3bd --- /dev/null +++ b/src/model/Remote/ChromecastDevice.ts @@ -0,0 +1,97 @@ +import { VirtualFile } from "../FileSystem/types"; +import { PostMessageChannel } from "./Channels/PostMessage"; +import { WebRtcChannelCaster } from "./Channels/WebRTCChannel"; +import { WsSignalingCaster } from "./Channels/WsSignaling"; +import { RemoteDeviceManagerCtx } from "./RemoteDeviceManager"; +import { ImageSink, RemoteDevice } from "./types"; + +export class ChromecastDevice { + + public readonly type = 'chromecast'; + webRtcChannel?: WebRtcChannelCaster; + public name: string = 'Chromecast'; + public readonly hasImageSink = true; + public readonly imageSink? = new ChromecastImageSink(this); + public readonly audioSink: GainNode; + + constructor(private ctx: RemoteDeviceManagerCtx) { + this.audioSink = ctx.audioContext.createGain(); + } + + setup() { + this.imageSink?.setup(); + } + + get isConnected(): boolean { + return this.webRtcChannel !== null; + } + + async connect(): Promise { + return new Promise(async (resolve) => { + const channel = new WsSignalingCaster(); + const uuid = await channel.initialize(); + const win = window.open(`/chromecast/?ws=${uuid}`, '', 'width=200,height=200')!; + // const channel = new PostMessageChannel(win); + channel.on((msg) => { + console.log('CHROMECAST MESSAGE', msg) + // @ts-ignore + if (msg.type === 'webRtcSignalingNewClient') { + this.webRtcChannel = new WebRtcChannelCaster(channel, { + onOpen: () => { + win.close(); + resolve(this); + } + }, this.ctx); + this.audioSink.connect(this.webRtcChannel.sink); + } + if (msg.type == 'summoned') { + this.name = msg.name; + } + }); + + win.onbeforeunload = () => { + console.log('closed'); + channel.disconnect(); + } + + + }); + } +} + +class ChromecastImageSink implements ImageSink { + resolution: { width: number; height: number; } = {width: NaN, height: NaN}; + + constructor(private device: ChromecastDevice) { + + } + + setup() { + this.device.webRtcChannel?.on((msg)=> { + if (msg.type === 'resolution') { + this.resolution = msg; + } + console.log(msg); + }) + } + + get name(): string { + return this.device.name; + } + + async transferFile(meta: VirtualFile, data: Blob) { + await this.device.webRtcChannel?.sendBinary(meta, data); + } + + async show(file: VirtualFile) { + await this.device.webRtcChannel?.send({type: 'showFile', file}); + console.log('show', file); + } + + async transform({x, y, scale}: {x: number, y: number, scale: number}) { + await this.device.webRtcChannel?.send({ + type: 'transform', + x, y, scale + }); + } +} \ No newline at end of file diff --git a/src/model/Remote/FullscreenWindow.ts b/src/model/Remote/FullscreenWindow.ts new file mode 100644 index 0000000..f05bcd8 --- /dev/null +++ b/src/model/Remote/FullscreenWindow.ts @@ -0,0 +1,135 @@ +import { VirtualFile } from "../FileSystem/types"; +import { PostMessageChannel } from "./Channels/PostMessage"; +import { JsonChannelCaster } from "./Channels/type"; +import { WebRtcChannelCaster } from "./Channels/WebRTCChannel"; +import { RemoteDeviceManagerCtx } from "./RemoteDeviceManager"; +import { ImageSink, RemoteDevice, RemoteDeviceOptions } from "./types"; + +export interface FullscreenWindowConfig extends RemoteDeviceOptions { + type: 'fullscreen-window'; + screen: number; +} + +export class FullscreenWindowDevice implements RemoteDevice { + + public readonly type = 'fullscreen-window'; + private target: WindowProxy|null = null; + public jsonChannel?: JsonChannelCaster; + public webRtcChannel?: WebRtcChannelCaster; + public imageSink?: FullscreenWindowImageSink; + public readonly hasImageSink = true; + public readonly hasAudioSink = false; + public readonly audioSink: GainNode; + + constructor(private options: FullscreenWindowConfig, private ctx: RemoteDeviceManagerCtx) { + this.audioSink = ctx.audioContext.createGain(); + window.addEventListener('unload', () => { + this.disconnect(); + }); + } + + async connect() { + // @ts-ignore + const screenDetails = await window.getScreenDetails() + const {availHeight, availWidth, left, top} = screenDetails.screens[this.options.screen]; + this.target = window.open( + `/summon/?channel=postMessage&name=${this.options.name}`, + '', + `left=${left},top=${top},width=${availWidth},height=${availHeight},fullscreen=yes` + ); + this.connectJsonChannel(); + this.imageSink = new FullscreenWindowImageSink(this); + this.imageSink.setup(); + } + + private connectJsonChannel() { + if (this.webRtcChannel) { + this.webRtcChannel.disconnect(); + this.webRtcChannel = undefined; + } + if (this.jsonChannel) { + this.jsonChannel.disconnect(); + this.jsonChannel = undefined; + } + if (this.target) { + this.jsonChannel = new PostMessageChannel(this.target); + this.webRtcChannel = new WebRtcChannelCaster(this.jsonChannel, {}, this.ctx); + this.audioSink.connect(this.webRtcChannel.sink); + this.webRtcChannel.on((message) => { + if (message.type === 'disconnected') { + this.disconnect(); + } + }); + this.jsonChannel.on(console.log); + } + } + + async disconnect(): Promise { + if (this.target) { + this.target.close(); + this.target = null; + this.imageSink = undefined; + this.connectJsonChannel(); + } + } + + static async getDevices(): Promise { + // @ts-ignore + const screenDetails = await window.getScreenDetails() + return screenDetails.screens.map((screen: any, index: number) => ({ + name: `Screen ${index + 1}`, + type: "fullscreen-window", + screen: index + })); + } + + get isConnected(): boolean { + return this.target !== null; + } + + get name(): string { + return this.options.name; + } + + get config(): FullscreenWindowConfig { + return this.options; + } +} + +class FullscreenWindowImageSink implements ImageSink { + + public resolution = {width: NaN, height: NaN}; + + constructor(private device: FullscreenWindowDevice) {} + + setup() { + this.device.webRtcChannel?.on((msg) => { + if(msg.type === 'resolution') { + this.resolution = { + width: msg.width, + height: msg.height + } + } + }); + } + + get name(): string { + return this.device.name; + } + + async transferFile(meta: VirtualFile, data: Blob) { + await this.device.webRtcChannel?.sendBinary(meta, data); + } + + async show(file: VirtualFile) { + await this.device.webRtcChannel?.send({type: 'showFile', file}); + console.log('show', file); + } + + async transform({x, y, scale}: {x: number, y: number, scale: number}) { + await this.device.webRtcChannel?.send({ + type: 'transform', + x, y, scale + }); + } +} diff --git a/src/model/Remote/LocalAudioDevice.ts b/src/model/Remote/LocalAudioDevice.ts new file mode 100644 index 0000000..c83b504 --- /dev/null +++ b/src/model/Remote/LocalAudioDevice.ts @@ -0,0 +1,9 @@ +export class LocalAudioDevice { + audioSink: GainNode; + public readonly name = 'Local Audio'; + public readonly type = 'local'; + constructor(audioContext: AudioContext) { + this.audioSink = audioContext.createGain(); + this.audioSink.connect(audioContext.destination); + } +} \ No newline at end of file diff --git a/src/model/Remote/RemoteDeviceManager.ts b/src/model/Remote/RemoteDeviceManager.ts new file mode 100644 index 0000000..7013e20 --- /dev/null +++ b/src/model/Remote/RemoteDeviceManager.ts @@ -0,0 +1,105 @@ +import { ChromecastDevice } from "./ChromecastDevice"; +import { FullscreenWindowConfig, FullscreenWindowDevice } from "./FullscreenWindow"; +import { LocalAudioDevice } from "./LocalAudioDevice"; + +export type DeviceConfig = FullscreenWindowConfig; +export type Devices = FullscreenWindowDevice | ChromecastDevice; + +export interface RemoteDeviceManagerOptions { + devices: (DeviceConfig)[]; +} + +export interface RemoteDeviceManagerCtx { + audioContext: AudioContext; +} + + +export class RemoteDeviceManager { + + private _devices:Devices[] = []; + private localAudioDevice: LocalAudioDevice; + + constructor(private options: RemoteDeviceManagerOptions, private ctx: RemoteDeviceManagerCtx) { + for (const device of options.devices) { + this.createInstance(device); + } + + this.localAudioDevice = new LocalAudioDevice(ctx.audioContext); + } + + get devices(): Devices[] { + return this._devices; + } + + get audioDevices() { + return [ + ...this._devices.filter(d => d.audioSink), + this.localAudioDevice, + ]; + } + + private async createInstance(device: DeviceConfig): Promise { + if (device.type === 'fullscreen-window') { + const deviceInstance = new FullscreenWindowDevice(device, this.ctx); + this._devices.push(deviceInstance); + return deviceInstance; + } + } + + async add(device: DeviceConfig, connect: boolean = false): Promise { + console.log('add', device); + const instance = await this.createInstance(device); + if (!instance) { + return; + } + this.persist(); + if (connect) { + await instance.connect(); + } + } + + removeDevice(device: Devices) { + this._devices = this._devices.filter(d => d !== device); + this.persist() + } + + async availableDevices(): Promise { + return [ + ...(await FullscreenWindowDevice.getDevices()), + ]; + } + + async connectChromeCast() { + new ChromecastDevice(this.ctx) + .connect() + .then((dev) => { + this._devices.push(dev); + (this._devices.at(-1) as ChromecastDevice).setup(); + }) + } + + get config(): RemoteDeviceManagerOptions { + return { + ...this.options, + devices: this._devices + .map(d => { + // @ts-ignore + return d.config; + }) + .filter(d => d !== undefined) as DeviceConfig[] + }; + } + + persist() { + console.log('persist'); + localStorage.setItem('remote-devices', JSON.stringify(this.config)); + } + + static restore(ctx: RemoteDeviceManagerCtx): RemoteDeviceManager { + const data = localStorage.getItem('remote-devices'); + if (data) { + return new RemoteDeviceManager(JSON.parse(data), ctx); + } + return new RemoteDeviceManager({devices: []}, ctx); + } +} \ No newline at end of file diff --git a/src/model/Remote/types.ts b/src/model/Remote/types.ts new file mode 100644 index 0000000..aa79eb0 --- /dev/null +++ b/src/model/Remote/types.ts @@ -0,0 +1,32 @@ +import { VirtualFile } from "../FileSystem/types"; + +export interface RemoteDeviceOptions { + name: string; + type: string; +} + +export interface RemoteDevice { + name: string; + // audioSink?: AudioSink; + imageSink?: ImageSink; + audioSink: GainNode; + + isConnected: boolean; + connect(): Promise; + disconnect(): Promise; +} + + +export interface RemoteSink { + name: string; + transferFile(meta: VirtualFile, blob: Blob): void; +} + +export interface ImageSink extends RemoteSink { + resolution: { width: number, height: number }; + show(file: VirtualFile): void; +} + +// export interface AudioSink extends RemoteSink { +// play(file: VirtualFile): void; +// } \ No newline at end of file diff --git a/src/model/SoundPlayer.ts b/src/model/SoundPlayer.ts new file mode 100644 index 0000000..040f986 --- /dev/null +++ b/src/model/SoundPlayer.ts @@ -0,0 +1,60 @@ + + +export class SoundPlayer { + public sounds = ['sword slash', 'knock - wood']; + private ctx = new AudioContext(); + private cache = new Map(); + private cacheOptions = new Map(); + + constructor() { + + } + + async play(sound: string) { + if (this.sounds.includes(sound)) { + const options = await this.fetchSoundOptions(sound); + const randomOption = options[Math.floor(Math.random() * options.length)]; + const soundBuffer = await this.fetchSound(sound, randomOption); + this.playBuffer(soundBuffer); + console.log(`Playing sound: ${sound}`); + } else { + console.log(`Sound not found: ${sound}`); + } + } + + async fetchSoundOptions(sound: string) { + const url = `/soundfx/${sound}/`; + const dir = fetch(url) + .then((res) => res.json()) + .then((dir) => { + this.cacheOptions.set(url, dir.map(({name}: any) => name) as string[]); + }); + if (!this.cacheOptions.has(url)) { + await dir; + } + return this.cacheOptions.get(url)!; + } + + async fetchSound(sound: string, options: string) { + const url = `/soundfx/${sound}/${options}`; + if (this.cache.has(url)) { + return this.cache.get(url)!; + } + console.log(`Fetching sound: ${sound}/${options}`); + const arrayBuffer = await (await fetch(url)).arrayBuffer(); + const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer); + this.cache.set(url, audioBuffer); + return audioBuffer; + } + + async playBuffer(buffer: AudioBuffer) { + const source = this.ctx.createBufferSource(); + const panner = this.ctx.createStereoPanner(); + // panner.pan.value = Math.random() > 0.5 ? 1 : -1; + source.buffer = buffer; + source.connect(panner); + panner.connect(this.ctx.destination); + source.start(); + } +} + diff --git a/src/model/injectionSymbols.ts b/src/model/injectionSymbols.ts index f697898..3e37960 100644 --- a/src/model/injectionSymbols.ts +++ b/src/model/injectionSymbols.ts @@ -1,9 +1,18 @@ -import { InjectionKey } from "vue"; +import { InjectionKey, Reactive } from "vue"; import type { FileManager } from "./FileManager"; import { Receiver, Sender } from "./CommunicationChannel"; +import type { MountedFs } from "./FileSystem/MountedFs"; +import type { RemoteDeviceManager } from "./Remote/RemoteDeviceManager"; +import { JsonChannelSummon } from "./Remote/Channels/type"; +import { WebRtcChannelSummon } from "./Remote/Channels/WebRTCChannel"; export const FileManagerSymbol = Symbol("FileManager") as InjectionKey; +export const RemoteDeviceManagerSymbol = Symbol("RemoteDeviceManager") as InjectionKey>; +export const MountedFsSymbol = Symbol("MountedFs") as InjectionKey; +export const AudioContextSymbol = Symbol("AudioContext") as InjectionKey; export const CastTypeSymbol = Symbol("CastType") as InjectionKey<"sender" | "receiver">; export const SenderSymbol = Symbol('Sender') as InjectionKey; -export const ReceiverSymbol = Symbol('Receiver') as InjectionKey; \ No newline at end of file +export const ReceiverSymbol = Symbol('Receiver') as InjectionKey; +export const JsonChannelSymbol = Symbol('JsonChannel') as InjectionKey; +export const WebRtcChannelSymbol = Symbol('WebRtcChannelSymbol') as InjectionKey; \ No newline at end of file diff --git a/src/modules/controls/ControlApp.vue b/src/modules/controls/ControlApp.vue index 8069f6f..789b337 100644 --- a/src/modules/controls/ControlApp.vue +++ b/src/modules/controls/ControlApp.vue @@ -1,26 +1,46 @@ + \ No newline at end of file diff --git a/src/modules/summon/SummonApp.vue b/src/modules/summon/SummonApp.vue new file mode 100644 index 0000000..18e9ed3 --- /dev/null +++ b/src/modules/summon/SummonApp.vue @@ -0,0 +1,68 @@ + + \ No newline at end of file diff --git a/src/summon.ts b/src/summon.ts new file mode 100644 index 0000000..e2bc64c --- /dev/null +++ b/src/summon.ts @@ -0,0 +1,58 @@ +import { createApp } from 'vue'; +import './style.css'; +import { JsonChannelSymbol, WebRtcChannelSymbol } from './model/injectionSymbols'; +import { PostMessageChannel } from './model/Remote/Channels/PostMessage'; +import SummonApp from './modules/summon/SummonApp.vue'; +import { immediate } from './helpers/immediate'; +import { WebRtcChannelSummon } from './model/Remote/Channels/WebRTCChannel'; +import { loadScript } from './helpers/loadScript'; +import { ChromecastChannelSummon } from './model/Remote/Channels/ChromcastSummon'; +import { WsSignalingSummon } from './model/Remote/Channels/WsSignaling'; + +const app = createApp(SummonApp) + +const query = new URLSearchParams(location.search); +let name = query.get('name') ?? 'Unknown'; +app.provide('name', name); + +const channel = (() => { + if (query.get('channel') === 'postMessage') { + const channel = new PostMessageChannel(window) + channel.send({type: 'summoned', name}); + return channel; + } else if (query.get('channel') === 'chromecast') { + const chromeCastChannel = new ChromecastChannelSummon(); + const channel = new WsSignalingSummon(); + + chromeCastChannel.initialize(); + chromeCastChannel.on(async (msg) => { + if (msg.type === 'hello') { + name = msg.name ?? 'Chromecast'; + } + if (msg.type === 'RtcSignalingConnect') { + channel.disconnect(); + await channel.connect(msg.uuid); + channel.send({type: 'summoned', name}); + } + }); + return channel; + } +})(); + +if (channel) { + const webRtcChannel = new WebRtcChannelSummon(channel); + app.provide(WebRtcChannelSymbol, webRtcChannel); + app.provide(JsonChannelSymbol, channel); + + webRtcChannel.on((msg) => { + if (msg.type === 'connected') { + webRtcChannel.send({ + type: 'resolution', + width: window.innerWidth * window.devicePixelRatio, + height: window.innerHeight * window.devicePixelRatio, + }); + } + }) +} + +app.mount('#app') diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..1407828 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,16 @@ +export function debounce void>(func: T, wait: number, immediate: boolean = false): T { + let timeout: number | NodeJS.Timeout | undefined; + return function (this: any, ...args) { + var context = this; + var later = function () { + timeout = undefined; + if (!immediate) + func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) + func.apply(context, args); + } as T; +} \ No newline at end of file diff --git a/src/utils/groupBy.ts b/src/utils/groupBy.ts new file mode 100644 index 0000000..466ec28 --- /dev/null +++ b/src/utils/groupBy.ts @@ -0,0 +1,11 @@ +export function groupBy(arr: T[], key: (item: T) => TK): Map { + const map = new Map(); + for (const item of arr) { + const group = key(item); + if (!map.has(group)) { + map.set(group, []); + } + map.get(group)!.push(item); + } + return map; +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/summon/index.html b/summon/index.html new file mode 100644 index 0000000..297ae55 --- /dev/null +++ b/summon/index.html @@ -0,0 +1,13 @@ + + + + + + + Summon | BattleCaster + + +
+ + + diff --git a/vite.config.ts b/vite.config.ts index 103285b..ebe425a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' + // https://vitejs.dev/config/ export default defineConfig({ build: { @@ -9,9 +10,14 @@ export default defineConfig({ input: { main: resolve(__dirname, 'index.html'), map: resolve(__dirname, 'map/index.html'), + summon: resolve(__dirname, 'summon/index.html'), + chromecast: resolve(__dirname, 'chromecast/index.html'), }, }, }, + define: { + 'import.meta.env.CHROMECAST_APP_ID': JSON.stringify(process.env.VITE_CHROMECAST_APP_ID) + }, plugins: [ vue({ template: { @@ -23,8 +29,10 @@ export default defineConfig({ ], server: { host: '0.0.0.0', + cors: true, proxy: { - '/maps': 'http://localhost:8080' + '/maps': 'http://localhost:8080', + '/soundfx': 'http://localhost:8080' } } })