sounds, better files
This commit is contained in:
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
#VITE_CHROMECAST_APP_ID=0D9CC4B6
|
||||
VITE_CHROMECAST_APP_ID=AB5F6AA2
|
||||
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_CHROMECAST_APP_ID=0D9CC4B6
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
maps
|
||||
public/maps/*
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
|
||||
17
README.md
17
README.md
@@ -1,5 +1,14 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
# Battle caster
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<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
16
chromecast/index.html
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
35
src/assets/main.css
Normal file
35
src/assets/main.css
Normal 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
174
src/assets/theme.css
Normal 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
23
src/chromecast.ts
Normal 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();
|
||||
})
|
||||
237
src/components/Audio/AudioCard.vue
Normal file
237
src/components/Audio/AudioCard.vue
Normal 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>
|
||||
78
src/components/Audio/DeviceVolume.vue
Normal file
78
src/components/Audio/DeviceVolume.vue
Normal 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>
|
||||
95
src/components/Audio/Playlist.vue
Normal file
95
src/components/Audio/Playlist.vue
Normal 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>
|
||||
58
src/components/Audio/Sfx.vue
Normal file
58
src/components/Audio/Sfx.vue
Normal 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>
|
||||
129
src/components/Audio/SfxSound.vue
Normal file
129
src/components/Audio/SfxSound.vue
Normal 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>
|
||||
120
src/components/Audio/Soundboard.vue
Normal file
120
src/components/Audio/Soundboard.vue
Normal 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>
|
||||
|
||||
21
src/components/Audio/TabSource.vue
Normal file
21
src/components/Audio/TabSource.vue
Normal 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>
|
||||
69
src/components/Audio/modal.vue
Normal file
69
src/components/Audio/modal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
100
src/components/Files/FilesAside.vue
Normal file
100
src/components/Files/FilesAside.vue
Normal 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>
|
||||
5
src/components/Icons/Backward.vue
Normal file
5
src/components/Icons/Backward.vue
Normal 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>
|
||||
5
src/components/Icons/Cog.vue
Normal file
5
src/components/Icons/Cog.vue
Normal 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>
|
||||
5
src/components/Icons/Forward.vue
Normal file
5
src/components/Icons/Forward.vue
Normal 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>
|
||||
5
src/components/Icons/Mute.vue
Normal file
5
src/components/Icons/Mute.vue
Normal 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>
|
||||
5
src/components/Icons/Pause.vue
Normal file
5
src/components/Icons/Pause.vue
Normal 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>
|
||||
5
src/components/Icons/Play.vue
Normal file
5
src/components/Icons/Play.vue
Normal 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>
|
||||
6
src/components/Icons/Speaker.vue
Normal file
6
src/components/Icons/Speaker.vue
Normal 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>
|
||||
5
src/components/Icons/Stop.vue
Normal file
5
src/components/Icons/Stop.vue
Normal 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>
|
||||
64
src/components/RemoteDevices/RemoteDevices.vue
Normal file
64
src/components/RemoteDevices/RemoteDevices.vue
Normal 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)">×</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>
|
||||
192
src/components/ScreenManager/RemoteScreen.vue
Normal file
192
src/components/ScreenManager/RemoteScreen.vue
Normal 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>
|
||||
28
src/components/ScreenManager/ScreenManager.vue
Normal file
28
src/components/ScreenManager/ScreenManager.vue
Normal 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>
|
||||
13
src/components/Soundboard.vue
Normal file
13
src/components/Soundboard.vue
Normal 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
56
src/components/Tabs.vue
Normal 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
4
src/helpers/immediate.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function immediate<T extends (...args: any[]) => any>(fn: T):T {
|
||||
fn();
|
||||
return fn;
|
||||
}
|
||||
9
src/helpers/loadScript.ts
Normal file
9
src/helpers/loadScript.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
34
src/helpers/mouseDragging.ts
Normal file
34
src/helpers/mouseDragging.ts
Normal 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};
|
||||
}
|
||||
16
src/helpers/withLoading.ts
Normal file
16
src/helpers/withLoading.ts
Normal 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 };
|
||||
}
|
||||
57
src/main.ts
57
src/main.ts
@@ -1,21 +1,58 @@
|
||||
import { createApp } from 'vue'
|
||||
import './assets/main.css'
|
||||
import { createApp, reactive } from 'vue'
|
||||
import './style.css'
|
||||
import ControlApp from './modules/controls/ControlApp.vue'
|
||||
import { FileManager } from './model/FileManager'
|
||||
import { FileManagerSymbol, SenderSymbol } from './model/injectionSymbols'
|
||||
import { BroadcastSender } from './model/BroadcastListener';
|
||||
import { MultiSender } from './model/MultiListener'
|
||||
import { ChromecastSender } from './model/ChromecastListener'
|
||||
import { AudioContextSymbol, MountedFsSymbol, RemoteDeviceManagerSymbol } from './model/injectionSymbols'
|
||||
import { MountedFs } from './model/FileSystem/MountedFs'
|
||||
import { RemoteDeviceManager } from './model/Remote/RemoteDeviceManager'
|
||||
import { ChromecastChannelCaster } from './model/Remote/Channels/ChromcastCaster'
|
||||
import { WsSignalingCaster } from './model/Remote/Channels/WsSignaling'
|
||||
|
||||
|
||||
const app = createApp(ControlApp);
|
||||
|
||||
const chromecastChannelCaster = new ChromecastChannelCaster();
|
||||
chromecastChannelCaster.initialize();
|
||||
console.log('chromecastChannelCaster', chromecastChannelCaster);
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
app
|
||||
.provide(SenderSymbol, new MultiSender([
|
||||
new BroadcastSender(),
|
||||
new ChromecastSender(),
|
||||
// .provide(SenderSymbol, new MultiSender([
|
||||
// new BroadcastSender(),
|
||||
// new ChromecastSender(),
|
||||
// ]))
|
||||
// .provide(FileManagerSymbol, new FileManager())
|
||||
.provide(RemoteDeviceManagerSymbol, reactive(RemoteDeviceManager.restore({audioContext})))
|
||||
.provide(AudioContextSymbol, audioContext)
|
||||
.provide(MountedFsSymbol, new MountedFs([
|
||||
/*{
|
||||
name: 'Maps',
|
||||
type: 'nginx',
|
||||
rootUrl: 'http://localhost:8080/maps/',
|
||||
auth: undefined,
|
||||
},
|
||||
{
|
||||
name: 'SFX',
|
||||
type: 'nginx',
|
||||
rootUrl: 'http://localhost:8080/soundfx/',
|
||||
auth: undefined,
|
||||
},*/
|
||||
{
|
||||
name: 'Maps',
|
||||
type: 'filestash',
|
||||
domain: 'https://files.fisoft.eu',
|
||||
key: '426pBJgSyfmgJAaUmNjk',
|
||||
share: 'nCKcVyl',
|
||||
},
|
||||
{
|
||||
name: 'Audio',
|
||||
type: 'filestash',
|
||||
domain: 'https://files.fisoft.eu',
|
||||
key: '426pBJgSyfmgJAaUmNjk',
|
||||
share: '74uqzNZ',
|
||||
}
|
||||
]))
|
||||
.provide(FileManagerSymbol, new FileManager())
|
||||
.mount('#app')
|
||||
|
||||
|
||||
|
||||
44
src/model/Audio/AudioNodesManager.ts
Normal file
44
src/model/Audio/AudioNodesManager.ts
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
68
src/model/Audio/PlaylistGroupNode.ts
Normal file
68
src/model/Audio/PlaylistGroupNode.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
4
src/model/Audio/README.md
Normal file
4
src/model/Audio/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# AudioTrack
|
||||
|
||||
represents one audio file
|
||||
|
||||
41
src/model/Audio/SfxGroupNode.ts
Normal file
41
src/model/Audio/SfxGroupNode.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
146
src/model/Audio/SoundGroupNode.ts
Normal file
146
src/model/Audio/SoundGroupNode.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
177
src/model/Audio/SoundNode.ts
Normal file
177
src/model/Audio/SoundNode.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/model/Audio/TabSourceNode.ts
Normal file
76
src/model/Audio/TabSourceNode.ts
Normal 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
7
src/model/Audio/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { MountedFs } from "../FileSystem/MountedFs";
|
||||
import { FileSystemSource } from "../FileSystem/types";
|
||||
|
||||
export type SoundboardContext = {
|
||||
audioContext: AudioContext;
|
||||
fs: MountedFs,
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
21
src/model/FileMeta/ExtensionMimeType.ts
Normal file
21
src/model/FileMeta/ExtensionMimeType.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
50
src/model/FileMeta/MapMeta.ts
Normal file
50
src/model/FileMeta/MapMeta.ts
Normal 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/'),
|
||||
},
|
||||
}
|
||||
}
|
||||
100
src/model/FileSystem/FileStashFs.ts
Normal file
100
src/model/FileSystem/FileStashFs.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/model/FileSystem/FsCache.ts
Normal file
52
src/model/FileSystem/FsCache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
129
src/model/FileSystem/MountedFs.ts
Normal file
129
src/model/FileSystem/MountedFs.ts
Normal 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);
|
||||
// }
|
||||
// }
|
||||
102
src/model/FileSystem/NginxFS.ts
Normal file
102
src/model/FileSystem/NginxFS.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
27
src/model/FileSystem/types.ts
Normal file
27
src/model/FileSystem/types.ts
Normal 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;
|
||||
}
|
||||
111
src/model/Remote/Channels/ChromcastCaster.ts
Normal file
111
src/model/Remote/Channels/ChromcastCaster.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
63
src/model/Remote/Channels/ChromcastSummon.ts
Normal file
63
src/model/Remote/Channels/ChromcastSummon.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/model/Remote/Channels/PostMessage.ts
Normal file
25
src/model/Remote/Channels/PostMessage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
172
src/model/Remote/Channels/WebRTCChannel.ts
Normal file
172
src/model/Remote/Channels/WebRTCChannel.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
92
src/model/Remote/Channels/WsSignaling.ts
Normal file
92
src/model/Remote/Channels/WsSignaling.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
131
src/model/Remote/Channels/type.ts
Normal file
131
src/model/Remote/Channels/type.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
97
src/model/Remote/ChromecastDevice.ts
Normal file
97
src/model/Remote/ChromecastDevice.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
135
src/model/Remote/FullscreenWindow.ts
Normal file
135
src/model/Remote/FullscreenWindow.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
9
src/model/Remote/LocalAudioDevice.ts
Normal file
9
src/model/Remote/LocalAudioDevice.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
105
src/model/Remote/RemoteDeviceManager.ts
Normal file
105
src/model/Remote/RemoteDeviceManager.ts
Normal 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
32
src/model/Remote/types.ts
Normal 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
60
src/model/SoundPlayer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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() {
|
||||
|
||||
50
src/modules/summon/Debug.vue
Normal file
50
src/modules/summon/Debug.vue
Normal 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>
|
||||
68
src/modules/summon/SummonApp.vue
Normal file
68
src/modules/summon/SummonApp.vue
Normal 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
58
src/summon.ts
Normal 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
16
src/utils/debounce.ts
Normal 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
11
src/utils/groupBy.ts
Normal 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
0
src/utils/index.ts
Normal file
13
summon/index.html
Normal file
13
summon/index.html
Normal 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>
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user