✨ new maps
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
>
|
>
|
||||||
<div class="a-card__label-wrapper">
|
<div class="a-card__label-wrapper">
|
||||||
<div class="a-card__label">{{ label }}</div>
|
<div class="a-card__label">{{ label }}</div>
|
||||||
|
<div class="a-card__delete" @click="emits('delete')">×</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="a-card__volume">
|
<div class="a-card__volume">
|
||||||
<span style="height: 1.25rem" @click="node.toggleMute()">
|
<span style="height: 1.25rem" @click="node.toggleMute()">
|
||||||
@@ -60,6 +61,7 @@ import DeviceVolume from './DeviceVolume.vue';
|
|||||||
import Stop from '../Icons/Stop.vue';
|
import Stop from '../Icons/Stop.vue';
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
|
'delete': [],
|
||||||
'next': [],
|
'next': [],
|
||||||
'prev': [],
|
'prev': [],
|
||||||
'togglePlay': [],
|
'togglePlay': [],
|
||||||
@@ -119,6 +121,7 @@ function onDrop(event: DragEvent) {
|
|||||||
.a-card {
|
.a-card {
|
||||||
--box-color: var(--audio-box-color-playlist, #ffb308);
|
--box-color: var(--audio-box-color-playlist, #ffb308);
|
||||||
--box-color-900: color-mix(in lab, var(--box-color), #000 80%);
|
--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-100: color-mix(in lab, var(--box-color), #fff 80%);
|
||||||
--box-color-300: color-mix(in lab, var(--box-color), #fff 40%);
|
--box-color-300: color-mix(in lab, var(--box-color), #fff 40%);
|
||||||
width: 160px;
|
width: 160px;
|
||||||
@@ -179,6 +182,7 @@ function onDrop(event: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.a-card__label-wrapper {
|
.a-card__label-wrapper {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: -1rem;
|
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 {
|
@keyframes ripples {
|
||||||
to {
|
to {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
:supports="[ 'next', 'prev', 'togglePlay' ]"
|
:supports="[ 'next', 'prev', 'togglePlay' ]"
|
||||||
@next="node.next()"
|
@next="node.next()"
|
||||||
@prev="node.prev()"
|
@prev="node.prev()"
|
||||||
label="Playlist test"
|
@delete="$emit('delete')"
|
||||||
|
label="Playlist"
|
||||||
style="--box-color: var(--color-amber)"
|
style="--box-color: var(--color-amber)"
|
||||||
>
|
>
|
||||||
<template #preview>
|
<template #preview>
|
||||||
@@ -12,20 +13,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #popover>
|
<template #popover>
|
||||||
<div>
|
<!--<div>
|
||||||
{{ node.currentTrack?.name ?? 'No track selected' }}
|
{{ node.currentTrack?.name ?? 'No track selected' }}
|
||||||
<div>
|
<div>
|
||||||
<input type="range" min="0" max="100" style="width: 100%" />00:00
|
<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 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)">
|
||||||
|
×
|
||||||
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -34,17 +47,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AudioCard from './AudioCard.vue';
|
import AudioCard from './AudioCard.vue';
|
||||||
import {PlaylistGroupNode} from '../../model/Audio/PlaylistGroupNode';
|
import {PlaylistGroupNode} from '../../model/Audio/PlaylistGroupNode';
|
||||||
|
import Play from '../Icons/Play.vue';
|
||||||
|
import {SoundNode} from '../../model/Audio/SoundNode';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: PlaylistGroupNode
|
node: PlaylistGroupNode
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'delete': [],
|
||||||
|
}>();
|
||||||
|
|
||||||
// playlist.connect(audioContext.destination);
|
// playlist.connect(audioContext.destination);
|
||||||
|
|
||||||
function playTrack(idx: number) {
|
function playTrack(idx: number) {
|
||||||
props.node.changeTrack(idx);
|
props.node.changeTrack(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeSong(song: SoundNode) {
|
||||||
|
props.node.removeSoundNode(song);
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
@@ -92,4 +115,36 @@ function playTrack(idx: number) {
|
|||||||
border: 2px solid var(--box-color-300);
|
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>
|
</style>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
:supports="['stop']"
|
:supports="['stop']"
|
||||||
label="SFX"
|
label="SFX"
|
||||||
:node="node"
|
:node="node"
|
||||||
|
@delete="emit('delete')"
|
||||||
>
|
>
|
||||||
<template #preview>
|
<template #preview>
|
||||||
<div
|
<div
|
||||||
@@ -38,6 +39,11 @@ const props = defineProps<{
|
|||||||
node: SfxGroupNode,
|
node: SfxGroupNode,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'delete': [],
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
const grouppedSounds = computed(() => {
|
const grouppedSounds = computed(() => {
|
||||||
return groupBy(props.node.sounds(), (sound) => {
|
return groupBy(props.node.sounds(), (sound) => {
|
||||||
return sound.name.replace(/(\d+)$/, '').trim();
|
return sound.name.replace(/(\d+)$/, '').trim();
|
||||||
|
|||||||
@@ -8,17 +8,34 @@
|
|||||||
<Playlist
|
<Playlist
|
||||||
v-if="node.type === 'playlist'"
|
v-if="node.type === 'playlist'"
|
||||||
:node="node"
|
:node="node"
|
||||||
|
@delete="deleteNode(node)"
|
||||||
/>
|
/>
|
||||||
<Sfx
|
<Sfx
|
||||||
v-else-if="node.type === 'sfx'"
|
v-else-if="node.type === 'sfx'"
|
||||||
:node="node"
|
:node="node"
|
||||||
|
@delete="deleteNode(node)"
|
||||||
/>
|
/>
|
||||||
<TabSource
|
<TabSource
|
||||||
v-else-if="node.type === 'tabSource'"
|
v-else-if="node.type === 'tabSource'"
|
||||||
:node="node"
|
:node="node"
|
||||||
|
@delete="deleteNode(node)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -34,45 +51,56 @@ import TabSource from './TabSource.vue';
|
|||||||
import DeviceVolume from './DeviceVolume.vue';
|
import DeviceVolume from './DeviceVolume.vue';
|
||||||
import {AudioNodeManager} from '../../model/Audio/AudioNodesManager';
|
import {AudioNodeManager} from '../../model/Audio/AudioNodesManager';
|
||||||
import {debounce} from '../../utils/debounce';
|
import {debounce} from '../../utils/debounce';
|
||||||
|
import IconPlus from '../Icons/IconPlus.vue';
|
||||||
|
import {SoundNode} from '../../model/Audio/SoundNode';
|
||||||
|
|
||||||
const fs = inject(MountedFsSymbol)!;
|
const fs = inject(MountedFsSymbol)!;
|
||||||
const audioContext = inject(AudioContextSymbol) as AudioContext;
|
const audioContext = inject(AudioContextSymbol) as AudioContext;
|
||||||
const deviceManager = inject(RemoteDeviceManagerSymbol)!;
|
const deviceManager = inject(RemoteDeviceManagerSymbol)!;
|
||||||
|
|
||||||
const nodesDefinition = JSON.parse(localStorage.getItem('soundboard') ?? '[]');
|
|
||||||
const audioNodesManager = new AudioNodeManager();
|
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}));
|
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) => {
|
watch(() => nodes, debounce((newNodes) => {
|
||||||
console.log('NEW_NODES', newNodes);
|
console.log('NEW_NODES', newNodes);
|
||||||
audioNodesManager.store(newNodes);
|
audioNodesManager.store(newNodes);
|
||||||
@@ -116,5 +144,22 @@ watch(() => deviceManager.audioDevices.length, () => {
|
|||||||
gap: 3rem 1rem;
|
gap: 3rem 1rem;
|
||||||
padding: 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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
:supports="['play', 'stop']"
|
:supports="['play', 'stop']"
|
||||||
label="Tab Audio"
|
label="Tab Audio"
|
||||||
:node="node"
|
:node="node"
|
||||||
|
@delete="emit('delete')"
|
||||||
>
|
>
|
||||||
</AudioCard>
|
</AudioCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -15,6 +16,10 @@
|
|||||||
import {TabSourceNode} from '../../model/Audio/TabSourceNode';
|
import {TabSourceNode} from '../../model/Audio/TabSourceNode';
|
||||||
import AudioCard from './AudioCard.vue';
|
import AudioCard from './AudioCard.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'delete': [],
|
||||||
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: TabSourceNode,
|
node: TabSourceNode,
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -8,28 +8,64 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</template>
|
||||||
</FilesAside>
|
</FilesAside>
|
||||||
<IconButton name="Settings">
|
|
||||||
<Cog />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="app-layout__aside bg-theme-500 contrast" style="grid-area: menu;">
|
<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>
|
||||||
|
|
||||||
<div class="app-layout__overview bg-theme-800 contrast" style="grid-area: overview;">
|
<div class="app-layout__overview bg-theme-800 contrast" style="grid-area: overview;">
|
||||||
<slot name="overview"></slot>
|
<slot name="overview"></slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-layout__main" style="grid-area: main;">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import {onMounted, ref} from 'vue';
|
||||||
import FilesAside from '../Files/FilesAside.vue';
|
import FilesAside from '../Files/FilesAside.vue';
|
||||||
import Cog from '../Icons/Cog.vue';
|
import Cog from '../Icons/Cog.vue';
|
||||||
import Directory from '../Icons/Directory.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';
|
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>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {VirtualFile} from '../../model/FileSystem/types';
|
|||||||
|
|
||||||
const isDragOver = ref(false);
|
const isDragOver = ref(false);
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
onDrop: [VirtualFile],
|
dropFile: [VirtualFile],
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -65,7 +65,8 @@ function onDrop(event: DragEvent) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
isDragOver.value = false;
|
isDragOver.value = false;
|
||||||
const drop = parseMeta(event);
|
const drop = parseMeta(event);
|
||||||
|
if (drop) {
|
||||||
console.log('drop', drop);
|
emit('dropFile', drop);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -69,7 +69,8 @@ function onFilePreview(file: VirtualFile) {
|
|||||||
|
|
||||||
watch(search, async (value) => {
|
watch(search, async (value) => {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
if (value === '') {
|
console.log('search', value, value.length);
|
||||||
|
if (value.length < 3) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (searchAbort) {
|
if (searchAbort) {
|
||||||
|
|||||||
@@ -49,16 +49,20 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
async function toggleCollapse() {
|
async function toggleCollapse() {
|
||||||
if (isCollapsed) {
|
const isOpening = isCollapsed.value;
|
||||||
|
isCollapsed.value = !isCollapsed.value;
|
||||||
|
if (isOpening) {
|
||||||
await fetchDirectoryContent();
|
await fetchDirectoryContent();
|
||||||
}
|
}
|
||||||
isCollapsed.value = !isCollapsed.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDirectoryContent() {
|
async function fetchDirectoryContent() {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
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 {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="currentPreviewUrl" class="file-preview">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -15,26 +28,42 @@ const props=defineProps<{
|
|||||||
|
|
||||||
const fs = inject(MountedFsSymbol)!;
|
const fs = inject(MountedFsSymbol)!;
|
||||||
const currentPreviewUrl = ref<string | null>(null);
|
const currentPreviewUrl = ref<string | null>(null);
|
||||||
|
const currentPrevewType = ref<string | null>(null);
|
||||||
|
let abort: AbortController | undefined = undefined;
|
||||||
|
|
||||||
|
|
||||||
watch(()=> props.file, async (file)=>{
|
watch(()=> props.file, async (file)=>{
|
||||||
|
console.log('file', file);
|
||||||
|
if (abort) {
|
||||||
|
abort.abort('User canceled');
|
||||||
|
}
|
||||||
|
abort = new AbortController();
|
||||||
if (currentPreviewUrl.value) {
|
if (currentPreviewUrl.value) {
|
||||||
URL.revokeObjectURL(currentPreviewUrl.value);
|
URL.revokeObjectURL(currentPreviewUrl.value);
|
||||||
}
|
}
|
||||||
if (!file) {
|
currentPreviewUrl.value = null;
|
||||||
currentPreviewUrl.value = null;
|
try {
|
||||||
return;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const blob = await fs.download(file);
|
|
||||||
currentPreviewUrl.value = URL.createObjectURL(blob);
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
|
.file-preview__video,
|
||||||
.file-preview__image {
|
.file-preview__image {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -46,7 +46,6 @@ function loadFilePreview(file: VirtualFile){
|
|||||||
const dragging = ref(false);
|
const dragging = ref(false);
|
||||||
|
|
||||||
function onDragStart(){
|
function onDragStart(){
|
||||||
console.log('dragstart');
|
|
||||||
dragging.value = true;
|
dragging.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +56,7 @@ function onDragLeave(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDragEnd(){
|
function onDragEnd(){
|
||||||
console.log('dragend');
|
|
||||||
dragging.value = false;
|
dragging.value = false;
|
||||||
showPreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('dragend', onDragEnd, {passive: true});
|
window.addEventListener('dragend', onDragEnd, {passive: true});
|
||||||
|
|||||||
3
src/components/Icons/IconChromecast.vue
Normal file
3
src/components/Icons/IconChromecast.vue
Normal 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>
|
||||||
5
src/components/Icons/IconMap.vue
Normal file
5
src/components/Icons/IconMap.vue
Normal 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>
|
||||||
5
src/components/Icons/IconMusic.vue
Normal file
5
src/components/Icons/IconMusic.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="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>
|
||||||
5
src/components/Icons/IconPlus.vue
Normal file
5
src/components/Icons/IconPlus.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="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>
|
||||||
5
src/components/Icons/IconTv.vue
Normal file
5
src/components/Icons/IconTv.vue
Normal 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>
|
||||||
@@ -5,109 +5,232 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'map-control--over': isDragOver
|
'map-control--over': isDragOver
|
||||||
}"
|
}"
|
||||||
@onDrop="onDrop"
|
@dropFile="onDrop"
|
||||||
|
@mousewheel="onScroll"
|
||||||
@mousedown="onMouseDown"
|
@mousedown="onMouseDown"
|
||||||
>
|
>
|
||||||
<div ref="rendererWrapper">
|
<button @click="reset()">reset</button>
|
||||||
<MapRenderer
|
<select v-model="mode">
|
||||||
ref="renderer"
|
<option value="drag">drag</option>
|
||||||
:layers="layers"
|
<option value="draw">draw</option>
|
||||||
:width="3840"
|
<option value="measure">measure</option>
|
||||||
:height="2160"
|
</select>
|
||||||
:offsetX="offsetX"
|
<select
|
||||||
:offsetY="offsetY"
|
v-if="canvasDisplay?.mapLayer"
|
||||||
:dpi="300"
|
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>
|
</div>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
<button @click="onLoad">load</button>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<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 Dropzone from '../Dropzone/Dropzone.vue';
|
||||||
import MapRenderer from './MapRenderer.vue';
|
import MapRenderer from './MapRenderer.vue';
|
||||||
import {MountedFsSymbol} from '../../model/injectionSymbols';
|
import {MountedFsSymbol, RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
|
||||||
import {MapDrawable, MapImage} from './helpers';
|
import {closerToZero, MapDrawable, MapImage} from './helpers';
|
||||||
import {VirtualFile} from '../../model/FileSystem/types';
|
import {VirtualFile} from '../../model/FileSystem/types';
|
||||||
|
import {CanvasDisplay, MapLayerDrawable} from './object';
|
||||||
|
import {Devices} from '../../model/Remote/RemoteDeviceManager';
|
||||||
|
|
||||||
const fs = inject(MountedFsSymbol)!;
|
const fs = inject(MountedFsSymbol)!;
|
||||||
const layers = ref<MapDrawable[]>([])
|
const remoteManager = inject(RemoteDeviceManagerSymbol)!;
|
||||||
const renderer = ref<MapRenderer>();
|
|
||||||
const rendererWrapper = ref<HTMLDivElement>();
|
const screens = computed(() => {
|
||||||
const scale = ref(1);
|
return remoteManager.devices
|
||||||
const offsetX = ref(0);
|
.filter(device => device.hasImageSink)
|
||||||
const offsetY = ref(0);
|
})
|
||||||
|
|
||||||
|
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) {
|
async function onDrop(file: VirtualFile) {
|
||||||
const data = await fs.download(file);
|
console.log('onDrop!!', file);
|
||||||
console.log('file', file, data);
|
onLoad(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = ref<'drag' | 'draw'>('drag');
|
const mode = ref<'drag' | 'draw' | 'measure'>('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 dragging = ref(false);
|
const dragging = ref(false);
|
||||||
|
|
||||||
function onMouseMove(event: MouseEvent) {
|
|
||||||
const mouseScale = rendererWrapper.value!.clientWidth / 3840;
|
|
||||||
console.log(renderer.value)
|
function onScroll(event: WheelEvent) {
|
||||||
if (dragging.value) {
|
event.preventDefault();
|
||||||
offsetX.value += event.movementX / mouseScale;
|
// console.log('scroll', event.deltaY, event.offsetX, event.offsetY);
|
||||||
offsetY.value += event.movementY / mouseScale;
|
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) {
|
function showPointer(show: boolean) {
|
||||||
console.log('mouse up', event);
|
pointerVisible.value = show;
|
||||||
dragging.value = false;
|
if (canvasDisplay && !show) {
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
canvasDisplay.distanceMeasurement.pointer = undefined;
|
||||||
window.removeEventListener('mouseup', onMouseUp);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseDown(event: MouseEvent) {
|
function onMouseDown(event: MouseEvent) {
|
||||||
console.log('mouse down', event);
|
window.getSelection()?.removeAllRanges();
|
||||||
dragging.value = true;
|
if (mode.value === 'drag') {
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
dragging.value = true;
|
||||||
|
}
|
||||||
|
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);
|
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(() => {
|
onMounted(() => {
|
||||||
onLoad();
|
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>
|
</script>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
.map-control {
|
.map-control {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 1px solid black;
|
border: 1px solid white;
|
||||||
background: white;
|
background: white;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<canvas
|
<canvas
|
||||||
ref="map" style="max-width: 100%;"
|
ref="map" style="max-width: 25%;"
|
||||||
:width="props.width"
|
:width="props.width"
|
||||||
:height="props.height"
|
:height="props.height"
|
||||||
></canvas>
|
></canvas>
|
||||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
|||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
dpi: number,
|
dpi: number,
|
||||||
|
scale: number,
|
||||||
layers: MapDrawable[]
|
layers: MapDrawable[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ const mapContext = computed(() => {
|
|||||||
virtualWidth: props.width,
|
virtualWidth: props.width,
|
||||||
offsetX: props.offsetX,
|
offsetX: props.offsetX,
|
||||||
offsetY: props.offsetY,
|
offsetY: props.offsetY,
|
||||||
scale: props.dpi,
|
scale: props.scale,
|
||||||
dpi: props.dpi,
|
dpi: props.dpi,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -40,6 +41,9 @@ function redraw() {
|
|||||||
ctx: map.value!.getContext('2d')!,
|
ctx: map.value!.getContext('2d')!,
|
||||||
...mapContext.value,
|
...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) {
|
for (const layer of props.layers) {
|
||||||
layer.draw(ctx);
|
layer.draw(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
if (src instanceof File) {
|
||||||
console.log('Creating object URL');
|
console.log('Creating object URL');
|
||||||
src = URL.createObjectURL(src);
|
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
|
* number of 1 inch squares in the map
|
||||||
*/
|
*/
|
||||||
@@ -61,7 +65,7 @@ export class MapImage implements MapDrawable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
draw(mapContext: MapContext) {
|
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 naturalWidth = this.file.width;
|
||||||
const naturalHeight = this.file.height;
|
const naturalHeight = this.file.height;
|
||||||
const w = this.gameDimensions.w!;
|
const w = this.gameDimensions.w!;
|
||||||
@@ -77,7 +81,7 @@ export class MapImage implements MapDrawable {
|
|||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
this.file,
|
this.file,
|
||||||
0, 0, naturalWidth, naturalHeight,
|
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
389
src/components/Map/object.ts
Normal file
389
src/components/Map/object.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,16 +3,38 @@
|
|||||||
<div>
|
<div>
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input type="text" placeholder="Name" v-model="name" />
|
<input type="text" placeholder="Name" v-model="name" />
|
||||||
<button @click="showAvailableDevices">Available devices</button>
|
<button @click="showAvailableDevices">Create secondary screen</button>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div>
|
<table>
|
||||||
<div v-for="device in manager.devices">
|
<thead>
|
||||||
<span @click="manager.removeDevice(device)">×</span> {{ device.name }}
|
<tr>
|
||||||
<button v-if="!device.isConnected" @click="device.connect()">connect</button>
|
<th>Device</th>
|
||||||
<button v-else @click="device.disconnect()">disconnect</button>
|
<th>Inches</th>
|
||||||
</div>
|
<th colspan="2">Actions</th>
|
||||||
</div>
|
<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">
|
<dialog ref="localWindowDialog">
|
||||||
<div v-for="device in availableDevices">
|
<div v-for="device in availableDevices">
|
||||||
<div class="remote-devices__available-device" @click="addAvailableDevice(device)">
|
<div class="remote-devices__available-device" @click="addAvailableDevice(device)">
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
<canvas
|
<canvas
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
style="max-width: 100%;"
|
style="max-width: 100%;"
|
||||||
|
:width="imageSink?.resolution?.width / 4"
|
||||||
|
:height="imageSink?.resolution?.height / 4"
|
||||||
:style="{
|
:style="{
|
||||||
// transform: `scale(${scale / 100}) translate(${(x)}%, ${(y)}%)`,
|
// transform: `scale(${scale / 100}) translate(${(x)}%, ${(y)}%)`,
|
||||||
}"
|
}"
|
||||||
@@ -34,6 +36,7 @@ import {MountedFsSymbol} from '../../model/injectionSymbols';
|
|||||||
import {VirtualFile} from '../../model/FileSystem/types';
|
import {VirtualFile} from '../../model/FileSystem/types';
|
||||||
import {useLoading} from '../../helpers/withLoading';
|
import {useLoading} from '../../helpers/withLoading';
|
||||||
import {useMouseDragging} from '../../helpers/mouseDragging';
|
import {useMouseDragging} from '../../helpers/mouseDragging';
|
||||||
|
import {MapLayerDrawable} from '../Map/object';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
screen: RemoteDevice;
|
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) {
|
async function drawImage(blob: Blob) {
|
||||||
if (blob.type.startsWith('image/')) {
|
if (blob.type.startsWith('image/')) {
|
||||||
@@ -91,14 +100,20 @@ async function drawImage(blob: Blob) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFile(meta: VirtualFile, file: Blob) {
|
async function loadFile(meta: VirtualFile, file: File) {
|
||||||
await withLoading(async () => {
|
if (imageSink.value?.display) {
|
||||||
await Promise.all([
|
const map = new MapLayerDrawable(file, meta.map?.width);
|
||||||
imageSink.value?.transferFile(meta, file),
|
await map.preloadFile();
|
||||||
drawImage(file),
|
imageSink.value.display.mapLayer = map
|
||||||
])
|
console.log(imageSink.value?.display);
|
||||||
await imageSink.value?.show(meta);
|
}
|
||||||
})
|
// await withLoading(async () => {
|
||||||
|
// await Promise.all([
|
||||||
|
// imageSink.value?.transferFile(meta, file),
|
||||||
|
// drawImage(file),
|
||||||
|
// ])
|
||||||
|
// await imageSink.value?.show(meta);
|
||||||
|
// })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDrop(event: DragEvent) {
|
async function onDrop(event: DragEvent) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="screens">
|
<div class="screens">
|
||||||
<RemoteScreen :screen="screen" v-for="screen of screens" />
|
<RemoteScreen :screen="screen" v-for="screen of screens" @click="revealScreen(screen)" />
|
||||||
<div @click="remoteManager.connectChromeCast()">chromecast</div>
|
<div>
|
||||||
|
<IconButton name="Chromecast" @click="remoteManager.connectChromeCast()">
|
||||||
|
<IconChromecast />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -9,6 +13,9 @@ import {computed, inject} from 'vue';
|
|||||||
import {RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
|
import {RemoteDeviceManagerSymbol} from '../../model/injectionSymbols';
|
||||||
import RemoteScreen from './RemoteScreen.vue';
|
import RemoteScreen from './RemoteScreen.vue';
|
||||||
import {ImageSink} from '../../model/Remote/types';
|
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 remoteManager = inject(RemoteDeviceManagerSymbol)!;
|
||||||
|
|
||||||
const screens = computed(() => {
|
const screens = computed(() => {
|
||||||
@@ -16,6 +23,10 @@ const screens = computed(() => {
|
|||||||
.filter(device => device.hasImageSink)
|
.filter(device => device.hasImageSink)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function revealScreen(screen: Devices) {
|
||||||
|
window.dispatchEvent(new CustomEvent('reveal-screen', {detail: screen}));
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.screens {
|
.screens {
|
||||||
|
|||||||
68
src/helpers/persistentKeyValue.ts
Normal file
68
src/helpers/persistentKeyValue.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export interface PlaylistGroupNodeOptions extends SoundGroupNodeOptions {
|
|||||||
export class PlaylistGroupNode extends SoundGroupNode<PlaylistGroupNodeOptions> {
|
export class PlaylistGroupNode extends SoundGroupNode<PlaylistGroupNodeOptions> {
|
||||||
|
|
||||||
public readonly type = 'playlist';
|
public readonly type = 'playlist';
|
||||||
|
private intervalId?: number;
|
||||||
|
|
||||||
changeTrack(track: number) {
|
changeTrack(track: number) {
|
||||||
this.currentTrack.stop();
|
this.currentTrack.stop();
|
||||||
@@ -29,13 +30,31 @@ export class PlaylistGroupNode extends SoundGroupNode<PlaylistGroupNodeOptions>
|
|||||||
return this.soundNodes.some(sound => sound.isPlaying);
|
return this.soundNodes.some(sound => sound.isPlaying);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stateChecker() {
|
||||||
|
if (this.currentTrack?.isPlaying) {
|
||||||
|
this.options.currentTrackTime = this.currentTrack.currentTime;
|
||||||
|
} else {
|
||||||
|
this.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
play() {
|
play() {
|
||||||
this.currentTrack?.play();
|
this.currentTrack?.play();
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
// @ts-ignore
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.stateChecker();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.currentTrack?.stop();
|
||||||
|
clearInterval(this.intervalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePlay() {
|
togglePlay() {
|
||||||
if (this.isPlaying) {
|
if (this.isPlaying) {
|
||||||
this.currentTrack?.stop();
|
this.stop();
|
||||||
} else {
|
} else {
|
||||||
this.play();
|
this.play();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
sounds() {
|
||||||
return this.soundNodes;
|
return this.soundNodes;
|
||||||
}
|
}
|
||||||
@@ -91,7 +98,6 @@ export abstract class SoundGroupNode<T extends SoundGroupNodeOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get volume() {
|
get volume() {
|
||||||
|
|
||||||
return this.options.volume;
|
return this.options.volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export abstract class SoundNode {
|
|||||||
public readonly numberOfOutputs = 1;
|
public readonly numberOfOutputs = 1;
|
||||||
protected activeSources: AudioBufferSourceNode[] = [];
|
protected activeSources: AudioBufferSourceNode[] = [];
|
||||||
protected audioContext: AudioContext;
|
protected audioContext: AudioContext;
|
||||||
|
public currentTime: number = 0;
|
||||||
protected readonly fs: MountedFs;
|
protected readonly fs: MountedFs;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CancellationToken } from "typescript";
|
||||||
import { extensionMimeType } from "../FileMeta/ExtensionMimeType";
|
import { extensionMimeType } from "../FileMeta/ExtensionMimeType";
|
||||||
import { CachePolicy, FsCache } from "./FsCache";
|
import { CachePolicy, FsCache } from "./FsCache";
|
||||||
import { VirtualDirectory, VirtualFile, FileSystemSource, ListDirOptions } from "./types";
|
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);
|
const url = new URL(this.options.domain);
|
||||||
url.pathname = `/api/files/${type}`;
|
url.pathname = `/api/files/${type}`;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('share', this.options.share);
|
params.set('share', this.options.share);
|
||||||
params.set('key', this.options.key);
|
params.set('key', this.options.key);
|
||||||
params.set('path', `/${path.join('/')}`);
|
params.set('path', `/${path.join('/')}`);
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
url.search = params.toString();
|
url.search = params.toString();
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -80,15 +84,46 @@ export class FileStashFs implements FileSystemSource<FileStashFsConfig> {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown type ${result.type}`);
|
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> {
|
async download(file: VirtualFile, {abortSignal}: {abortSignal?: AbortSignal} = {}): Promise<File> {
|
||||||
const response = await fetch(this.createUrl('cat', file.path));
|
const response = await fetch(this.createUrl('cat', file.path), {signal: abortSignal});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to download file ${file.path.join('/')}`);
|
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() {
|
getConfig() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const CACHE = Symbol('Cache');
|
|||||||
export interface Fs {
|
export interface Fs {
|
||||||
get root(): VirtualDirectory[];
|
get root(): VirtualDirectory[];
|
||||||
download(file: VirtualFile): Promise<File>;
|
download(file: VirtualFile): Promise<File>;
|
||||||
|
downloadThumbnail(file: VirtualFile): Promise<File>;
|
||||||
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
|
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,14 @@ export class MountedFs implements Fs {
|
|||||||
return fs.download(file);
|
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) {
|
async listDir(dir: VirtualDirectory, options?: ListDirOptions) {
|
||||||
|
|
||||||
const fs = this.filesystems.get(dir.source);
|
const fs = this.filesystems.get(dir.source);
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ export class NginxFS implements FileSystemSource<NginxFsConfig> {
|
|||||||
return new File([await response.blob()], file.name, { type: file.mimeType });
|
return new File([await response.blob()], file.name, { type: file.mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadThumbnail(file: VirtualFile): Promise<File> {
|
||||||
|
return this.download(file);
|
||||||
|
}
|
||||||
|
|
||||||
getConfig() {
|
getConfig() {
|
||||||
return {
|
return {
|
||||||
...this.options,
|
...this.options,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface FileSystemSource<TOptions> {
|
|||||||
root: VirtualDirectory;
|
root: VirtualDirectory;
|
||||||
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
|
listDir(dir: VirtualDirectory, options?: ListDirOptions): Promise<(VirtualDirectory|VirtualFile)[]>;
|
||||||
download(file: VirtualFile): Promise<File>;
|
download(file: VirtualFile): Promise<File>;
|
||||||
|
downloadThumbnail(file: VirtualFile, options?: {abortSignal?: AbortSignal}): Promise<File>;
|
||||||
getConfig(): TOptions & { type: string };
|
getConfig(): TOptions & { type: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
private onMessage = (event: MessageEvent) => {
|
||||||
@@ -86,6 +97,10 @@ export class WebRtcChannelCaster extends MessageEmitter implements JsonChannelCa
|
|||||||
this.dataChannel.send(JSON.stringify(data));
|
this.dataChannel.send(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectVideoStream(stream: MediaStream) {
|
||||||
|
this.peer.addTrack(stream.getVideoTracks()[0], stream);
|
||||||
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
super.disconnect();
|
super.disconnect();
|
||||||
}
|
}
|
||||||
@@ -109,13 +124,28 @@ export class WebRtcChannelSummon extends MessageEmitter implements JsonChannelSu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.peer.ontrack = (event) => {
|
this.peer.ontrack = (event) => {
|
||||||
console.log('ontrack', event);
|
event.streams.forEach((stream) => {
|
||||||
const audioElement = document.createElement('audio');
|
stream.getTracks().forEach((track) => {
|
||||||
audioElement.srcObject = event.streams[0]; // Attach the received stream
|
if (event.track.kind === 'audio') {
|
||||||
audioElement.play();
|
console.log('audio track', track);
|
||||||
}
|
const audioElement = document.createElement('audio');
|
||||||
|
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) => {
|
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 = event.channel;
|
||||||
this.dataChannel.addEventListener('message', this.onRtcMessage);
|
this.dataChannel.addEventListener('message', this.onRtcMessage);
|
||||||
this.emit({ type: 'connected' });
|
this.emit({ type: 'connected' });
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export type ShowFileMessage = {
|
|||||||
file: VirtualFile;
|
file: VirtualFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DisconnectMessage = {
|
||||||
|
type: 'disconnect';
|
||||||
|
}
|
||||||
|
|
||||||
export type DisconnectedMessage = {
|
export type DisconnectedMessage = {
|
||||||
type: 'disconnected';
|
type: 'disconnected';
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,7 @@ export type Message = PingMessage |
|
|||||||
FileTransferedMessage |
|
FileTransferedMessage |
|
||||||
FileTransferProgressMessage |
|
FileTransferProgressMessage |
|
||||||
ShowFileMessage |
|
ShowFileMessage |
|
||||||
|
DisconnectMessage |
|
||||||
DisconnectedMessage |
|
DisconnectedMessage |
|
||||||
ConnectedMessage |
|
ConnectedMessage |
|
||||||
HelloMessage |
|
HelloMessage |
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { CanvasDisplay } from "../../components/Map/object";
|
||||||
|
import { createPersistentKeyValStore, createPersistentValue, PersistentKeyVal } from "../../helpers/persistentKeyValue";
|
||||||
import { VirtualFile } from "../FileSystem/types";
|
import { VirtualFile } from "../FileSystem/types";
|
||||||
import { PostMessageChannel } from "./Channels/PostMessage";
|
import { PostMessageChannel } from "./Channels/PostMessage";
|
||||||
import { WebRtcChannelCaster } from "./Channels/WebRTCChannel";
|
import { WebRtcChannelCaster } from "./Channels/WebRTCChannel";
|
||||||
@@ -32,7 +34,7 @@ export class ChromecastDevice {
|
|||||||
const uuid = await channel.initialize();
|
const uuid = await channel.initialize();
|
||||||
const win = window.open(`/chromecast/?ws=${uuid}`, '', 'width=200,height=200')!;
|
const win = window.open(`/chromecast/?ws=${uuid}`, '', 'width=200,height=200')!;
|
||||||
// const channel = new PostMessageChannel(win);
|
// const channel = new PostMessageChannel(win);
|
||||||
channel.on((msg) => {
|
channel.on(async (msg) => {
|
||||||
console.log('CHROMECAST MESSAGE', msg)
|
console.log('CHROMECAST MESSAGE', msg)
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (msg.type === 'webRtcSignalingNewClient') {
|
if (msg.type === 'webRtcSignalingNewClient') {
|
||||||
@@ -51,25 +53,41 @@ export class ChromecastDevice {
|
|||||||
|
|
||||||
win.onbeforeunload = () => {
|
win.onbeforeunload = () => {
|
||||||
console.log('closed');
|
console.log('closed');
|
||||||
channel.disconnect();
|
//channel.disconnect();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.webRtcChannel?.send({
|
||||||
|
type: 'disconnect'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChromecastImageSink implements ImageSink {
|
class ChromecastImageSink implements ImageSink {
|
||||||
resolution: { width: number; height: number; } = {width: NaN, height: NaN};
|
resolution: { width: number; height: number; } = {width: NaN, height: NaN};
|
||||||
|
public display?: CanvasDisplay;
|
||||||
|
private _diameter?: PersistentKeyVal<number>;
|
||||||
|
|
||||||
constructor(private device: ChromecastDevice) {
|
constructor(private device: ChromecastDevice) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.device.webRtcChannel?.on((msg)=> {
|
this.device.webRtcChannel?.on(async (msg)=> {
|
||||||
if (msg.type === 'resolution') {
|
if (msg.type === 'resolution') {
|
||||||
this.resolution = msg;
|
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);
|
console.log(msg);
|
||||||
})
|
})
|
||||||
@@ -94,4 +112,17 @@ class ChromecastImageSink implements ImageSink {
|
|||||||
x, y, scale
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { CanvasDisplay } from "../../components/Map/object";
|
||||||
|
import { createPersistentValue, PersistentKeyVal } from "../../helpers/persistentKeyValue";
|
||||||
import { VirtualFile } from "../FileSystem/types";
|
import { VirtualFile } from "../FileSystem/types";
|
||||||
import { PostMessageChannel } from "./Channels/PostMessage";
|
import { PostMessageChannel } from "./Channels/PostMessage";
|
||||||
import { JsonChannelCaster } from "./Channels/type";
|
import { JsonChannelCaster } from "./Channels/type";
|
||||||
@@ -99,20 +101,42 @@ export class FullscreenWindowDevice implements RemoteDevice {
|
|||||||
class FullscreenWindowImageSink implements ImageSink {
|
class FullscreenWindowImageSink implements ImageSink {
|
||||||
|
|
||||||
public resolution = {width: NaN, height: NaN};
|
public resolution = {width: NaN, height: NaN};
|
||||||
|
public display?: CanvasDisplay;
|
||||||
|
private _diameter?: PersistentKeyVal<number>;
|
||||||
|
|
||||||
constructor(private device: FullscreenWindowDevice) {}
|
constructor(private device: FullscreenWindowDevice) {}
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.device.webRtcChannel?.on((msg) => {
|
this.device.webRtcChannel?.on(async (msg) => {
|
||||||
if(msg.type === 'resolution') {
|
if(msg.type === 'resolution') {
|
||||||
this.resolution = {
|
this.resolution = {
|
||||||
width: msg.width,
|
width: msg.width,
|
||||||
height: msg.height
|
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 {
|
get name(): string {
|
||||||
return this.device.name;
|
return this.device.name;
|
||||||
}
|
}
|
||||||
@@ -123,7 +147,6 @@ class FullscreenWindowImageSink implements ImageSink {
|
|||||||
|
|
||||||
async show(file: VirtualFile) {
|
async show(file: VirtualFile) {
|
||||||
await this.device.webRtcChannel?.send({type: 'showFile', file});
|
await this.device.webRtcChannel?.send({type: 'showFile', file});
|
||||||
console.log('show', file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async transform({x, y, scale}: {x: number, y: number, scale: number}) {
|
async transform({x, y, scale}: {x: number, y: number, scale: number}) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CanvasDisplay } from "../../components/Map/object";
|
||||||
import { VirtualFile } from "../FileSystem/types";
|
import { VirtualFile } from "../FileSystem/types";
|
||||||
|
|
||||||
export interface RemoteDeviceOptions {
|
export interface RemoteDeviceOptions {
|
||||||
@@ -24,6 +25,8 @@ export interface RemoteSink {
|
|||||||
|
|
||||||
export interface ImageSink extends RemoteSink {
|
export interface ImageSink extends RemoteSink {
|
||||||
resolution: { width: number, height: number };
|
resolution: { width: number, height: number };
|
||||||
|
display?: CanvasDisplay;
|
||||||
|
diameter?: number;
|
||||||
show(file: VirtualFile): void;
|
show(file: VirtualFile): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,34 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<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>
|
<template v-slot:overview>
|
||||||
<ScreenManager />
|
<ScreenManager />
|
||||||
</template>
|
</template>
|
||||||
<Tabs
|
<template v-slot:settings>
|
||||||
:tabs="[
|
<fieldset>
|
||||||
{label: 'Sounds', key: 'sounds'},
|
<legend>Image settings</legend>
|
||||||
{label: 'Settings', key: 'settings'},
|
<label>Width</label>
|
||||||
{label: 'Devices', key: 'devices'},
|
<input type="number" v-model="imageWidth" />
|
||||||
{label: 'Map', key: 'map'},
|
<label>Screen Diameter</label>
|
||||||
]"
|
<input type="number" v-model="screenDiameterInch" />
|
||||||
>
|
<label>Flip <input type="checkbox" v-model="flipped"></label>
|
||||||
<template v-slot:settings>
|
<label>Position (X/Y)</label>
|
||||||
<fieldset>
|
<input type="range" min="0" max="100" v-model="positionX" />
|
||||||
<legend>Image settings</legend>
|
<input type="range" min="0" max="100" v-model="positionY" />
|
||||||
<label>Width</label>
|
<button @click="positionX = 50;positionY = 50">center</button>
|
||||||
<input type="number" v-model="imageWidth" />
|
</fieldset>
|
||||||
<label>Screen Diameter</label>
|
</template>
|
||||||
<input type="number" v-model="screenDiameterInch" />
|
<template v-slot:devices>
|
||||||
<label>Flip <input type="checkbox" v-model="flipped"></label>
|
<RemoteDevices />
|
||||||
<label>Position (X/Y)</label>
|
</template>
|
||||||
<input type="range" min="0" max="100" v-model="positionX" />
|
<template v-slot:sounds>
|
||||||
<input type="range" min="0" max="100" v-model="positionY" />
|
<div style="padding: 1rem">
|
||||||
<button @click="positionX = 50;positionY = 50">center</button>
|
<Soundboard />
|
||||||
</fieldset>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:devices>
|
<template v-slot:map>
|
||||||
<RemoteDevices />
|
<MapControl />
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:sounds>
|
|
||||||
<div style="padding: 1rem">
|
|
||||||
<Soundboard />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-slot:map>
|
|
||||||
|
|
||||||
<MapControl />
|
|
||||||
</template>
|
|
||||||
</Tabs>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<Debug />
|
<!--<Debug />-->
|
||||||
<canvas ref="canvas" style="max-width: 100%" />
|
<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>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {inject, ref} from 'vue';
|
import {inject, ref} from 'vue';
|
||||||
@@ -9,10 +12,9 @@ import {WebRtcChannelSymbol} from '../../model/injectionSymbols';
|
|||||||
import {VirtualFile} from '../../model/FileSystem/types';
|
import {VirtualFile} from '../../model/FileSystem/types';
|
||||||
|
|
||||||
const webRtc = inject(WebRtcChannelSymbol)!;
|
const webRtc = inject(WebRtcChannelSymbol)!;
|
||||||
const canvas = ref<HTMLCanvasElement>();
|
|
||||||
|
|
||||||
const cachedFiles = new Map<string, Blob>();
|
/*const cachedFiles = new Map<string, Blob>();
|
||||||
const fileToKey = (file: VirtualFile) => `${file.source}:${file.path.join('/')}`;
|
const fileToKey = (file: VirtualFile) => `${file.source}:${file.path.join('/')}`;*/
|
||||||
|
|
||||||
function sendScreenInfo() {
|
function sendScreenInfo() {
|
||||||
webRtc.send({
|
webRtc.send({
|
||||||
@@ -38,15 +40,17 @@ window.addEventListener('resize', () => {
|
|||||||
|
|
||||||
webRtc.on((message) => {
|
webRtc.on((message) => {
|
||||||
console.log('webRtc message', message);
|
console.log('webRtc message', message);
|
||||||
if (message.type === 'connected') {
|
if (message.type === 'disconnect') {
|
||||||
|
window.close();
|
||||||
|
} else if (message.type === 'connected') {
|
||||||
sendScreenInfo();
|
sendScreenInfo();
|
||||||
sendAudioInfo();
|
sendAudioInfo();
|
||||||
} else if (message.type === 'fileTransfered') {
|
} else if (message.type === 'fileTransfered') {
|
||||||
debugger;
|
/*debugger;
|
||||||
const key = fileToKey(message.meta);
|
const key = fileToKey(message.meta);
|
||||||
cachedFiles.set(key, message.blob);
|
cachedFiles.set(key, message.blob);*/
|
||||||
} else if (message.type === 'showFile') {
|
} else if (message.type === 'showFile') {
|
||||||
debugger;
|
/*debugger;
|
||||||
const key = fileToKey(message.file);
|
const key = fileToKey(message.file);
|
||||||
const blob = cachedFiles.get(key);
|
const blob = cachedFiles.get(key);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
@@ -59,7 +63,7 @@ webRtc.on((message) => {
|
|||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
}
|
}
|
||||||
img.src = url;
|
img.src = url;
|
||||||
}
|
}*/
|
||||||
} else if (message.type === 'transform') {
|
} else if (message.type === 'transform') {
|
||||||
// canvas.value.style = `max-width: 100%;transform: scale(${message.scale}) translate(${message.x}%, ${message.y}%);transition: transform 100ms;`;
|
// canvas.value.style = `max-width: 100%;transform: scale(${message.scale}) translate(${message.x}%, ${message.y}%);transition: transform 100ms;`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user