new maps

This commit is contained in:
2024-11-24 17:34:41 +00:00
parent eb6abbb97d
commit 641938f7e4
38 changed files with 1241 additions and 228 deletions

View File

@@ -7,6 +7,7 @@
>
<div class="a-card__label-wrapper">
<div class="a-card__label">{{ label }}</div>
<div class="a-card__delete" @click="emits('delete')">&times;</div>
</div>
<div class="a-card__volume">
<span style="height: 1.25rem" @click="node.toggleMute()">
@@ -60,6 +61,7 @@ import DeviceVolume from './DeviceVolume.vue';
import Stop from '../Icons/Stop.vue';
const emits = defineEmits<{
'delete': [],
'next': [],
'prev': [],
'togglePlay': [],
@@ -119,6 +121,7 @@ function onDrop(event: DragEvent) {
.a-card {
--box-color: var(--audio-box-color-playlist, #ffb308);
--box-color-900: color-mix(in lab, var(--box-color), #000 80%);
--box-color-800: color-mix(in lab, var(--box-color), #000 60%);
--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;
@@ -179,6 +182,7 @@ function onDrop(event: DragEvent) {
}
.a-card__label-wrapper {
position: relative;
width: 100%;
text-align: center;
margin-top: -1rem;
@@ -224,6 +228,24 @@ function onDrop(event: DragEvent) {
}
}
.a-card__delete {
position: absolute;
top: 0;
right: -1rem;
width: 1.5rem;
height: 1.5rem;
color: var(--box-color-100);
background: var(--box-color-900);
border-radius: 0.25rem;
cursor: pointer;
border: 1px solid rgba(from var(--box-color) r g b / 0.2);
opacity: 0%;
}
.a-card:hover .a-card__delete {
opacity: 100%;
}
@keyframes ripples {
to {
box-shadow:

View File

@@ -4,7 +4,8 @@
:supports="[ 'next', 'prev', 'togglePlay' ]"
@next="node.next()"
@prev="node.prev()"
label="Playlist test"
@delete="$emit('delete')"
label="Playlist"
style="--box-color: var(--color-amber)"
>
<template #preview>
@@ -12,20 +13,32 @@
</template>
<template #popover>
<div>
<!--<div>
{{ node.currentTrack?.name ?? 'No track selected' }}
<div>
<input type="range" min="0" max="100" style="width: 100%" />00:00
</div>
<input type="range" min="0" max="100" style="width: 100%" />
</div>
</div>-->
<div>
<div
class="a-playlist__song"
:class="{
'a-playlist__song--playing': song.isPlaying
}"
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>
<span class="a-playlist__song-play-indicator">
<Play style="height: 1rem" />
</span>
<span class="a-playlist__song-name">
{{ song.options.vFile.name }}
</span>
<span @click.prevent="removeSong(song)">
&times;
</span>
</div>
</div>
</template>
@@ -34,17 +47,27 @@
<script setup lang="ts">
import AudioCard from './AudioCard.vue';
import {PlaylistGroupNode} from '../../model/Audio/PlaylistGroupNode';
import Play from '../Icons/Play.vue';
import {SoundNode} from '../../model/Audio/SoundNode';
const props = defineProps<{
node: PlaylistGroupNode
}>();
const emit = defineEmits<{
'delete': [],
}>();
// playlist.connect(audioContext.destination);
function playTrack(idx: number) {
props.node.changeTrack(idx);
}
function removeSong(song: SoundNode) {
props.node.removeSoundNode(song);
}
</script>
<style>
@@ -92,4 +115,36 @@ function playTrack(idx: number) {
border: 2px solid var(--box-color-300);
}
}
.a-playlist__song {
display: flex;
flex-direction: row;
cursor: pointer;
padding: 0.5rem;
border-bottom: 1px solid var(--box-color-800);
transition: background-color 0.2s;
}
.a-playlist__song-name {
flex: 1;
}
.a-playlist__song--playing {
background-color: var(--box-color-800);
}
.a-playlist__song:first-child {
border-top: 1px solid var(--box-color-800);
}
.a-playlist__song-play-indicator {
opacity: 0;
transition: opacity 0.2s;
margin-right: 0.5rem;
}
.a-playlist__song:hover .a-playlist__song-play-indicator,
.a-playlist__song--playing .a-playlist__song-play-indicator {
opacity: 1;
}
</style>

View File

@@ -6,6 +6,7 @@
:supports="['stop']"
label="SFX"
:node="node"
@delete="emit('delete')"
>
<template #preview>
<div
@@ -38,6 +39,11 @@ const props = defineProps<{
node: SfxGroupNode,
}>();
const emit = defineEmits<{
'delete': [],
}>();
const grouppedSounds = computed(() => {
return groupBy(props.node.sounds(), (sound) => {
return sound.name.replace(/(\d+)$/, '').trim();

View File

@@ -8,17 +8,34 @@
<Playlist
v-if="node.type === 'playlist'"
:node="node"
@delete="deleteNode(node)"
/>
<Sfx
v-else-if="node.type === 'sfx'"
:node="node"
@delete="deleteNode(node)"
/>
<TabSource
v-else-if="node.type === 'tabSource'"
:node="node"
@delete="deleteNode(node)"
/>
</template>
</div>
<div class="a-card add-card" style="--box-color: var(--color-zinc)">
<div class="a-card__label-wrapper">
<div class="a-card__label">Add</div>
</div>
<div @click="addNode('playlist')" class="soundboard-add-card__btn">
Playlist
</div>
<div @click="addNode('sfx')" class="soundboard-add-card__btn">
Sfx
</div>
<div @click="addNode('tab')" class="soundboard-add-card__btn">
Tab Source
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -34,45 +51,56 @@ import TabSource from './TabSource.vue';
import DeviceVolume from './DeviceVolume.vue';
import {AudioNodeManager} from '../../model/Audio/AudioNodesManager';
import {debounce} from '../../utils/debounce';
import IconPlus from '../Icons/IconPlus.vue';
import {SoundNode} from '../../model/Audio/SoundNode';
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}));
function addNode(name: string) {
if (name === 'playlist') {
nodes.push(reactive(new PlaylistGroupNode({
currentTrack: 0,
currentTrackTime: 0,
volume: 100,
isMuted: false,
sounds: [],
}, {
fs,
audioContext,
})))
} else if (name === 'sfx') {
nodes.push(reactive(new SfxGroupNode({
isMuted: false,
volume: 100,
sounds: [],
}, {
fs,
audioContext,
})))
} else if (name === 'tab') {
nodes.push(reactive(new TabSourceNode({
isMuted: false,
volume: 100,
sounds: [],
}, {
fs,
audioContext,
})))
}
}
const deleteNode = (node: SoundNode) => {
node.stop?.();
node.disconnect();
// @ts-ignore
nodes.splice(nodes.indexOf(node), 1);
}
watch(() => nodes, debounce((newNodes) => {
console.log('NEW_NODES', newNodes);
audioNodesManager.store(newNodes);
@@ -116,5 +144,22 @@ watch(() => deviceManager.audioDevices.length, () => {
gap: 3rem 1rem;
padding: 1rem;
}
.soundboard-add-card {
}
.soundboard-add-card__btn {
text-align: center;
border: 1px solid;
margin: 0.25rem;
background: var(--box-color-900);
border: 1px solid rgba(from var(--box-color) r g b / 0.2);
cursor: pointer;
}
.soundboard-add-card__btn:hover {
background: var(--box-color-800);
}
</style>

View File

@@ -7,6 +7,7 @@
:supports="['play', 'stop']"
label="Tab Audio"
:node="node"
@delete="emit('delete')"
>
</AudioCard>
</template>
@@ -15,6 +16,10 @@
import {TabSourceNode} from '../../model/Audio/TabSourceNode';
import AudioCard from './AudioCard.vue';
const emit = defineEmits<{
'delete': [],
}>();
const props = defineProps<{
node: TabSourceNode,
}>();

View File

@@ -8,28 +8,64 @@
</IconButton>
</template>
</FilesAside>
<IconButton name="Settings">
<Cog />
</IconButton>
</div>
<div class="app-layout__aside bg-theme-500 contrast" style="grid-area: menu;">
<IconButton name="Map" @click="switchMain('map')">
<IconMap />
</IconButton>
<IconButton name="Sound" @click="switchMain('sounds')">
<IconMusic />
</IconButton>
<IconButton name="Devices" @click="switchMain('devices')">
<IconTv />
</IconButton>
<div style="flex: 1"></div>
<IconButton name="Settings" @click="switchMain('settings')">
<Cog />
</IconButton>
</div>
<div class="app-layout__overview bg-theme-800 contrast" style="grid-area: overview;">
<slot name="overview"></slot>
</div>
<div class="app-layout__main" style="grid-area: main;">
This is main
<div v-show="mainSection === 'devices'">
<slot name=devices></slot>
</div>
<div v-show="mainSection === 'sounds'">
<slot name=sounds></slot>
</div>
<div v-show="mainSection === 'map'">
<slot name=map></slot>
</div>
<div v-show="mainSection === 'settings'">
<slot name=settings></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {onMounted, ref} from 'vue';
import FilesAside from '../Files/FilesAside.vue';
import Cog from '../Icons/Cog.vue';
import Directory from '../Icons/Directory.vue';
import IconMap from '../Icons/IconMap.vue';
import IconMusic from '../Icons/IconMusic.vue';
import IconTv from '../Icons/IconTv.vue';
import IconButton from './IconButton.vue';
const mainSection = ref('sounds');
function switchMain(section: string){
mainSection.value = section;
}
onMounted(() => {
window.addEventListener('reveal-screen', (event: CustomEvent) => {
switchMain('map');
});
})
</script>
<style>

View File

@@ -15,7 +15,7 @@ import {VirtualFile} from '../../model/FileSystem/types';
const isDragOver = ref(false);
const emit = defineEmits<{
onDrop: [VirtualFile],
dropFile: [VirtualFile],
}>();
const props = defineProps<{
@@ -65,7 +65,8 @@ function onDrop(event: DragEvent) {
event.preventDefault();
isDragOver.value = false;
const drop = parseMeta(event);
console.log('drop', drop);
if (drop) {
emit('dropFile', drop);
}
}
</script>

View File

@@ -69,7 +69,8 @@ function onFilePreview(file: VirtualFile) {
watch(search, async (value) => {
searchResults.value = [];
if (value === '') {
console.log('search', value, value.length);
if (value.length < 3) {
return;
}
if (searchAbort) {

View File

@@ -49,16 +49,20 @@ const props = defineProps<{
}>();
async function toggleCollapse() {
if (isCollapsed) {
const isOpening = isCollapsed.value;
isCollapsed.value = !isCollapsed.value;
if (isOpening) {
await fetchDirectoryContent();
}
isCollapsed.value = !isCollapsed.value;
}
async function fetchDirectoryContent() {
try {
isLoading.value = true;
content.value = await fs.listDir(props.dir);
// preload from cache
content.value = await fs.listDir(props.dir, {cache: 'only-cache'});
// replace with network
content.value = await fs.listDir(props.dir, {cache: 'use-network'});
} finally {
isLoading.value = false;
}

View File

@@ -1,6 +1,19 @@
<template>
<div v-if="currentPreviewUrl" class="file-preview">
<img :src="currentPreviewUrl" alt="preview" class="file-preview__image" />
<img
v-if="currentPrevewType?.startsWith('image/')"
:src="currentPreviewUrl"
alt="preview"
class="file-preview__image" />
<video
v-if="currentPrevewType?.startsWith('video/')"
:src="currentPreviewUrl"
autoplay
muted
loop
playsinline
class="file-preview__video"
></video>
</div>
</template>
<script setup lang="ts">
@@ -15,26 +28,42 @@ const props=defineProps<{
const fs = inject(MountedFsSymbol)!;
const currentPreviewUrl = ref<string | null>(null);
const currentPrevewType = ref<string | null>(null);
let abort: AbortController | undefined = undefined;
watch(()=> props.file, async (file)=>{
console.log('file', file);
if (abort) {
abort.abort('User canceled');
}
abort = new AbortController();
if (currentPreviewUrl.value) {
URL.revokeObjectURL(currentPreviewUrl.value);
}
if (!file) {
currentPreviewUrl.value = null;
return;
}
const blob = await fs.download(file);
try {
console.log('downloading thumbnail');
const blob = await fs.downloadThumbnail(file, {abortSignal: abort.signal});
currentPreviewUrl.value = URL.createObjectURL(blob);
currentPrevewType.value = file.mimeType;
console.log('currentPreviewUrl', currentPreviewUrl.value);
abort = undefined;
} catch (e) {
if (e !== 'User canceled') {
console.error(e);
} else {
console.log('User canceled');
}
}
})
</script>
<style>
.file-preview__video,
.file-preview__image {
max-width: 100%;
}
</style>

View File

@@ -46,7 +46,6 @@ function loadFilePreview(file: VirtualFile){
const dragging = ref(false);
function onDragStart(){
console.log('dragstart');
dragging.value = true;
}
@@ -57,9 +56,7 @@ function onDragLeave(event) {
}
function onDragEnd(){
console.log('dragend');
dragging.value = false;
showPreview();
}
window.addEventListener('dragend', onDragEnd, {passive: true});

View File

@@ -0,0 +1,3 @@
<template>
<svg x="0px" y="0px" width="100%" height="100%" viewBox="0 0 24 24"><g><path id="cast_caf_icon_arch0" class="cast_caf_state_d" d="M1,18 L1,21 L4,21 C4,19.3 2.66,18 1,18 L1,18 Z"></path><path id="cast_caf_icon_arch1" class="cast_caf_state_d" d="M1,14 L1,16 C3.76,16 6,18.2 6,21 L8,21 C8,17.13 4.87,14 1,14 L1,14 Z"></path><path id="cast_caf_icon_arch2" class="cast_caf_state_d" d="M1,10 L1,12 C5.97,12 10,16.0 10,21 L12,21 C12,14.92 7.07,10 1,10 L1,10 Z"></path><path id="cast_caf_icon_box" class="cast_caf_state_d" d="M21,3 L3,3 C1.9,3 1,3.9 1,5 L1,8 L3,8 L3,5 L21,5 L21,19 L14,19 L14,21 L21,21 C22.1,21 23,20.1 23,19 L23,5 C23,3.9 22.1,3 21,3 L21,3 Z"></path><path id="cast_caf_icon_boxfill" class="cast_caf_state_h" d="M5,7 L5,8.63 C8,8.6 13.37,14 13.37,17 L19,17 L19,7 Z"></path></g></svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.403-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.31-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .658.122Z" clip-rule="evenodd" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25ZM12.75 9a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V15a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V9Z" clip-rule="evenodd" />
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125Z" />
</svg>
</template>

View File

@@ -5,109 +5,232 @@
:class="{
'map-control--over': isDragOver
}"
@onDrop="onDrop"
@dropFile="onDrop"
@mousewheel="onScroll"
@mousedown="onMouseDown"
>
<div ref="rendererWrapper">
<MapRenderer
ref="renderer"
:layers="layers"
:width="3840"
:height="2160"
:offsetX="offsetX"
:offsetY="offsetY"
:dpi="300"
<button @click="reset()">reset</button>
<select v-model="mode">
<option value="drag">drag</option>
<option value="draw">draw</option>
<option value="measure">measure</option>
</select>
<select
v-if="canvasDisplay?.mapLayer"
v-model="canvasDisplay.mapLayer.grid"
>
<option value="none">no grid</option>
<option value="light">light grid</option>
<option value="dark">dark grid</option>
</select>
<select
v-if="canvasDisplay?.mapLayer"
v-model="canvasDisplay.mapLayer.rotation"
>
<option :value="0">0°</option>
<option :value="1">90°</option>
<option :value="2">180°</option>
<option :value="3">270°</option>
</select>
<input
v-if="canvasDisplay?.mapLayer"
type="number"
v-model="canvasDisplay.mapLayer.mapWidthInInches"
/>
<div ref="rendererWrapper" v-show="canvasDisplay">
<canvas :width="selectedScreen?.imageSink?.resolution.width" :height="selectedScreen?.imageSink?.resolution.height" ref="canvas" style="max-width: 100%;" />
</div>
</Dropzone>
<button @click="onLoad">load</button>
</template>
<script setup lang="ts">
import {inject, onMounted, ref} from 'vue';
import {computed, inject, onBeforeUnmount, onMounted, reactive, ref} from 'vue';
import Dropzone from '../Dropzone/Dropzone.vue';
import MapRenderer from './MapRenderer.vue';
import {MountedFsSymbol} from '../../model/injectionSymbols';
import {MapDrawable, MapImage} from './helpers';
import {MountedFsSymbol, RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
import {closerToZero, MapDrawable, MapImage} from './helpers';
import {VirtualFile} from '../../model/FileSystem/types';
import {CanvasDisplay, MapLayerDrawable} from './object';
import {Devices} from '../../model/Remote/RemoteDeviceManager';
const fs = inject(MountedFsSymbol)!;
const layers = ref<MapDrawable[]>([])
const renderer = ref<MapRenderer>();
const rendererWrapper = ref<HTMLDivElement>();
const scale = ref(1);
const offsetX = ref(0);
const offsetY = ref(0);
const remoteManager = inject(RemoteDeviceManagerSymbol)!;
const screens = computed(() => {
return remoteManager.devices
.filter(device => device.hasImageSink)
})
const canvas = ref<HTMLCanvasElement>();
const selectedScreen = ref<Devices>();
const pointerVisible = ref(false);
let canvasDisplay: undefined | CanvasDisplay;
function selectScreen (screen: any) {
if (screen.imageSink?.display) {
selectedScreen.value = screen;
console.log('selectScreen!!!', screen.imageSink?.display);
canvasDisplay?.disconnectTarget(canvas.value!);
canvasDisplay = screen.imageSink?.display;
canvasDisplay?.connectTarget(canvas.value!);
}
}
async function onDrop(file: VirtualFile) {
const data = await fs.download(file);
console.log('file', file, data);
console.log('onDrop!!', file);
onLoad(file);
}
const mode = ref<'drag' | 'draw'>('drag');
async function onLoad() {
const newLayers = [];
const map: VirtualFile = {
"type": "file",
"source": "Maps",
"name": "G_AbandonedMineEntrance_EscapedDragon_Night.jpg",
"mimeType": "image/jpeg",
"path": [
"Abandoned Mine Entrance [33x21] - $5 Rewards",
"Gridded",
"G_AbandonedMineEntrance_EscapedDragon_Night.jpg"
],
"map": {
"width": 33,
"height": 21,
"valid": true,
"animated": false
}
}
const mapFile = await fs.download(map);
const mapLayer = await MapImage.fromImageFile(mapFile, {
gameDimensions: {w: 33, h: 21},
});
newLayers.push(mapLayer);
layers.value = newLayers;
}
const mode = ref<'drag' | 'draw' | 'measure'>('drag');
const dragging = ref(false);
function onMouseMove(event: MouseEvent) {
const mouseScale = rendererWrapper.value!.clientWidth / 3840;
console.log(renderer.value)
if (dragging.value) {
offsetX.value += event.movementX / mouseScale;
offsetY.value += event.movementY / mouseScale;
function onScroll(event: WheelEvent) {
event.preventDefault();
// console.log('scroll', event.deltaY, event.offsetX, event.offsetY);
const delta = -1 * event.deltaY / 2000;
if (!canvasDisplay) {
return;
}
canvasDisplay.scale = Math.min(Math.max(0.1, canvasDisplay.scale + delta), 10);
if (canvas.value) {
if (delta > 0) {
const displayMousePosition = {
x: ((event.offsetX / canvas.value!.width) - 0.5) * 2 * canvasDisplay.width,
y: ((event.offsetY / canvas.value!.height) - 0.5) * 2 * canvasDisplay.height,
};
canvasDisplay.offsetX += displayMousePosition.x * delta;
canvasDisplay.offsetY += displayMousePosition.y * delta;
} else {
canvasDisplay.offsetX -= closerToZero((canvasDisplay.width * Math.abs(delta)) * Math.sign(canvasDisplay.offsetX), canvasDisplay.offsetX);
canvasDisplay.offsetY -= closerToZero((canvasDisplay.height * Math.abs(delta)) * Math.sign(canvasDisplay.offsetY), canvasDisplay.offsetY);
}
}
}
function onMouseUp(event: MouseEvent) {
console.log('mouse up', event);
dragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
function showPointer(show: boolean) {
pointerVisible.value = show;
if (canvasDisplay && !show) {
canvasDisplay.distanceMeasurement.pointer = undefined;
}
}
function onMouseDown(event: MouseEvent) {
console.log('mouse down', event);
window.getSelection()?.removeAllRanges();
if (mode.value === 'drag') {
dragging.value = true;
window.addEventListener('mousemove', onMouseMove);
}
if (mode.value === 'measure') {
if (canvasDisplay) {
const mouseScale = canvas.value!.clientWidth / canvasDisplay.width;
canvasDisplay.distanceMeasurement.start = {x: event.offsetX / mouseScale, y: event.offsetY / mouseScale}
}
}
window.addEventListener('mouseup', onMouseUp);
}
function onMouseUp(event: MouseEvent) {
dragging.value = false;
window.removeEventListener('mouseup', onMouseUp);
if (mode.value === 'measure' && canvasDisplay) {
canvasDisplay.distanceMeasurement.start = undefined;
}
}
function onMouseMove(event: MouseEvent) {
if (pointerVisible.value && canvasDisplay) {
const mouseScale = canvas.value!.clientWidth / canvasDisplay.width;
if (canvasDisplay) {
canvasDisplay.distanceMeasurement.pointer = {x: event.offsetX / mouseScale, y: event.offsetY / mouseScale};
}
}
if (event.buttons === 0) {
onMouseUp(event);
return;
}
if (!canvasDisplay) {
return;
}
const mouseScale = canvas.value!.clientWidth / canvasDisplay.width;
if (dragging.value) {
canvasDisplay.offsetX -= event.movementX / mouseScale;
canvasDisplay.offsetY -= event.movementY / mouseScale;
}
if (mode.value === 'measure') {
if (canvasDisplay) {
canvasDisplay.distanceMeasurement.end = {x: event.offsetX / mouseScale, y: event.offsetY / mouseScale};
}
}
}
function reset() {
if (!canvasDisplay) {
return;
}
canvasDisplay.offsetX = 0;
canvasDisplay.offsetY = 0;
canvasDisplay.scale = 1;
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Control') {
showPointer(true);
}
}
function onKeyup(event: KeyboardEvent) {
if (event.key === 'Control') {
showPointer(false);
}
}
onMounted(() => {
onLoad();
canvasDisplay?.connectTarget(canvas.value!);
window.addEventListener('reveal-screen', (event: CustomEvent) => {
selectScreen(event.detail);
});
window.addEventListener('keydown', onKeydown);
window.addEventListener('keyup', onKeyup);
window.addEventListener('mousemove', onMouseMove);
});
onBeforeUnmount(() => {
canvasDisplay?.disconnectTarget(canvas.value!);
window.removeEventListener('keydown', onKeydown);
window.removeEventListener('keyup', onKeyup);
window.removeEventListener('mousemove', onMouseMove);
});
async function onLoad(file?: VirtualFile | File) {
const meta = file;
if (!canvasDisplay) {
return;
}
if (!(file instanceof File) && file?.type === 'file') {
file = await fs.download(file);
} else {
return;
}
console.log('onLoad', file, meta?.map?.width);
// @ts-ignore
const layer = new MapLayerDrawable(file, meta?.map?.width);
await layer.preloadFile();
canvasDisplay.mapLayer = layer;
};
</script>
<style lang="css">
.map-control {
display: inline-block;
border: 1px solid black;
border: 1px solid white;
background: white;
position: relative;
}

View File

@@ -1,6 +1,6 @@
<template>
<canvas
ref="map" style="max-width: 100%;"
ref="map" style="max-width: 25%;"
:width="props.width"
:height="props.height"
></canvas>
@@ -17,6 +17,7 @@ const props = defineProps<{
offsetX: number,
offsetY: number,
dpi: number,
scale: number,
layers: MapDrawable[]
}>()
@@ -30,7 +31,7 @@ const mapContext = computed(() => {
virtualWidth: props.width,
offsetX: props.offsetX,
offsetY: props.offsetY,
scale: props.dpi,
scale: props.scale,
dpi: props.dpi,
}
})
@@ -40,6 +41,9 @@ function redraw() {
ctx: map.value!.getContext('2d')!,
...mapContext.value,
};
console.log('redraw', ctx.scale);
ctx.ctx.fillStyle = 'black';
ctx.ctx.fillRect(0, 0, ctx.virtualWidth, ctx.virtualHeight);
for (const layer of props.layers) {
layer.draw(ctx);
}

View File

@@ -1,4 +1,4 @@
async function loadImage(src: string | File): Promise<HTMLImageElement> {
export async function loadImage(src: string | File): Promise<HTMLImageElement> {
if (src instanceof File) {
console.log('Creating object URL');
src = URL.createObjectURL(src);
@@ -15,6 +15,10 @@ async function loadImage(src: string | File): Promise<HTMLImageElement> {
}
}
export function closerToZero(a: number, b: number) {
return Math.abs(a) < Math.abs(b) ? a : b;
}
/**
* number of 1 inch squares in the map
*/
@@ -61,7 +65,7 @@ export class MapImage implements MapDrawable {
}
draw(mapContext: MapContext) {
const {ctx, dpi, virtualWidth, virtualHeight, offsetX, offsetY} = mapContext;
const {ctx, dpi, virtualWidth, virtualHeight, offsetX, offsetY, scale} = mapContext;
const naturalWidth = this.file.width;
const naturalHeight = this.file.height;
const w = this.gameDimensions.w!;
@@ -77,7 +81,7 @@ export class MapImage implements MapDrawable {
ctx.drawImage(
this.file,
0, 0, naturalWidth, naturalHeight,
basicOffsetX + offsetX, basicOffsetY + offsetY, w * imageDpi * dpiScale, h * imageDpi * dpiScale,
basicOffsetX + offsetX, basicOffsetY + offsetY, w * imageDpi * dpiScale * scale, h * imageDpi * dpiScale * scale,
);
}

View File

@@ -0,0 +1,389 @@
/* CanvasDisplay
- width, height - size of the map
- dpi - dots per inch (natural resolution of the map)
- offset (x,y) - set by moving the map
- scale - zoom set by mouse wheel
*/
import { loadImage } from "./helpers";
/* MapLayer
- image (url/bitmap?) - fog image
- offset (x,y) - usually 0
- scale - usually display.dpi / image.dpi
- dpi - dots per inch
- fog? - bitmap mask
*/
/* Tokens / Picture
- image (url/bitmap?)
- offset (x,y)
- size (w,h) (defaults to contains)
*/
type RenderingContext2D = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
export interface CanvasDisplayDrawable {
draw(ctx: RenderingContext2D, display: CanvasDisplay): void;
bounds?(display: CanvasDisplay): {left: number, top: number, right: number, bottom: number} | undefined;
}
type GridStyle = 'none' | 'light' | 'dark';
export type Measurement = DistanceMeasurement;
export class DistanceMeasurement implements CanvasDisplayDrawable {
public start?: {x: number, y: number};
public end?: {x: number, y: number};
public pointer?: {x: number, y: number};
draw(ctx: RenderingContext2D, display: CanvasDisplay): void {
ctx.scale(1/display.scale, 1/display.scale);
ctx.translate(display.offsetX - display.width/2, display.offsetY - display.height / 2 );
if (this.pointer) {
console.log('Drawing pointer', );
ctx.fillStyle = 'rgba(255,0,0,0.5)';
ctx.beginPath();
ctx.arc(this.pointer.x, this.pointer.y, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,0,0.5)';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.arc(this.pointer.x, this.pointer.y, 12, 0, 2 * Math.PI);
ctx.stroke();
ctx.strokeStyle = 'rgba(0,255,255,0.5)';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.arc(this.pointer.x, this.pointer.y, 24, 0, 2 * Math.PI);
ctx.stroke();
}
if (!this.start || !this.end) {
return;
}
const lengthPx = Math.sqrt(Math.pow(this.start!.x - this.end!.x, 2) + Math.pow(this.start!.y - this.end!.y, 2));
const lengthFeet = ((lengthPx / display.dpi) * 5) / display.scale;
const roundedFeets = Math.floor(lengthFeet/5) * 5;
const roundedPx = ((roundedFeets) / 5) * display.dpi * display.scale;
const halfInchInPx = 0.5 * display.dpi * display.scale;
ctx.strokeStyle = 'rgba(255,0,0,0.5)';
ctx.lineWidth = 10;
ctx.beginPath();
ctx.moveTo(this.start.x, this.start.y);
ctx.lineTo(this.end.x, this.end.y);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,0,0.25)';
ctx.beginPath();
ctx.arc(this.start.x, this.start.y, roundedPx + halfInchInPx, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = 'rgba(0,255,255,0.25)';
ctx.beginPath();
ctx.moveTo(this.start.x, this.start.y);
const angle = Math.atan2(-this.end.x + this.start.x, this.end.y - this.start.y) + Math.PI/2;
ctx.arc(this.start.x, this.start.y, roundedPx + halfInchInPx, angle - Math.PI/6, angle - Math.PI/6);
ctx.arc(this.start.x, this.start.y, roundedPx + halfInchInPx, angle + Math.PI/6, angle + Math.PI/6);
ctx.moveTo(this.start.x, this.start.y);
ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(this.end!.x, this.end!.y - 30, 200, 40);
ctx.font = "30px Arial";
ctx.fillStyle = 'red';
ctx.fillText(`${roundedFeets} / ${lengthFeet.toFixed(2)} ft`, this.end!.x + 5, this.end!.y);
}
}
export class MapLayerDrawable implements CanvasDisplayDrawable {
/** actual file to draw */
file: File;
/** how many inches is the image wide */
private mapWidth?: number ;
img?: HTMLImageElement;
video?: HTMLVideoElement;
offsetX: number = 0;
offsetY: number = 0;
scale: number = 1;
dpi? : number;
rotation: number = 0;
grid: GridStyle = 'none';
constructor(
file: File,
mapWidth: number | undefined,
) {
this.file = file;
this.mapWidth = mapWidth;
}
async preloadFile() {
if (this.img) {
this.img = undefined;
}
if (this.video) {
this.video.pause();
URL.revokeObjectURL(this.video.src);
this.video = undefined;
}
if (this.file.type.startsWith('image/')) {
this.img = await loadImage(this.file);
this.mapWidthInInches = this.mapWidth ?? 10;
if (this.img.naturalWidth < this.img.naturalHeight) {
this.rotation = 1;
}
} else if (this.file.type.startsWith('video/')) {
const video = document.createElement('video');
const videoMeta = new Promise<void>((resolve) => {
video.addEventListener('canplay', () => resolve(), {once: true});
});
video.src = URL.createObjectURL(this.file);
video.muted = true;
video.playsInline = true;
video.loop = true;
video.play();
await videoMeta;
if (video.videoWidth < video.videoHeight) {
this.rotation = 1;
}
this.video = video;
}
}
set mapWidthInInches(value: number) {
this.mapWidth = value;
if (this.img) {
this.dpi = this.img.naturalWidth / value;
} else if (this.video) {
this.dpi = this.video.videoWidth / value;
}
}
get mapWidthInInches() {
return this.mapWidth ?? 10;
}
private setDefaultDPI(display: CanvasDisplay, width: number, height: number) {
if (this.dpi === undefined) {
this.dpi = Math.min((width / display.width), height / display.height) * display.dpi;
}
}
private dimensions(display: CanvasDisplay) {
if (this.img) {
this.setDefaultDPI(display, this.img?.naturalWidth, this.img?.naturalHeight);
const finalScale = (display.dpi / this.dpi!) * this.scale;
const finalWidth = this.img.naturalWidth * finalScale;
const finalHeight = this.img.naturalHeight * finalScale;
return {
left: -finalWidth/2,
top: -finalHeight/2,
bottom: finalHeight/2,
right: finalWidth/2,
width: finalWidth,
height: finalHeight,
}
} else if (this.video) {
this.setDefaultDPI(display, this.video.videoWidth, this.video.videoHeight);
const finalScale = (display.dpi / this.dpi!) * this.scale;
const finalWidth = this.video.videoWidth * finalScale;
const finalHeight = this.video.videoHeight * finalScale;
return {
left: -finalWidth/2,
top: -finalHeight/2,
bottom: finalHeight/2,
right: finalWidth/2,
width: finalWidth,
height: finalHeight,
}
}
}
private drawGrid(ctx: RenderingContext2D, display: CanvasDisplay) {
if (this.grid === 'none') {
return;
}
const dim = this.dimensions(display);
if (!dim) {
return;
}
const {width, height} = dim;
if (this.grid === 'dark') {
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
} else {
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
}
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < width; x += display.dpi) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = 0; y < height; y += display.dpi) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.stroke();
}
draw(ctx: RenderingContext2D, display: CanvasDisplay) {
const dim = this.dimensions(display);
if (!dim) {
return;
}
const {width, height} = dim;
ctx.rotate(this.rotation * 90 * Math.PI / 180);
ctx.translate(-width/2, -height/2);
if (this.img) {
ctx.drawImage(this.img, 0, 0, width, height);
} else if (this.video) {
ctx.drawImage(this.video, 0, 0, width, height);
}
this.drawGrid(ctx, display);
}
bounds(display: CanvasDisplay) {
return this.dimensions(display);
}
}
export class CanvasDisplay {
width: number;
height: number;
dpi: number;
offsetX: number;
offsetY: number;
scale: number;
canvas: HTMLCanvasElement;
mapLayer?: MapLayerDrawable;
distanceMeasurement: DistanceMeasurement = new DistanceMeasurement();
// layers: CanvasDisplayDrawable[] = [];
targets: Set<HTMLCanvasElement> = new Set();
animationFrameRequest?: number;
get layers(): CanvasDisplayDrawable[] {
const layers = [];
if (this.mapLayer) {
layers.push(this.mapLayer);
}
layers.push(this.distanceMeasurement);
return layers;
}
constructor(width: number, height: number, diameter?: number) {
this.dpi = 100;
this.width = width;
this.height = height;
if (diameter) {
console.log('Setting diameter', diameter);
this.setDiameter(diameter);
}
this.offsetX = 0;
this.offsetY = 0;
this.scale = 1;
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
}
correctOffset() {
for (const layer of this.layers) {
const bounds = layer.bounds?.(this);
if (!bounds) {
continue;
}
}
}
resize(width: number, height: number) {
this.width = width;
this.height = height;
this.canvas.width = width;
this.canvas.height = height;
this.redrawBuffer();
}
redrawBuffer() {
this.correctOffset();
const ctx = this.canvas.getContext('2d', { alpha: false });
if (!ctx) {
console.warn('Failed to get 2d context');
return;
}
// clear the canvas
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, this.width, this.height);
// offset and scale the canvas
try {
// draw the layers
for (const layer of this.layers) {
ctx.translate(-this.offsetX + this.width/2, -this.offsetY + this.height / 2 );
ctx.scale(this.scale, this.scale);
layer.draw(ctx, this);
ctx.resetTransform();
}
} finally {
}
}
redrawDisplay(display: HTMLCanvasElement) {
const ctx = display.getContext('2d');
if (!ctx) {
console.warn('Failed to get 2d context of real canvas');
return;
}
this.redrawBuffer();
ctx.drawImage(this.canvas, 0, 0, display.width, display.height);
}
redrawAllTargets() {
for (const target of this.targets) {
this.redrawDisplay(target);
}
this.animationFrameRequest = requestAnimationFrame(this.redrawAllTargets.bind(this));
}
start() {
this.stop();
this.redrawAllTargets();
}
stop() {
if (this.animationFrameRequest) {
cancelAnimationFrame(this.animationFrameRequest);
}
}
connectTarget(target: HTMLCanvasElement) {
this.targets.add(target);
if (this.targets.size > 0) {
this.start();
console.log('Canvas', this.canvas);
}
}
setDiameter(diameter: number) {
this.dpi = Math.sqrt(Math.pow(this.width, 2) + Math.pow(this.height, 2)) / diameter;
}
async getStream() {
this.redrawBuffer();
console.log('Streamed Canvas', this.canvas.width, this.canvas.height);
await new Promise((resolve) => requestAnimationFrame(resolve));
return this.canvas.captureStream(25);
}
disconnectTarget(target: HTMLCanvasElement) {
this.targets.delete(target);
if (this.targets.size === 0) {
this.stop();
}
}
}

View File

@@ -3,16 +3,38 @@
<div>
<label>Name</label>
<input type="text" placeholder="Name" v-model="name" />
<button @click="showAvailableDevices">Available devices</button>
<button @click="showAvailableDevices">Create secondary screen</button>
</div>
<hr>
<div>
<div v-for="device in manager.devices">
<span @click="manager.removeDevice(device)">&times;</span> {{ device.name }}
<button v-if="!device.isConnected" @click="device.connect()">connect</button>
<button v-else @click="device.disconnect()">disconnect</button>
</div>
</div>
<table>
<thead>
<tr>
<th>Device</th>
<th>Inches</th>
<th colspan="2">Actions</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="device in manager.devices">
<td>{{ device.name }}</td>
<td>
<input
v-if="device.imageSink"
type="number"
v-model="device.imageSink.diameter"
/>
</td>
<td>
<button v-if="!device.isConnected" @click.prevent="device.connect()">Connect</button>
<button v-else @click.prevent="device.disconnect()">Disconnect</button>
</td>
<td>
<button @click.prevent="manager.removeDevice(device)">Delete</button>
</td>
</tr>
</tbody>
</table>
<dialog ref="localWindowDialog">
<div v-for="device in availableDevices">
<div class="remote-devices__available-device" @click="addAvailableDevice(device)">

View File

@@ -21,6 +21,8 @@
<canvas
ref="canvas"
style="max-width: 100%;"
:width="imageSink?.resolution?.width / 4"
:height="imageSink?.resolution?.height / 4"
:style="{
// transform: `scale(${scale / 100}) translate(${(x)}%, ${(y)}%)`,
}"
@@ -34,6 +36,7 @@ import {MountedFsSymbol} from '../../model/injectionSymbols';
import {VirtualFile} from '../../model/FileSystem/types';
import {useLoading} from '../../helpers/withLoading';
import {useMouseDragging} from '../../helpers/mouseDragging';
import {MapLayerDrawable} from '../Map/object';
const props = defineProps<{
screen: RemoteDevice;
@@ -63,6 +66,12 @@ watch(() => [x.value, y.value, scale.value], () => {
});
})
watch(() => imageSink.value?.display, ()=> {
if (imageSink.value?.display) {
imageSink.value?.display.connectTarget(canvas.value!);
}
});
async function drawImage(blob: Blob) {
if (blob.type.startsWith('image/')) {
@@ -91,14 +100,20 @@ async function drawImage(blob: Blob) {
}
}
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 loadFile(meta: VirtualFile, file: File) {
if (imageSink.value?.display) {
const map = new MapLayerDrawable(file, meta.map?.width);
await map.preloadFile();
imageSink.value.display.mapLayer = map
console.log(imageSink.value?.display);
}
// await withLoading(async () => {
// await Promise.all([
// imageSink.value?.transferFile(meta, file),
// drawImage(file),
// ])
// await imageSink.value?.show(meta);
// })
}
async function onDrop(event: DragEvent) {

View File

@@ -1,7 +1,11 @@
<template>
<div class="screens">
<RemoteScreen :screen="screen" v-for="screen of screens" />
<div @click="remoteManager.connectChromeCast()">chromecast</div>
<RemoteScreen :screen="screen" v-for="screen of screens" @click="revealScreen(screen)" />
<div>
<IconButton name="Chromecast" @click="remoteManager.connectChromeCast()">
<IconChromecast />
</IconButton>
</div>
</div>
</template>
<script setup lang="ts">
@@ -9,6 +13,9 @@ import {computed, inject} from 'vue';
import {RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
import RemoteScreen from './RemoteScreen.vue';
import {ImageSink} from '../../model/Remote/types';
import IconButton from '../Common/IconButton.vue';
import IconChromecast from '../Icons/IconChromecast.vue';
import {Devices} from '../../model/Remote/RemoteDeviceManager';
const remoteManager = inject(RemoteDeviceManagerSymbol)!;
const screens = computed(() => {
@@ -16,6 +23,10 @@ const screens = computed(() => {
.filter(device => device.hasImageSink)
})
function revealScreen(screen: Devices) {
window.dispatchEvent(new CustomEvent('reveal-screen', {detail: screen}));
}
</script>
<style>
.screens {

View File

@@ -0,0 +1,68 @@
export type PersistentKeyVal<T> = {
value: T;
}
export class PersistentKeyValStore<T> {
private: Map<string, T> = new Map();
private key: string;
constructor(key: string) {
this.key = key;
}
set(key: string, value: T): void {
this.private.set(key, value);
this.save();
}
has(key: string): boolean {
return this.private.has(key);
}
get(key: string): T | undefined {
return this.private.get(key);
}
delete(key: string): void {
this.private.delete(key);
this.save();
}
private save() {
localStorage
.setItem(this.key, JSON.stringify(Array.from(this.private.entries())));
}
async load() {
const data = localStorage.getItem(this.key);
if (data) {
this.private = new Map(JSON.parse(data));
}
return this;
}
async getPersistentValue(key: string, defaultValue: T): Promise<PersistentKeyVal<T>> {
const store = this;
return {
_value: store.get(key) ?? defaultValue,
get value() {
return this._value;
},
set value(value: T) {
this._value = value;
store.set(key, value);
}
} as PersistentKeyVal<T> & { _value: T };
}
}
const originals = new Map();
export async function createPersistentKeyValStore<T>(key: string): Promise<PersistentKeyValStore<T>> {
if (!originals.has(key)) {
originals.set(key, await (new PersistentKeyValStore<T>(key).load()));
}
return originals.get(key);
}
export async function createPersistentValue<T>(store: string, key: string, defaultValue: T): Promise<PersistentKeyVal<T>> {
return await (await createPersistentKeyValStore<T>(store)).getPersistentValue(key, defaultValue);
}

View File

@@ -11,6 +11,7 @@ export interface PlaylistGroupNodeOptions extends SoundGroupNodeOptions {
export class PlaylistGroupNode extends SoundGroupNode<PlaylistGroupNodeOptions> {
public readonly type = 'playlist';
private intervalId?: number;
changeTrack(track: number) {
this.currentTrack.stop();
@@ -29,13 +30,31 @@ export class PlaylistGroupNode extends SoundGroupNode<PlaylistGroupNodeOptions>
return this.soundNodes.some(sound => sound.isPlaying);
}
stateChecker() {
if (this.currentTrack?.isPlaying) {
this.options.currentTrackTime = this.currentTrack.currentTime;
} else {
this.next();
}
}
play() {
this.currentTrack?.play();
clearInterval(this.intervalId);
// @ts-ignore
this.intervalId = setInterval(() => {
this.stateChecker();
}, 1000);
}
stop() {
this.currentTrack?.stop();
clearInterval(this.intervalId);
}
togglePlay() {
if (this.isPlaying) {
this.currentTrack?.stop();
this.stop();
} else {
this.play();
}

View File

@@ -65,6 +65,13 @@ export abstract class SoundGroupNode<T extends SoundGroupNodeOptions> {
}
}
removeSoundNode(node: SoundNode) {
const index = this.soundNodes.indexOf(node);
if (index > -1) {
this.soundNodes.splice(index, 1);
}
}
sounds() {
return this.soundNodes;
}
@@ -91,7 +98,6 @@ export abstract class SoundGroupNode<T extends SoundGroupNodeOptions> {
}
get volume() {
return this.options.volume;
}

View File

@@ -22,6 +22,7 @@ export abstract class SoundNode {
public readonly numberOfOutputs = 1;
protected activeSources: AudioBufferSourceNode[] = [];
protected audioContext: AudioContext;
public currentTime: number = 0;
protected readonly fs: MountedFs;
constructor(

View File

@@ -1,3 +1,4 @@
import { CancellationToken } from "typescript";
import { extensionMimeType } from "../FileMeta/ExtensionMimeType";
import { CachePolicy, FsCache } from "./FsCache";
import { VirtualDirectory, VirtualFile, FileSystemSource, ListDirOptions } from "./types";
@@ -40,13 +41,16 @@ export class FileStashFs implements FileSystemSource<FileStashFsConfig> {
}
}
private createUrl(type: 'cat' | 'ls', path: string[]) {
private createUrl(type: 'cat' | 'ls', path: string[], query: Record<string, 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('/')}`);
for (const [key, value] of Object.entries(query)) {
params.set(key, value);
}
url.search = params.toString();
return url;
}
@@ -80,15 +84,46 @@ export class FileStashFs implements FileSystemSource<FileStashFsConfig> {
} else {
throw new Error(`Unknown type ${result.type}`);
}
}).sort((a, b) => {
if (a.type === 'file' && b.type === 'directory') {
return 1;
} else if (a.type === 'directory' && b.type === 'file') {
return -1;
} else if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
}
return 0;
});
}
async download(file: VirtualFile): Promise<File> {
const response = await fetch(this.createUrl('cat', file.path));
async download(file: VirtualFile, {abortSignal}: {abortSignal?: AbortSignal} = {}): Promise<File> {
const response = await fetch(this.createUrl('cat', file.path), {signal: abortSignal});
if (!response.ok) {
throw new Error(`Failed to download file ${file.path.join('/')}`);
}
return new File([await response.blob()], file.name);
return new File([await response.blob()], file.name, {type: file.mimeType});
}
async downloadThumbnail(file: VirtualFile, {abortSignal}: {abortSignal?: AbortSignal} = {}): Promise<File> {
try {
const abortSignals = [AbortSignal.timeout(1000)]
if (abortSignal) {
abortSignals.push(abortSignal);
}
const response = await fetch(this.createUrl('cat', file.path, {thumbnail: 'true'}), {
signal: AbortSignal.any(abortSignals),
});
if (!response.ok) {
throw new Error(`Failed to download file ${file.path.join('/')}`);
}
return new File([await response.blob()], file.name, {type: file.mimeType});
} catch (err) {
if (err !== 'User canceled') {
return this.download(file, {abortSignal});
}
}
}
getConfig() {

View File

@@ -9,6 +9,7 @@ const CACHE = Symbol('Cache');
export interface Fs {
get root(): VirtualDirectory[];
download(file: VirtualFile): Promise<File>;
downloadThumbnail(file: VirtualFile): Promise<File>;
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
}
@@ -38,6 +39,14 @@ export class MountedFs implements Fs {
return fs.download(file);
}
downloadThumbnail(file: VirtualFile, {abortSignal}: {abortSignal?: AbortSignal} = {}) {
const fs = this.filesystems.get(file.source);
if (!fs) {
throw new Error(`Filesystem ${file.source} not found`);
}
return fs.downloadThumbnail(file, {abortSignal});
}
async listDir(dir: VirtualDirectory, options?: ListDirOptions) {
const fs = this.filesystems.get(dir.source);

View File

@@ -92,6 +92,10 @@ export class NginxFS implements FileSystemSource<NginxFsConfig> {
return new File([await response.blob()], file.name, { type: file.mimeType });
}
async downloadThumbnail(file: VirtualFile): Promise<File> {
return this.download(file);
}
getConfig() {
return {
...this.options,

View File

@@ -4,6 +4,7 @@ export interface FileSystemSource<TOptions> {
root: VirtualDirectory;
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
download(file: VirtualFile): Promise<File>;
downloadThumbnail(file: VirtualFile, options?: {abortSignal?: AbortSignal}): Promise<File>;
getConfig(): TOptions & { type: string };
}

View File

@@ -36,6 +36,17 @@ export class WebRtcChannelCaster extends MessageEmitter implements JsonChannelCa
});
}
}
this.peer.onnegotiationneeded = async () => {
const offer = await this.peer.createOffer();
await this.peer.setLocalDescription(offer);
// Send the offer to the remote peer
this.channel.send({
type: 'RtcOffer',
offer
});
console.log('negotiationneeded');
}
}
private onMessage = (event: MessageEvent) => {
@@ -86,6 +97,10 @@ export class WebRtcChannelCaster extends MessageEmitter implements JsonChannelCa
this.dataChannel.send(JSON.stringify(data));
}
connectVideoStream(stream: MediaStream) {
this.peer.addTrack(stream.getVideoTracks()[0], stream);
}
disconnect() {
super.disconnect();
}
@@ -109,13 +124,28 @@ export class WebRtcChannelSummon extends MessageEmitter implements JsonChannelSu
}
}
this.peer.ontrack = (event) => {
console.log('ontrack', event);
event.streams.forEach((stream) => {
stream.getTracks().forEach((track) => {
if (event.track.kind === 'audio') {
console.log('audio track', track);
const audioElement = document.createElement('audio');
audioElement.srcObject = event.streams[0]; // Attach the received stream
audioElement.srcObject = stream; // Attach the received stream
audioElement.play();
} else if (event.track.kind === 'video') {
console.log('video track', track);
const videoEl = document.getElementById('mainVideo') as HTMLVideoElement;
if (videoEl) {
videoEl.srcObject = stream;
videoEl.play();
}
}
});
});
};
this.peer.ondatachannel = (event) => {
if (event.channel.label === 'json') {
if (event.channel.label === 'video') {
this.videoChannel = event.channel;
} else if (event.channel.label === 'json') {
this.dataChannel = event.channel;
this.dataChannel.addEventListener('message', this.onRtcMessage);
this.emit({ type: 'connected' });

View File

@@ -61,6 +61,10 @@ export type ShowFileMessage = {
file: VirtualFile;
}
export type DisconnectMessage = {
type: 'disconnect';
}
export type DisconnectedMessage = {
type: 'disconnected';
}
@@ -92,6 +96,7 @@ export type Message = PingMessage |
FileTransferedMessage |
FileTransferProgressMessage |
ShowFileMessage |
DisconnectMessage |
DisconnectedMessage |
ConnectedMessage |
HelloMessage |

View File

@@ -1,3 +1,5 @@
import { CanvasDisplay } from "../../components/Map/object";
import { createPersistentKeyValStore, createPersistentValue, PersistentKeyVal } from "../../helpers/persistentKeyValue";
import { VirtualFile } from "../FileSystem/types";
import { PostMessageChannel } from "./Channels/PostMessage";
import { WebRtcChannelCaster } from "./Channels/WebRTCChannel";
@@ -32,7 +34,7 @@ export class ChromecastDevice {
const uuid = await channel.initialize();
const win = window.open(`/chromecast/?ws=${uuid}`, '', 'width=200,height=200')!;
// const channel = new PostMessageChannel(win);
channel.on((msg) => {
channel.on(async (msg) => {
console.log('CHROMECAST MESSAGE', msg)
// @ts-ignore
if (msg.type === 'webRtcSignalingNewClient') {
@@ -51,25 +53,41 @@ export class ChromecastDevice {
win.onbeforeunload = () => {
console.log('closed');
channel.disconnect();
//channel.disconnect();
}
});
}
disconnect() {
this.webRtcChannel?.send({
type: 'disconnect'
});
}
}
class ChromecastImageSink implements ImageSink {
resolution: { width: number; height: number; } = {width: NaN, height: NaN};
public display?: CanvasDisplay;
private _diameter?: PersistentKeyVal<number>;
constructor(private device: ChromecastDevice) {
}
setup() {
this.device.webRtcChannel?.on((msg)=> {
this.device.webRtcChannel?.on(async (msg)=> {
if (msg.type === 'resolution') {
this.resolution = msg;
if (!this.display) {
this._diameter = await createPersistentValue('chromecastDiameter', this.name, 65);
this.display = new CanvasDisplay(msg.width, msg.height, this.diameter);
console.log('connecting video stream');
this.device.webRtcChannel?.connectVideoStream(await this.display.getStream());
} else {
this.display.resize(msg.width, msg.height);
}
}
console.log(msg);
})
@@ -94,4 +112,17 @@ class ChromecastImageSink implements ImageSink {
x, y, scale
});
}
get diameter() {
return this._diameter?.value ?? NaN;
}
set diameter(value: number) {
if (this._diameter) {
this._diameter.value = value;
}
if (this.display) {
this.display.setDiameter(value);
}
}
}

View File

@@ -1,3 +1,5 @@
import { CanvasDisplay } from "../../components/Map/object";
import { createPersistentValue, PersistentKeyVal } from "../../helpers/persistentKeyValue";
import { VirtualFile } from "../FileSystem/types";
import { PostMessageChannel } from "./Channels/PostMessage";
import { JsonChannelCaster } from "./Channels/type";
@@ -99,20 +101,42 @@ export class FullscreenWindowDevice implements RemoteDevice {
class FullscreenWindowImageSink implements ImageSink {
public resolution = {width: NaN, height: NaN};
public display?: CanvasDisplay;
private _diameter?: PersistentKeyVal<number>;
constructor(private device: FullscreenWindowDevice) {}
setup() {
this.device.webRtcChannel?.on((msg) => {
this.device.webRtcChannel?.on(async (msg) => {
if(msg.type === 'resolution') {
this.resolution = {
width: msg.width,
height: msg.height
}
if (!this.display) {
this._diameter = await createPersistentValue('secondaryDiameter', this.name, 65);
this.display = new CanvasDisplay(msg.width, msg.height, this.diameter);
this.device.webRtcChannel?.connectVideoStream(await this.display.getStream());
} else {
this.display.resize(msg.width, msg.height);
}
}
});
}
get diameter() {
return this._diameter?.value ?? NaN;
}
set diameter(value: number) {
if (this._diameter) {
this._diameter.value = value;
}
if (this.display) {
this.display.setDiameter(value);
}
}
get name(): string {
return this.device.name;
}
@@ -123,7 +147,6 @@ class FullscreenWindowImageSink implements ImageSink {
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}) {

View File

@@ -1,3 +1,4 @@
import { CanvasDisplay } from "../../components/Map/object";
import { VirtualFile } from "../FileSystem/types";
export interface RemoteDeviceOptions {
@@ -24,6 +25,8 @@ export interface RemoteSink {
export interface ImageSink extends RemoteSink {
resolution: { width: number, height: number };
display?: CanvasDisplay;
diameter?: number;
show(file: VirtualFile): void;
}

View File

@@ -1,25 +1,9 @@
<template>
<AppLayout>
<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> -->
</div>
<template v-slot:overview>
<ScreenManager />
</template>
<Tabs
:tabs="[
{label: 'Sounds', key: 'sounds'},
{label: 'Settings', key: 'settings'},
{label: 'Devices', key: 'devices'},
{label: 'Map', key: 'map'},
]"
>
<template v-slot:settings>
<fieldset>
<legend>Image settings</legend>
@@ -43,10 +27,8 @@
</div>
</template>
<template v-slot:map>
<MapControl />
</template>
</Tabs>
</AppLayout>
</template>
<script setup lang="ts">

View File

@@ -1,6 +1,9 @@
<template>
<Debug />
<canvas ref="canvas" style="max-width: 100%" />
<!--<Debug />-->
<div style="overflow: hidden;height: 100vh;width: 100vw;">
<audio id="mainAudio" style="display: none"></audio>
<video id="mainVideo" loop muted autoplay playsinline style="width: 100vw;height: 100vh"></video>
</div>
</template>
<script setup lang="ts">
import {inject, ref} from 'vue';
@@ -9,10 +12,9 @@ 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('/')}`;
/*const cachedFiles = new Map<string, Blob>();
const fileToKey = (file: VirtualFile) => `${file.source}:${file.path.join('/')}`;*/
function sendScreenInfo() {
webRtc.send({
@@ -38,15 +40,17 @@ window.addEventListener('resize', () => {
webRtc.on((message) => {
console.log('webRtc message', message);
if (message.type === 'connected') {
if (message.type === 'disconnect') {
window.close();
} else if (message.type === 'connected') {
sendScreenInfo();
sendAudioInfo();
} else if (message.type === 'fileTransfered') {
debugger;
/*debugger;
const key = fileToKey(message.meta);
cachedFiles.set(key, message.blob);
cachedFiles.set(key, message.blob);*/
} else if (message.type === 'showFile') {
debugger;
/*debugger;
const key = fileToKey(message.file);
const blob = cachedFiles.get(key);
if (blob) {
@@ -59,7 +63,7 @@ webRtc.on((message) => {
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;`;
}