sounds, better files

This commit is contained in:
2024-08-18 19:11:41 +00:00
parent 9ed2f70701
commit 08489ed67b
86 changed files with 4226 additions and 86 deletions

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
#VITE_CHROMECAST_APP_ID=0D9CC4B6
VITE_CHROMECAST_APP_ID=AB5F6AA2

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_CHROMECAST_APP_ID=0D9CC4B6

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
maps
public/maps/*
# Editor directories and files

21
Dockerfile Normal file
View File

@@ -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

View File

@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
[x] - basic map casting to second tab
[x] - scaling maps
[ ] - remember map scale for previously open maps
[ ] - fog of war
[x] - basic soundboard
[ ] - load folder with sounds
[ ] - bind sounds to keys
[ ] - rework file manager to support auth
[ ] - split app to public cloud hosted part and local file hosting
[ ] - support local filesystem api
[ ] - wled sound effect sync?
[ ] - caster mini app to sync multiple chromecast to single tab.

16
chromecast/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chromecast | BattleCaster</title>
</head>
<body>
<div style="max-width: 128px; height: 128px; overflow: hidden; background-color: white; border-radius: 4px;padding: 5px">
<google-cast-launcher style="--disconnected-color: black;" ></google-cast-launcher>
</div>
<button id="hail">Hail</button>
<script type="module" src="/src/chromecast.ts"></script>
</body>
</html>

View File

@@ -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

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vite + Vue + TS</title>
<title>BattleCaster</title>
</head>
<body>
<div id="app"></div>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vite + Vue + TS</title>
<title>MAP | BattleCaster</title>
</head>
<body>
<div id="app"></div>

View File

@@ -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;
}

35
src/assets/main.css Normal file
View File

@@ -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);
}
}

174
src/assets/theme.css Normal file
View File

@@ -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);
}
}

23
src/chromecast.ts Normal file
View File

@@ -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();
})

View File

@@ -0,0 +1,237 @@
<template>
<div
class="a-card"
:class="{ 'a-card--playing': node.isPlaying }"
@drop.prevent="onDrop"
@dragover.prevent="onDragover"
>
<div class="a-card__label-wrapper">
<div class="a-card__label">{{ label }}</div>
</div>
<div class="a-card__volume">
<span style="height: 1.25rem" @click="node.toggleMute()">
<Mute v-if="node.isMuted" style="height: 1.25rem" />
<Speaker v-else style="height: 1.25rem" />
</span>
<input type="range" min="0" max="100" v-model="node.volume" style="width: 100%;" />
</div>
<div class="a-card__content" @click="openPopover">
<slot name="preview"></slot>
</div>
<div class="a-card__controls">
<Backward v-if="supports?.includes('prev')" style="height: 1rem;" @click="$emit('prev')" />
<div class="a-card__play-button"
v-if="supports?.includes('togglePlay') || (supports?.includes('stop') && (node.isPlaying || supports?.includes('play')))"
>
<template v-if="supports?.includes('togglePlay')">
<Play v-if="!node.isPlaying" style="height: 1.5rem" @click="node.togglePlay()" />
<Pause v-if="node.isPlaying" style="height: 1.5rem" @click="node.togglePlay()" />
</template>
<template v-else>
<Play v-if="!node.isPlaying && supports?.includes('play')" style="height: 1.5rem" @click="node.togglePlay()" />
<Stop v-if="node.isPlaying && supports?.includes('stop')" style="height: 1.5rem" @click="node.togglePlay()" />
</template>
</div>
<Forward v-if="supports?.includes('next')" style="height: 1rem" @click="$emit('next')" />
</div>
<div class="a-card__popover" popover ref="popover">
<DeviceVolume
class="a-card__popover-volumes"
:deviceSource="(device) => node.getDeviceSource(device).gainNode"
/>
<slot name="popover"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import {mergeProps, ref} from 'vue';
import {SoundGroupNode} from '../../model/Audio/SoundGroupNode';
import Play from '../Icons/Play.vue';
import Backward from '../Icons/Backward.vue';
import Forward from '../Icons/Forward.vue';
import Mute from '../Icons/Mute.vue';
import Speaker from '../Icons/Speaker.vue';
import Pause from '../Icons/Pause.vue';
import DeviceVolume from './DeviceVolume.vue';
import Stop from '../Icons/Stop.vue';
const emits = defineEmits<{
'next': [],
'prev': [],
'togglePlay': [],
'update:label': [string]
}>();
type Supports = 'next' | 'prev' | 'togglePlay' | 'stop' | 'play';
const props = withDefaults(defineProps<{
node: SoundGroupNode<any>
supports?: Supports[]
label?: string,
noDetail?: boolean
}>(), {
// @ts-ignore
supports: [],
label: 'Audio',
noDetail: false
});
const popover = ref<HTMLElement>();
function openPopover() {
if (props.noDetail) {
return;
}
popover.value?.showPopover();
}
function onDragover(event: DragEvent) {
const data = event.dataTransfer?.getData('application/json');
console.log('dragover', event, '>', data, '<', typeof data);
event.preventDefault();
}
function onDrop(event: DragEvent) {
console.log('drop', event, event.dataTransfer?.getData('application/json'));
event.preventDefault();
if (event.dataTransfer?.getData('application/json')) {
const meta = JSON.parse(event.dataTransfer?.getData('application/json'));
if (meta.type === 'file') {
console.log('got file', meta);
props.node.addSound(meta);
}
}
}
</script>
<style>
@layer component {
@property --beat-color {
syntax: "<color>";
inherits: true;
initial-value: transparent;
}
.a-card {
--box-color: var(--audio-box-color-playlist, #ffb308);
--box-color-900: color-mix(in lab, var(--box-color), #000 80%);
--box-color-100: color-mix(in lab, var(--box-color), #fff 80%);
--box-color-300: color-mix(in lab, var(--box-color), #fff 40%);
width: 160px;
aspect-ratio: 4/5;
border: 1px solid rgba(from var(--box-color) r g b / 0.15);
padding: 4px;
background: linear-gradient(45deg, var(--box-color-900), color-mix(in lab, var(--box-color-900), #000 60%));
box-shadow: inset 10px -10px 30px 5px rgba(from var(--box-color-300) r g b / 0.05);
color: var(--box-color-100);
border-radius: 8px;
display: flex;
flex-direction: column;
--beat-color: rgba(from var(--box-color) r g b / 0);
transition: --beat-color linear 1s;
box-shadow:
inset 10px -10px 30px 5px rgba(from var(--box-color-300) r g b / 0.05),
0 0 var(--beat-color),
0 0 0 8px var(--beat-color),
0 0 0 16px var(--beat-color);
}
.a-card--playing {
--beat-color: rgba(from var(--box-color) r g b / 0.3);/*color-mix(in lab, var(--box-color), #000 60%);*/
animation: ripples 1s linear infinite;
}
.a-card__controls {
height: 1.5rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width:100%;
margin-bottom: 1rem;
}
.a-card__play-button {
width: 2rem;
height: 2rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
display: inline-flex;
justify-content: center;
align-items: center;
background: var(--box-color);
color: var(--box-color-900);
border-radius: 50%;
}
.a-card input {
accent-color: var(--box-color);
}
.a-card__volume {
display: flex;
flex-direction: row;
padding: 0rem 0.5rem
}
.a-card__label-wrapper {
width: 100%;
text-align: center;
margin-top: -1rem;
padding-bottom: 1rem;
}
.a-card__label {
display: inline-block;
width: 80%;
text-align: center;
border: 1px solid rgba(from var(--box-color) r g b / 0.2);
background: var(--box-color-900);
color: var(--box-color-100);
font-size: 1rem;
font-weight: semibold;
padding: 0.125rem;
border-radius: 6px;
}
.a-card__content {
flex: 1;
display: flex;
flex-direction: column;;
border-radius: 4px;
justify-content: center;
align-items: center;
cursor: pointer;
}
.a-card__popover {
height: calc(100% - 10rem);
width: calc(100% - 10rem);
background: var(--box-color-900);
color: var(--box-color-100);
padding: 1rem;
border-radius: 4px;
border: 2px solid rgba(from var(--box-color) r g b / 0.2);
background: linear-gradient(45deg, var(--box-color-900), color-mix(in lab, var(--box-color-900), #000 60%));
box-shadow: inset 10px -10px 30px 5px rgba(from var(--box-color-300) r g b / 0.05);
}
.a-card__popover-volumes {
margin-bottom: 1rem;
}
}
@keyframes ripples {
to {
box-shadow:
inset 10px -10px 30px 5px rgba(from var(--box-color-300) r g b / 0.05),
0 0 0 8px var(--beat-color),
0 0 0 16px var(--beat-color),
0 0 0 32px rgba(from var(--beat-color) r g b / 0);
}
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div class="device-volumes">
<div
v-for="{device, audioSink} in devicesWithSink"
class="device-sink theme contrast frosted"
:class="{
'device-sink--chromecast': device.type === 'chromecast',
'device-sink--second-screen': device.type === 'fullscreen-window',
'device-sink--local': device.type === 'local',
}"
>
<span>
{{ device.name }}
</span>
<input type="range" min="0" max="1" step="0.01" v-model="audioSink.gain.value">
</div>
</div>
</template>
<script setup lang="ts">
import {computed, inject} from 'vue';
import {RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
import {RemoteDevice} from '../../model/Remote/types';
import {LocalAudioDevice} from '../../model/Remote/LocalAudioDevice';
const props = withDefaults(defineProps<{
deviceSource?: (device: RemoteDevice | LocalAudioDevice) => GainNode
}>(), {
deviceSource: (device: RemoteDevice | LocalAudioDevice) => device.audioSink
});
const devicesWithSink = computed(() => {
return deviceManager.audioDevices.map((device) => ({
device,
audioSink: props.deviceSource(device)
}));
});
const deviceManager = inject(RemoteDeviceManagerSymbol)!
</script>
<style>
.device-volumes {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
}
.device-sink {
--color-theme: var(--device-color);
display: inline-flex;
padding: var(--s-2);
border-radius: var(--border-radius);
flex-direction: column;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-2);
}
.device-sink--chromecast {
--device-color: var(--color-sky);
}
.device-sink--second-screen {
--device-color: var(--color-emerald);
}
.device-sink--local {
--device-color: var(--color-rose);
}
.device-sink input {
accent-color: var(--device-color);
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<AudioCard
:node="node"
:supports="[ 'next', 'prev', 'togglePlay' ]"
@next="node.next()"
@prev="node.prev()"
label="Playlist test"
style="--box-color: var(--color-amber)"
>
<template #preview>
<div>{{ node.currentTrack?.name ?? 'No track selected' }}</div>
</template>
<template #popover>
<div>
{{ node.currentTrack?.name ?? 'No track selected' }}
<div>
<input type="range" min="0" max="100" style="width: 100%" />00:00
</div>
</div>
<div
class="a-playlist__song"
v-for="(song, idx) in node.sounds()"
:key="song.options.vFile.name"
@click="playTrack(idx)"
>
<span>{{ song.options.vFile.name }}</span>
<span>00:00</span>
</div>
</template>
</AudioCard>
</template>
<script setup lang="ts">
import AudioCard from './AudioCard.vue';
import {PlaylistGroupNode} from '../../model/Audio/PlaylistGroupNode';
const props = defineProps<{
node: PlaylistGroupNode
}>();
// playlist.connect(audioContext.destination);
function playTrack(idx: number) {
props.node.changeTrack(idx);
}
</script>
<style>
@layer component {
.a-playlist {
--box-color: var(--audio-box-color-playlist, #f59e0b);
--box-color-900: color-mix(in lab, var(--box-color), #000 80%);
--box-color-100: color-mix(in lab, var(--box-color), #fff 80%);
--box-color-300: color-mix(in lab, var(--box-color), #fff 40%);
width: 160px;
aspect-ratio: 4/5;
border: 2px solid rgba(from var(--box-color) r g b / 0.2);
padding: 4px;
background: var(--box-color-900);
color: var(--box-color-100);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.a-playlist input {
accent-color: var(--box-color);
}
.a-playlist__top, .a-playlist__bottom {
height: 1.5rem;
text-align: center;
}
.a-playlist__content {
flex: 1;
display: flex;
flex-direction: column;;
border-radius: 4px;
justify-content: center;
align-items: center;
}
.a-playlist__popover {
height: calc(100% - 10rem);
width: calc(100% - 10rem);
background: var(--box-color-900);
color: var(--box-color-100);
border-radius: 4px;
border: 2px solid var(--box-color-300);
}
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<AudioCard
:style="{
'--box-color': 'var(--color-fuchsia)'
}"
:supports="['stop']"
label="SFX"
:node="node"
>
<template #preview>
<div
v-for="sound in node.activeSounds"
>
{{ sound.name }}
</div>
</template>
<template #popover>
<div class="a-sfx__popover">
<SfxSound
v-for="[name, sounds] in grouppedSounds"
:key="name"
:sounds="sounds"
:name="name"
/>
</div>
</template>
</AudioCard>
</template>
<script setup lang="ts">
import {computed} from 'vue';
import {SfxGroupNode} from '../../model/Audio/SfxGroupNode';
import AudioCard from './AudioCard.vue';
import {groupBy} from '../../utils/groupBy';
import SfxSound from './SfxSound.vue';
const props = defineProps<{
node: SfxGroupNode,
}>();
const grouppedSounds = computed(() => {
return groupBy(props.node.sounds(), (sound) => {
return sound.name.replace(/(\d+)$/, '').trim();
})
});
</script>
<style>
.a-sfx__popover {
--box-size: 160px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--box-size), 1fr));
gap: var(--s-5)
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div
class="theme a-sfx-sound contrast frosted"
:class="{ 'a-sfx-sound--playing': isPlaying }"
@click="playRandomSound(sounds)"
>
<div class="a-sfx-sound__controls">
<Stop style="height: 2rem" v-if="isPlaying" @click.stop="stop" />
</div>
<div class="a-sfx-sound__name" >
{{ name }}
</div>
<template v-if="sounds.length > 1">
<div class="a-sfx-sound__alternatives">
<span
v-for="sound in sounds"
:key="sound.name"
class="a-sfx-sound__alternative"
@click.stop="sound.play()"
>
{{ getSoundNumber(sound) }}
</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue';
import {SoundNode} from '../../model/Audio/SoundNode';
import Stop from '../Icons/Stop.vue';
const props = defineProps<{
sounds: SoundNode[],
name: string,
}>();
const getSoundNumber = (sound: SoundNode) => {
const match = sound.name.match(/(\d+)$/);
return match ? parseInt(match[1]) : 0;
}
const playRandomSound = (sounds: SoundNode[]) => {
const sound = sounds[Math.floor(Math.random() * sounds.length)];
sound.play();
}
const isPlaying = computed(() => {
return props.sounds.some(sound => sound.isPlaying);
});
const stop = () => {
props.sounds.forEach(sound => sound.stop());
}
</script>
<style>
.a-sfx-sound {
width: 100%;
height: var(--box-size);
--color-theme: var(--box-color);
position: relative;
/* aspect-ratio: 1/1; */
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
border-radius: var(--border-radius);
user-select: none;
cursor: pointer;
}
.a-sfx-sound__controls {
position: absolute;
top: 0;
right: 0;
padding: 0.5rem;
}
.a-sfx-sound__alternatives {
position: absolute;
bottom: 0;
right: 0;
left: 0;
color: var(--color-contrast-700);
display: flex;
flex-direction: row;
gap: var(--s-0_5);
padding: var(--s-0_5);
}
.a-sfx-sound__alternative {
flex: 1;
padding: 0.5rem;
text-align: center;
color: var(--color-contrast-700);
border: 1px solid var(--color-contrast-50);
background-color: rgba(from var(--color-theme-950) r g b / 0.4);
border-radius: var(--border-radius-sm);
}
.a-sfx-sound__name {
text-align: center;
color: var(--color-contrast-900);
font-weight: 400;
padding: var(--s-1) var(--s-2);
}
.a-sfx-sound {
--beat-color: rgba(from var(--box-color) r g b / 0);
transition: --beat-color linear 1s;
box-shadow:
inset 10px -10px 30px 5px rgba(from var(--box-color-300) r g b / 0.05),
0 0 var(--beat-color),
0 0 0 8px var(--beat-color),
0 0 0 16px var(--beat-color);
}
.a-sfx-sound--playing {
--beat-color: rgba(from var(--box-color) r g b / 0.3);/*color-mix(in lab, var(--box-color), #000 60%);*/
animation: ripples 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="soundboard">
<div class="soundboard__controls">
<DeviceVolume />
</div>
<div class="soundboard__sounds" style="--color-theme: var(--color-lime)">
<template v-for="node in nodes" :key="node">
<Playlist
v-if="node.type === 'playlist'"
:node="node"
/>
<Sfx
v-else-if="node.type === 'sfx'"
:node="node"
/>
<TabSource
v-else-if="node.type === 'tabSource'"
:node="node"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {inject, reactive, ref, watch} from 'vue';
import Playlist from './Playlist.vue';
import {PlaylistGroupNode} from '../../model/Audio/PlaylistGroupNode';
import {AudioContextSymbol, MountedFsSymbol, RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
import {SfxGroupNode} from '../../model/Audio/SfxGroupNode';
import Sfx from './Sfx.vue';
import Modal from './modal.vue';
import {TabSourceNode} from '../../model/Audio/TabSourceNode';
import TabSource from './TabSource.vue';
import DeviceVolume from './DeviceVolume.vue';
import {AudioNodeManager} from '../../model/Audio/AudioNodesManager';
import {debounce} from '../../utils/debounce';
const fs = inject(MountedFsSymbol)!;
const audioContext = inject(AudioContextSymbol) as AudioContext;
const deviceManager = inject(RemoteDeviceManagerSymbol)!;
const nodesDefinition = JSON.parse(localStorage.getItem('soundboard') ?? '[]');
const audioNodesManager = new AudioNodeManager();
// const playlist = reactive(new PlaylistGroupNode({
// currentTrack: 0,
// currentTrackTime: 0,
// volume: 100,
// isMuted: false,
// sounds: [],
// }, {
// fs,
// audioContext,
// }))
// const sfx = reactive(new SfxGroupNode({
// isMuted: false,
// volume: 100,
// sounds: [],
// }, {
// fs,
// audioContext,
// }))
// const tabSource = reactive(new TabSourceNode({
// isMuted: false,
// volume: 100,
// sounds: [],
// }, {
// fs,
// audioContext,
// }))
const nodes = reactive(audioNodesManager.restore({fs, audioContext}));
watch(() => nodes, debounce((newNodes) => {
console.log('NEW_NODES', newNodes);
audioNodesManager.store(newNodes);
}, 500), {
deep: true,
})
const dest = audioContext.createGain();
// for (const node of nodes) {
// // node.connect(audioContext.destination);
// node.connect(dest);
// }
watch(() => deviceManager.audioDevices.length, () => {
console.log('DEVICE_MANAGER', deviceManager)
for(const device of deviceManager.audioDevices) {
dest.connect(device.audioSink);
console.log('DEVICES', device, device.audioSink);
}
}, {immediate: true})
</script>
<style>
.soundboard {
}
.soundboard__controls {
display: flex;
flex-direction: row;
padding: 0 1rem 2rem 1rem;
gap: 1rem;
}
.soundboard__sounds {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 3rem 1rem;
padding: 1rem;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<AudioCard
:style="{
'--box-color': 'var(--color-red)'
}"
:supports="['play', 'stop']"
label="Tab Audio"
:node="node"
>
</AudioCard>
</template>
<script setup lang="ts">
import {TabSourceNode} from '../../model/Audio/TabSourceNode';
import AudioCard from './AudioCard.vue';
const props = defineProps<{
node: TabSourceNode,
}>();
</script>

View File

@@ -0,0 +1,69 @@
<template>
<dialog class="dialog" ref="dialog" @close="onClose" @keydown.esc="onCancel">
<div class="dialog-main">
<slot></slot>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ac ante mollis, fermentum nunc nec, ultricies nunc. Donec
</div>
<div class="dialog-aside">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ac ante mollis, fermentum nunc nec, ultricies nunc. Donec
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const dialog = ref<HTMLDialogElement>();
const popover = ref<HTMLDivElement>();
const props = defineProps<{
open: boolean,
preventClose: boolean,
}>();
const emit = defineEmits<{
'update:open': [boolean]
}>();
function onCancel(event: Event) {
if (props.preventClose) {
event.preventDefault();
}
}
function onClose(event: Event) {
emit('update:open', false);
}
watch(() => props.open, (open) => {
if (open) {
console.log(dialog.value);
dialog.value?.showModal();
popover.value?.showPopover();
} else {
dialog.value?.close();
}
}, {immediate: true});
</script>
<style>
.dialog[open] {
display: grid;
max-width: 800px;
grid-template-columns: 2fr 1fr;
gap: 4rem;
background: none;
border: none;
pointer-events: visible;
}
.dialog[open]::backdrop {
background: rgba(from red r g b / 0.5);
backdrop-filter: blur(20px);
}
.dialog-main, .dialog-aside {
padding: 1rem;
background: white;
border: 5px solid black;
outline: none;
}
</style>

View File

@@ -1,19 +1,77 @@
<template>
<ul class="file-manager-dir">
<FileManagerDir :dir="fileManager.root" @openFile="$emit('openFile', $event)"></FileManagerDir>
</ul>
<div>
<button
@click="buildIndex"
:disabled="buildingIndex"
>
<template v-if="buildingIndex">building {{ indexCounter }}</template>
<template v-else>Build Index</template>
</button>
<input type="search" placeholder="search" v-model="search">
<ul
v-if="searchResults.length > 0"
class="file-manager-dir"
>
<FileManagerFile
v-for="file in searchResults"
:key="file.name"
:file="file"
@openFile="$emit('openFile', $event)"
/>
</ul>
<ul
v-else
class="file-manager-dir"
>
<FileManagerDir
v-for="dir in fs.root"
:key="dir.name"
:fs="fs"
:dir="dir"
@openFile="$emit('openFile', $event)"
/>
</ul>
</div>
</template>
<script setup lang="ts">
import {inject} from 'vue';
import {FileManagerSymbol} from '../../model/injectionSymbols';
import {inject, ref, watch} from 'vue';
import {MountedFsSymbol} from '../../model/injectionSymbols';
import FileManagerDir from './FileManagerDir.vue';
import type {FileWithMeta} from '../../model/FileManager';
import {VirtualFile} from '../../model/FileSystem/types';
import FileManagerFile from './FileManagerFile.vue';
const fileManager = inject(FileManagerSymbol)!;
const fs = inject(MountedFsSymbol)!;
const emit = defineEmits<{
'openFile': [value: FileWithMeta]
'openFile': [value: VirtualFile]
}>();
const search = ref('');
let searchAbort: AbortController;
const searchResults = ref<VirtualFile[]>([]);
watch(search, async (value) => {
searchResults.value = [];
if (value === '') {
return;
}
if (searchAbort) {
searchAbort.abort();
}
searchAbort = new AbortController();
for await (let result of fs.search(value, searchAbort.signal)) {
searchResults.value.push(result);
}
});
const buildingIndex = ref(false);
const indexCounter = ref(0);
async function buildIndex() {
buildingIndex.value = true;
for await(let counter of fs.buildIndex()) {
indexCounter.value = counter;
}
}
</script>
<style>

View File

@@ -4,45 +4,50 @@
@click="toggleCollapse"
class="file-manager-dir__label"
:class="{ 'file-manager-dir__label--open': !isCollapsed }"
>{{ dir.name }}/ <template v-if="isLoading">(loading)</template></span>
<ul v-if="dir.files" v-show="!isCollapsed" class="file-manager-dir">
>{{ dir.name }}/ <template v-if="isLoading"></template></span>
<ul v-if="content" v-show="!isCollapsed" class="file-manager-dir">
<template
v-for="file in dir.files"
v-for="file in content"
:key="file.name"
>
<FileManagerDir
v-if="file.type === 'directory'"
v-if="file.type === 'directory'"
:dir="file"
@openFile="$emit('openFile', $event)"
/>
<FileManagerFile v-else :file="file" @openFile="$emit('openFile', $event)" />
<FileManagerFile
v-else
:file="file"
@openFile="$emit('openFile', $event)"
/>
</template>
</ul>
</li>
</template>
<script setup lang="ts">
import {inject, ref} from 'vue';
import {DirectoryWithFiles, FileWithMeta} from '../../model/FileManager';
import FileManagerFile from './FileManagerFile.vue';
import FileManagerDir from './FileManagerDir.vue';
import {FileManagerSymbol} from '../../model/injectionSymbols';
import {MountedFsSymbol} from '../../model/injectionSymbols';
import {VirtualDirectory, VirtualFile} from '../../model/FileSystem/types';
const fileManager = inject(FileManagerSymbol)!;
const fs = inject(MountedFsSymbol)!;
const isLoading = ref(false);
const isCollapsed = ref(true);
const emit = defineEmits<{
'openFile': [value: FileWithMeta]
'openFile': [value: VirtualFile]
}>();
const content = ref<(VirtualFile | VirtualDirectory)[]>([]);
const props = defineProps<{
dir: DirectoryWithFiles;
dir: VirtualDirectory;
}>();
async function toggleCollapse() {
if (isCollapsed) {
await fetchDirectoryContent();
}
isCollapsed.value = !isCollapsed.value;
}
@@ -50,7 +55,7 @@ async function toggleCollapse() {
async function fetchDirectoryContent() {
try {
isLoading.value = true;
await fileManager?.fetchDirectoryContent(props.dir);
content.value = await fs.listDir(props.dir);
} finally {
isLoading.value = false;
}

View File

@@ -1,34 +1,64 @@
<template>
<li class="file-manager-file">
<span @click="$emit('openFile', file)">
<li class="file-manager-file" v-if="isValid || true">
<div
draggable="true"
class="file-manager-file__wrapper"
@click="$emit('openFile', file)"
@dragstart="onDragStart"
>
{{ file.name }}
<span v-if="isAnimated" class="file-manager-file__animated">A</span>
<span
v-if="isValid"
class="file-manager-file__size"
:class="{ 'file-manager-file__size--unknown': !file.width || !file.height }"
:class="{ 'file-manager-file__size--unknown': formattedSize === unknownFileSize }"
>
{{ formattedSize }}
</span>
</span>
</span>
</div>
</li>
</template>
<script setup lang="ts">
import {FileWithMeta} from '../../model/FileManager';
import {VirtualFile} from '../../model/FileSystem/types';
import {addMapMeta} from '../../model/FileMeta/MapMeta';
import {computed} from 'vue';
const unknownFileSize = '??x??';
const props = defineProps<{
file: FileWithMeta;
file: VirtualFile;
}>();
const emit = defineEmits<{
'openFile': [value: FileWithMeta]
'openFile': [value: VirtualFile]
}>();
const withMeta = computed(() => {
return addMapMeta(props.file);
});
const isAnimated = computed(() => {
return withMeta.value.map.animated;
});
const isValid = computed(() => {
return withMeta.value.map.valid;
});
function onDragStart(event: DragEvent) {
event.dataTransfer?.setData('application/json', JSON.stringify(withMeta.value));
}
const formattedSize = computed(() => {
if (props.file.width && props.file.height) {
return props.file.width + 'x' + props.file.height;
const {width, height} = withMeta.value.map;
if (width && height) {
return width + 'x' + height;
} else if (width) {
return width + 'x?';
} else if (height) {
return '?x' + height;
} else {
return '??x??';
return unknownFileSize;
}
});
@@ -38,6 +68,14 @@ const formattedSize = computed(() => {
cursor: pointer;
}
.file-manager-file__wrapper {
cursor: pointer;
display:inline-block;
margin: -2px;
padding: 2px;
user-select: none;
}
.file-manager-file__size {
display: inline;
padding: 0em 0.25em;
@@ -46,8 +84,21 @@ const formattedSize = computed(() => {
color: white;
}
.file-manager-file__animated {
display: inline;
padding: 0em 0.25em;
border-radius: 4px;
background-color: #07b480;
color: white;
}
.file-manager-file__size--unknown {
background-color: #801e1e;
}
.file-manager-file__wrapper:hover {
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.3);
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="files">
<div @click="openAside" style="cursor: pointer;">Files</div>
<div
popover
ref="popover"
class="files__aside"
:class="{ 'files__aside--dragging': dragging }"
>
<div
class="files__aside-content"
ref="content"
@dragstart="onDragStart"
@dragleave="onDragLeave"
@dragover.prevent
>
<FileManager />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {onUnmounted, ref} from 'vue';
import FileManager from '../FileManager/FileManager.vue';
const popover = ref<HTMLDivElement>()
const content = ref<HTMLDivElement>()
function openAside(){
popover.value?.showPopover();
}
const dragging = ref(false);
function onDragStart(){
console.log('dragstart');
dragging.value = true;
}
function onDragLeave(event) {
if (!content.value?.contains(event.relatedTarget)) {
popover.value?.hidePopover();
}
}
function onDragEnd(){
console.log('dragend');
dragging.value = false;
}
window.addEventListener('dragend', onDragEnd, {passive: true});
onUnmounted(() => {
window.removeEventListener('dragend', onDragEnd);
})
</script>
<style>
.files__aside {
--animation-duration: 200ms;
position: fixed;
inset: auto;
height: auto;
left: 0;
top: 10px;
bottom: 10px;
width: 500px;
max-width: 100vw;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
transform: translateX(-100%);
border: none;
transition: all var(--animation-duration) allow-discrete;
}
.files__aside-content {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
}
.files__aside:popover-open {
transform: translateX(0);
transition-behavior: allow-discrete;
}
@starting-style {
.files__aside:popover-open {
transform: translateX(-100%);
}
}
.files__aside--dragging:popover-open {
transform: translateX(-25%);
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path d="M9.195 18.44c1.25.714 2.805-.189 2.805-1.629v-2.34l6.945 3.968c1.25.715 2.805-.188 2.805-1.628V8.69c0-1.44-1.555-2.343-2.805-1.628L12 11.029v-2.34c0-1.44-1.555-2.343-2.805-1.628l-7.108 4.061c-1.26.72-1.26 2.536 0 3.256l7.108 4.061Z" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M11.828 2.25c-.916 0-1.699.663-1.85 1.567l-.091.549a.798.798 0 0 1-.517.608 7.45 7.45 0 0 0-.478.198.798.798 0 0 1-.796-.064l-.453-.324a1.875 1.875 0 0 0-2.416.2l-.243.243a1.875 1.875 0 0 0-.2 2.416l.324.453a.798.798 0 0 1 .064.796 7.448 7.448 0 0 0-.198.478.798.798 0 0 1-.608.517l-.55.092a1.875 1.875 0 0 0-1.566 1.849v.344c0 .916.663 1.699 1.567 1.85l.549.091c.281.047.508.25.608.517.06.162.127.321.198.478a.798.798 0 0 1-.064.796l-.324.453a1.875 1.875 0 0 0 .2 2.416l.243.243c.648.648 1.67.733 2.416.2l.453-.324a.798.798 0 0 1 .796-.064c.157.071.316.137.478.198.267.1.47.327.517.608l.092.55c.15.903.932 1.566 1.849 1.566h.344c.916 0 1.699-.663 1.85-1.567l.091-.549a.798.798 0 0 1 .517-.608 7.52 7.52 0 0 0 .478-.198.798.798 0 0 1 .796.064l.453.324a1.875 1.875 0 0 0 2.416-.2l.243-.243c.648-.648.733-1.67.2-2.416l-.324-.453a.798.798 0 0 1-.064-.796c.071-.157.137-.316.198-.478.1-.267.327-.47.608-.517l.55-.091a1.875 1.875 0 0 0 1.566-1.85v-.344c0-.916-.663-1.699-1.567-1.85l-.549-.091a.798.798 0 0 1-.608-.517 7.507 7.507 0 0 0-.198-.478.798.798 0 0 1 .064-.796l.324-.453a1.875 1.875 0 0 0-.2-2.416l-.243-.243a1.875 1.875 0 0 0-2.416-.2l-.453.324a.798.798 0 0 1-.796.064 7.462 7.462 0 0 0-.478-.198.798.798 0 0 1-.517-.608l-.091-.55a1.875 1.875 0 0 0-1.85-1.566h-.344ZM12 15.75a3.75 3.75 0 1 0 0-7.5 3.75 3.75 0 0 0 0 7.5Z" clip-rule="evenodd" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path d="M5.055 7.06C3.805 6.347 2.25 7.25 2.25 8.69v8.122c0 1.44 1.555 2.343 2.805 1.628L12 14.471v2.34c0 1.44 1.555 2.343 2.805 1.628l7.108-4.061c1.26-.72 1.26-2.536 0-3.256l-7.108-4.061C13.555 6.346 12 7.249 12 8.689v2.34L5.055 7.061Z" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 0 0 1.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06ZM17.78 9.22a.75.75 0 1 0-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L20.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-1.72 1.72-1.72-1.72Z" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 0 1 .75-.75H9a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H7.5a.75.75 0 0 1-.75-.75V5.25Zm7.5 0A.75.75 0 0 1 15 4.5h1.5a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H15a.75.75 0 0 1-.75-.75V5.25Z" clip-rule="evenodd" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M4.5 5.653c0-1.427 1.529-2.33 2.779-1.643l11.54 6.347c1.295.712 1.295 2.573 0 3.286L7.28 19.99c-1.25.687-2.779-.217-2.779-1.643V5.653Z" clip-rule="evenodd" />
</svg>
</template>

View File

@@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 0 0 1.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06ZM18.584 5.106a.75.75 0 0 1 1.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 0 1-1.06-1.06 8.25 8.25 0 0 0 0-11.668.75.75 0 0 1 0-1.06Z" />
<path d="M15.932 7.757a.75.75 0 0 1 1.061 0 6 6 0 0 1 0 8.486.75.75 0 0 1-1.06-1.061 4.5 4.5 0 0 0 0-6.364.75.75 0 0 1 0-1.06Z" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M4.5 7.5a3 3 0 0 1 3-3h9a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-9Z" clip-rule="evenodd" />
</svg>
</template>

View File

@@ -0,0 +1,64 @@
<template>
<div class="remote-devices">
<div>
<label>Name</label>
<input type="text" placeholder="Name" v-model="name" />
<button @click="showAvailableDevices">Available devices</button>
</div>
<hr>
<div>
<div v-for="device in manager.devices">
<span @click="manager.removeDevice(device)">&times;</span> {{ device.name }}
<button v-if="!device.isConnected" @click="device.connect()">connect</button>
<button v-else @click="device.disconnect()">disconnect</button>
</div>
</div>
<dialog ref="localWindowDialog">
<div v-for="device in availableDevices">
<div class="remote-devices__available-device" @click="addAvailableDevice(device)">
<template v-if="device.type === 'fullscreen-window'">
{{ device.name }}
</template>
</div>
</div>
</dialog>
</div>
</template>
<script setup lang="ts">
import { inject, ref } from 'vue';
import { RemoteDeviceManagerSymbol } from '../../model/injectionSymbols';
import {DeviceConfig} from '../../model/Remote/RemoteDeviceManager';
const name = ref('');
const manager = inject(RemoteDeviceManagerSymbol)!;
console.log(manager);
const localWindowDialog = ref<HTMLDialogElement>();
const availableDevices = ref<DeviceConfig[]>([]);
async function addAvailableDevice(device: DeviceConfig) {
manager.add({
...device,
name: name.value || device.name
})
manager.persist();
localWindowDialog.value?.close();
}
async function showAvailableDevices() {
localWindowDialog.value?.showModal();
availableDevices.value = await manager.availableDevices();
}
</script>
<style>
.remote-devices__available-device {
padding: 10px;
border: 1px solid #ccc;
margin: 5px;
cursor: pointer;
}
.remote-devices__available-device:hover {
background-color: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div
class="remote-screen"
:class="{ 'remote-screen--dragover': isDragOver }"
:style="{ aspectRatio: `${imageSink?.resolution?.width ?? 1}/${imageSink?.resolution?.height ?? 1}` }"
@drop="onDrop"
@dragover.prevent
@dragleave="isDragOver = false"
@dragenter="isDragOver = true"
@wheel.prevent="onWheel"
@mousedown="onMouseDown"
>
<div class="remote-screen__info">
{{ screen.name }} ({{ imageSink?.resolution?.width ?? '?' }}x{{ imageSink?.resolution?.height ?? '?' }})
<span style="position:absolute;right:0.25rem;"></span>
</div>
<div class="remote-screen__loading" v-if="isLoading"><span></span></div>
<div class="remote-screen__connect" v-if="!screen.isConnected">
<button @click="screen.connect()">Connect</button>
</div>
<canvas
ref="canvas"
style="max-width: 100%;"
:style="{
// transform: `scale(${scale / 100}) translate(${(x)}%, ${(y)}%)`,
}"
/>
</div>
</template>
<script setup lang="ts">
import {computed, inject, ref, watch} from 'vue';
import {ImageSink, RemoteDevice} from '../../model/Remote/types';
import {MountedFsSymbol} from '../../model/injectionSymbols';
import {VirtualFile} from '../../model/FileSystem/types';
import {useLoading} from '../../helpers/withLoading';
import {useMouseDragging} from '../../helpers/mouseDragging';
const props = defineProps<{
screen: RemoteDevice;
}>();
const imageSink = computed(() => props.screen.imageSink as ImageSink);
const fileSystem = inject(MountedFsSymbol)!;
const canvas = ref<HTMLCanvasElement>();
const isDragOver = ref(false);
const scale = ref(100);
const {isLoading, withLoading} = useLoading();
const {onMouseDown, x, y} = useMouseDragging()
function onWheel(event: WheelEvent) {
scale.value = Math.max(10, Math.min(1000, scale.value + event.deltaY / 100));
console.log('scroll', event.deltaY, scale.value);
}
watch(() => [x.value, y.value, scale.value], () => {
imageSink.value?.transform?.({
x: x.value,
y: y.value,
scale: scale.value / 100,
});
})
async function drawImage(blob: Blob) {
if (blob.type.startsWith('image/')) {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const ctx = canvas.value!.getContext('2d')!;
canvas.value!.width = img.width;
canvas.value!.height = img.height;
ctx.drawImage(img, 0, 0);
}
img.src = url;
} else if (blob.type.startsWith('video/')) {
const url = URL.createObjectURL(blob);
const video = document.createElement('video');
video.muted = true;
video.autoplay = true;
video.src = url;
video.oncanplaythrough = () => {
const ctx = canvas.value!.getContext('2d')!;
canvas.value!.width = video.videoWidth;
canvas.value!.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
video.pause();
}
}
}
async function loadFile(meta: VirtualFile, file: Blob) {
await withLoading(async () => {
await Promise.all([
imageSink.value?.transferFile(meta, file),
drawImage(file),
])
await imageSink.value?.show(meta);
})
}
async function onDrop(event: DragEvent) {
event.preventDefault();
isDragOver.value = false;
const file = event.dataTransfer?.files?.[0];
if (file) {
const meta = {
type: 'file',
source: 'drop',
path: [] as string[],
name: file.name,
mimeType: file.type
} as const;
await loadFile(meta, file)
} else if (event.dataTransfer?.getData('application/json')) {
const meta = JSON.parse(event.dataTransfer?.getData('application/json'));
if (meta.type === 'file') {
const file = await fileSystem.download(meta);
await loadFile(meta, file);
}
}
console.log('files', event.dataTransfer?.files);
console.log('drop', event.dataTransfer?.getData('application/json'));
}
</script>
<style>
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.remote-screen {
position: relative;
display: inline-block;
border: 2px solid white;
border-radius: 4px;
background-color: black;
aspect-ratio: 16 / 9;
overflow: hidden;
max-height: 100%;
}
.remote-screen__info {
position: absolute;
top: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
padding: 4px;
text-align: center;
}
.remote-screen__loading {
pointer-events: none;
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 2rem;
color: white;
background-color: rgba(0, 0, 0, 0.5);
span {
transform-origin: 44% 54%;
animation: spin 1000ms linear infinite;
}
}
.remote-screen__connect {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
padding: 4px;
text-align: center;
}
.remote-screen--dragover {
border-color: orange;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="screens">
<RemoteScreen :screen="screen" v-for="screen of screens" />
<div @click="remoteManager.connectChromeCast()">chromecast</div>
</div>
</template>
<script setup lang="ts">
import {computed, inject} from 'vue';
import {RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
import RemoteScreen from './RemoteScreen.vue';
import {ImageSink} from '../../model/Remote/types';
const remoteManager = inject(RemoteDeviceManagerSymbol)!;
const screens = computed(() => {
return remoteManager.devices
.filter(device => device.hasImageSink)
})
</script>
<style>
.screens {
display: flex;
height: 200px;
gap: 1rem;
justify-items: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<div v-for="sound in soundPlayer.sounds">
<button @click="soundPlayer.play(sound)">{{sound}}</button>
</div>
</template>
<script setup lang="ts">
import {reactive} from 'vue';
import {SoundPlayer} from '../model/SoundPlayer';
const soundPlayer = reactive(new SoundPlayer());
console.log(soundPlayer);
</script>

56
src/components/Tabs.vue Normal file
View File

@@ -0,0 +1,56 @@
<template>
<div class="tabs">
<div class="tabbar">
<div
class="tab"
:class="{active: selectedTab === tab.key}"
v-for="tab in tabs" @click="selectedTab = tab.key"
>
{{ tab.label }}
</div>
</div>
<div v-for="slot, name in slots" v-show="selectedTab === name">
<slot :name="name"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue';
const props = defineProps<{
tabs: {label: string, key: string}[]
}>()
const slots = defineSlots<{
slotName: string,
Id: string,
}>()
console.log(slots)
const selectedTab = ref(props.tabs?.[0]?.key);
</script>
<style>
@scope(.tabs) {
.tabbar {
display: flex;
gap: 0.5rem;
padding: 0 0.5rem;
border-bottom: 1px solid white;
}
.tab {
cursor: pointer;
padding: 0.5rem;
border:1px solid white;
border-bottom: none;
border-radius: 5px 5px 0 0;
&.active {
background-color: white;
color: black;
}
}
}
</style>

4
src/helpers/immediate.ts Normal file
View File

@@ -0,0 +1,4 @@
export function immediate<T extends (...args: any[]) => any>(fn: T):T {
fn();
return fn;
}

View File

@@ -0,0 +1,9 @@
export function loadScript(src: string) {
return new Promise<void>((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);
});
}

View File

@@ -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};
}

View File

@@ -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<void>) => {
loadingCount.value++;
try {
return await fn();
} finally {
loadingCount.value--;
}
}
return { isLoading, withLoading };
}

View File

@@ -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')

View File

@@ -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<T extends SoundGroupNode<any>> = ReturnType<T['serializeOptions']>
export type SupportedDefinitions = SerializedNode<PlaylistGroupNode> | SerializedNode<SfxGroupNode> | SerializedNode<TabSourceNode>
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<any>[]) {
const definitions = nodes.map(node => node.serializeOptions());
localStorage.setItem('audioNodes', JSON.stringify(definitions));
}
}

View File

@@ -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<PlaylistGroupNodeOptions> {
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,
});
}
}

View File

@@ -0,0 +1,4 @@
# AudioTrack
represents one audio file

View File

@@ -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<SfxGroupNodeOptions> {
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;
}
}

View File

@@ -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<T extends SoundGroupNodeOptions> {
public abstract readonly type: string;
public deviceSources: Map<RemoteDevice, DeviceSource> = new Map();
protected options: Omit<T, 'sounds'>
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,
}
}
}

View File

@@ -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<void>;
abstract stop(): Promise<void>;
}
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;
}
}
}

View File

@@ -0,0 +1,76 @@
import { SoundGroupNode, SoundGroupNodeOptions } from "./SoundGroupNode";
export interface TabSourceNodeOptions extends SoundGroupNodeOptions {
}
export class TabSourceNode extends SoundGroupNode<TabSourceNodeOptions> {
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;
}
}

7
src/model/Audio/types.ts Normal file
View File

@@ -0,0 +1,7 @@
import { MountedFs } from "../FileSystem/MountedFs";
import { FileSystemSource } from "../FileSystem/types";
export type SoundboardContext = {
audioContext: AudioContext;
fs: MountedFs,
}

View File

@@ -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<void>((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);
});
}

View File

@@ -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';
}
}

View File

@@ -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<T extends VirtualFile>(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/'),
},
}
}

View File

@@ -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<FileStashFsConfig> {
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 <VirtualFile>{
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<File> {
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;
}
}

View File

@@ -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<Response> {
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<Response> {
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);
}
}
}

View File

@@ -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> = T extends FileSystemSource<any> ? ReturnType<T['getConfig']> : never;
const CACHE = Symbol('Cache');
export interface Fs {
get root(): VirtualDirectory[];
download(file: VirtualFile): Promise<File>;
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
}
export class MountedFs implements Fs {
private filesystems: Map<string, FileSystemSource<any>> = new Map();
public readonly name: string = 'root';
constructor(filesystems: (Config<NginxFS> | Config<FileStashFs>)[]) {
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);
// }
// }

View File

@@ -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<NginxFsConfig> {
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<NginxFile | NginxDirectory>;
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<File> {
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;
}
}

View File

@@ -0,0 +1,27 @@
import {CachePolicy} from './FsCache';
export interface FileSystemSource<TOptions> {
name: string;
root: VirtualDirectory;
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
download(file: VirtualFile): Promise<File>;
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;
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<string, VirtualFile>();
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<ArrayBuffer>) => {
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));
}
}

View File

@@ -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<void>((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));
}
}

View File

@@ -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 = [];
}
}

View File

@@ -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<ChromecastDevice> {
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
});
}
}

View File

@@ -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<void> {
if (this.target) {
this.target.close();
this.target = null;
this.imageSink = undefined;
this.connectJsonChannel();
}
}
static async getDevices(): Promise<FullscreenWindowConfig[]> {
// @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
});
}
}

View File

@@ -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);
}
}

View File

@@ -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<Devices|undefined> {
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<void> {
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<DeviceConfig[]> {
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);
}
}

32
src/model/Remote/types.ts Normal file
View File

@@ -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<void>;
disconnect(): Promise<void>;
}
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;
// }

60
src/model/SoundPlayer.ts Normal file
View File

@@ -0,0 +1,60 @@
export class SoundPlayer {
public sounds = ['sword slash', 'knock - wood'];
private ctx = new AudioContext();
private cache = new Map<string, AudioBuffer>();
private cacheOptions = new Map<string, string[]>();
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();
}
}

View File

@@ -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<FileManager>;
export const RemoteDeviceManagerSymbol = Symbol("RemoteDeviceManager") as InjectionKey<Reactive<RemoteDeviceManager>>;
export const MountedFsSymbol = Symbol("MountedFs") as InjectionKey<MountedFs>;
export const AudioContextSymbol = Symbol("AudioContext") as InjectionKey<AudioContext>;
export const CastTypeSymbol = Symbol("CastType") as InjectionKey<"sender" | "receiver">;
export const SenderSymbol = Symbol('Sender') as InjectionKey<Sender>;
export const ReceiverSymbol = Symbol('Receiver') as InjectionKey<Receiver>;
export const ReceiverSymbol = Symbol('Receiver') as InjectionKey<Receiver>;
export const JsonChannelSymbol = Symbol('JsonChannel') as InjectionKey<JsonChannelSummon>;
export const WebRtcChannelSymbol = Symbol('WebRtcChannelSymbol') as InjectionKey<WebRtcChannelSummon>;

View File

@@ -1,26 +1,46 @@
<template>
<div>
<div style="background-color: white; display: flex; flex-direction: row;gap: 0.5rem;padding: 0.5rem">
<div style="max-width: 32px; height: 32px; overflow: hidden;">
<div style="background-color: white; display: flex; flex-direction: row;gap: 0.5rem;padding: 0.5rem;margin-bottom:0.5rem; color: black">
<FilesAside />
<!-- <div style="max-width: 32px; height: 32px; overflow: hidden;">
<google-cast-launcher style="--disconnected-color: black;" ></google-cast-launcher>
</div>
<button @click="openMapWindow">Open map window</button>
<button @click="onStreamImage">Stream image</button>
<button @click="onStreamImage">Stream image</button> -->
</div>
<fieldset>
<legend>Image settings</legend>
<label>Width</label>
<input type="number" v-model="imageWidth" />
<label>Screen Diameter</label>
<input type="number" v-model="screenDiameterInch" />
<label>Flip <input type="checkbox" v-model="flipped"></label>
<label>Position (X/Y)</label>
<input type="range" min="0" max="100" v-model="positionX" />
<input type="range" min="0" max="100" v-model="positionY" />
<button @click="positionX = 50;positionY = 50">center</button>
</fieldset>
<FileManager @openFile="openFile" />
<FilesAside />
<ScreenManager />
<Tabs
:tabs="[
{label: 'Sounds', key: 'sounds'},
{label: 'Settings', key: 'settings'},
{label: 'Devices', key: 'devices'},
]"
>
<template v-slot:settings>
<fieldset>
<legend>Image settings</legend>
<label>Width</label>
<input type="number" v-model="imageWidth" />
<label>Screen Diameter</label>
<input type="number" v-model="screenDiameterInch" />
<label>Flip <input type="checkbox" v-model="flipped"></label>
<label>Position (X/Y)</label>
<input type="range" min="0" max="100" v-model="positionX" />
<input type="range" min="0" max="100" v-model="positionY" />
<button @click="positionX = 50;positionY = 50">center</button>
</fieldset>
</template>
<template v-slot:devices>
<RemoteDevices />
</template>
<template v-slot:sounds>
<div style="padding: 1rem">
<Soundboard />
</div>
</template>
</Tabs>
</div>
</template>
<script setup lang="ts">
@@ -28,6 +48,12 @@ import FileManager from '../../components/FileManager/FileManager.vue';
import type { FileWithMeta } from '../../model/FileManager';
import {ref, inject, watch} from 'vue';
import {SenderSymbol} from '../../model/injectionSymbols';
import Tabs from '../../components/Tabs.vue';
import {VirtualFile} from '../../model/FileSystem/types';
import ScreenManager from '../../components/ScreenManager/ScreenManager.vue';
import RemoteDevices from '../../components/RemoteDevices/RemoteDevices.vue';
import Soundboard from '../../components/Audio/Soundboard.vue';
import FilesAside from '../../components/Files/FilesAside.vue';
const openedFile = ref<FileWithMeta>()
const sender = inject(SenderSymbol);
@@ -62,13 +88,14 @@ watch(() => [positionX.value, positionY.value, flipped.value], () => {
})
});
function openFile(file: FileWithMeta) {
sender?.sendMessage({
type: 'showFile',
file,
})
imageWidth.value = file.width ?? 20;
openedFile.value = file;
function openFile(file: VirtualFile) {
console.log('openFile', file);
// sender?.sendMessage({
// type: 'showFile',
// file,
// })
// imageWidth.value = file.width ?? 20;
// openedFile.value = file;
}
function openMapWindow() {

View File

@@ -0,0 +1,50 @@
<template>
<div class="debug-window">
<pre v-for="json in lastMessages" :class="{
'debug-window__json': json.source === 'json',
'debug-window__webrtc': json.source === 'webrtc',
}">{{ json.message }}</pre>
</div>
</template>
<script setup lang="ts">
import {computed, ref, inject} from 'vue';
import {Message} from '../../model/Remote/Channels/type';
import { JsonChannelSymbol, WebRtcChannelSymbol } from '../../model/injectionSymbols';
const jsonMessages = ref<{source: 'json' | 'webrtc', message: Message}[]>([]);
const lastMessages = computed(() => [...jsonMessages.value].reverse().slice(0, 10));
console.log(jsonMessages, lastMessages);
const channel = inject(JsonChannelSymbol)!;
const webRTC = inject(WebRtcChannelSymbol)!;
channel.on((message) => {
jsonMessages.value.push({source: 'json', message});
jsonMessages.value = jsonMessages.value.slice(-100);
});
webRTC.on((message) => {
jsonMessages.value.push({source: 'webrtc', message});
jsonMessages.value = jsonMessages.value.slice(-100);
});
</script>
<style>
.debug-window {
position: fixed;
left: 0;
top: 0;
width: 400px;
height: 800px;
background-color: rgba(0, 0, 0, 0.5);
border: 2px solid white;
z-index: 10000;
overflow: hidden;
}
.debug-window__json {
color: cadetblue;
}
.debug-window__webrtc {
color: greenyellow;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<Debug />
<canvas ref="canvas" style="max-width: 100%" />
</template>
<script setup lang="ts">
import {inject, ref} from 'vue';
import Debug from './Debug.vue';
import {WebRtcChannelSymbol} from '../../model/injectionSymbols';
import {VirtualFile} from '../../model/FileSystem/types';
const webRtc = inject(WebRtcChannelSymbol)!;
const canvas = ref<HTMLCanvasElement>();
const cachedFiles = new Map<string, Blob>();
const fileToKey = (file: VirtualFile) => `${file.source}:${file.path.join('/')}`;
function sendScreenInfo() {
webRtc.send({
type: 'resolution',
width: window.innerWidth * window.devicePixelRatio,
height: window.innerHeight * window.devicePixelRatio,
});
}
function sendAudioInfo() {
const ac = new AudioContext();
webRtc.send({
type: 'audioInfo',
channels: ac.destination.channelCount,
channelInterpretation: ac.destination.channelInterpretation,
})
}
window.addEventListener('resize', () => {
sendScreenInfo();
});
webRtc.on((message) => {
console.log('webRtc message', message);
if (message.type === 'connected') {
sendScreenInfo();
sendAudioInfo();
} else if (message.type === 'fileTransfered') {
debugger;
const key = fileToKey(message.meta);
cachedFiles.set(key, message.blob);
} else if (message.type === 'showFile') {
debugger;
const key = fileToKey(message.file);
const blob = cachedFiles.get(key);
if (blob) {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const ctx = canvas.value!.getContext('2d')!;
canvas.value!.width = img.width;
canvas.value!.height = img.height;
ctx.drawImage(img, 0, 0);
}
img.src = url;
}
} else if (message.type === 'transform') {
// canvas.value.style = `max-width: 100%;transform: scale(${message.scale}) translate(${message.x}%, ${message.y}%);transition: transform 100ms;`;
}
});
</script>

58
src/summon.ts Normal file
View File

@@ -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')

16
src/utils/debounce.ts Normal file
View File

@@ -0,0 +1,16 @@
export function debounce<T extends (...args: any[]) => 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;
}

11
src/utils/groupBy.ts Normal file
View File

@@ -0,0 +1,11 @@
export function groupBy<T, TK>(arr: T[], key: (item: T) => TK): Map<TK, T[]> {
const map = new Map<TK, T[]>();
for (const item of arr) {
const group = key(item);
if (!map.has(group)) {
map.set(group, []);
}
map.get(group)!.push(item);
}
return map;
}

0
src/utils/index.ts Normal file
View File

13
summon/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Summon | BattleCaster</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/summon.ts"></script>
</body>
</html>

View File

@@ -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'
}
}
})