🎉
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
public/maps/*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
README.md
Normal file
5
README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
version: "3.7"
|
||||
services:
|
||||
gate:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./public/maps:/usr/share/nginx/html/maps
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
map/index.html
Normal file
13
map/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/map.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
nginx/default.conf
Normal file
21
nginx/default.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location /maps {
|
||||
autoindex on;
|
||||
autoindex_format json;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
}
|
||||
3144
package-lock.json
generated
Normal file
3144
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "map-caster",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/chromecast-caf-receiver": "^6.0.16",
|
||||
"@types/chromecast-caf-sender": "^1.0.10",
|
||||
"vite-plugin-list-directory-contents": "^1.4.5",
|
||||
"vue": "^3.4.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.3",
|
||||
"vue-tsc": "^2.0.21"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
46
src/components/FileManager/FileManager.vue
Normal file
46
src/components/FileManager/FileManager.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<ul class="file-manager-dir">
|
||||
<FileManagerDir :dir="fileManager.root" @openFile="$emit('openFile', $event)"></FileManagerDir>
|
||||
</ul>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {inject} from 'vue';
|
||||
import {FileManagerSymbol} from '../../model/injectionSymbols';
|
||||
import FileManagerDir from './FileManagerDir.vue';
|
||||
import type {FileWithMeta} from '../../model/FileManager';
|
||||
|
||||
const fileManager = inject(FileManagerSymbol)!;
|
||||
const emit = defineEmits<{
|
||||
'openFile': [value: FileWithMeta]
|
||||
}>();
|
||||
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.file-manager {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.file-manager-dir {
|
||||
list-style-type: none;
|
||||
padding-left: 1rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.file-manager-dir__label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-manager-dir__label::before {
|
||||
content: '📁';
|
||||
margin-right: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.file-manager-dir__label--open::before {
|
||||
content: '📂';
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
62
src/components/FileManager/FileManagerDir.vue
Normal file
62
src/components/FileManager/FileManagerDir.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<li>
|
||||
<span
|
||||
@click="toggleCollapse"
|
||||
class="file-manager-dir__label"
|
||||
:class="{ 'file-manager-dir__label--open': !isCollapsed }"
|
||||
>{{ dir.name }}/ <template v-if="isLoading">(loading)</template></span>
|
||||
<ul v-if="dir.files" v-show="!isCollapsed" class="file-manager-dir">
|
||||
<template
|
||||
v-for="file in dir.files"
|
||||
:key="file.name"
|
||||
>
|
||||
<FileManagerDir
|
||||
v-if="file.type === 'directory'"
|
||||
:dir="file"
|
||||
@openFile="$emit('openFile', $event)"
|
||||
/>
|
||||
<FileManagerFile v-else :file="file" @openFile="$emit('openFile', $event)" />
|
||||
</template>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {inject, ref} from 'vue';
|
||||
import {DirectoryWithFiles, FileWithMeta} from '../../model/FileManager';
|
||||
import FileManagerFile from './FileManagerFile.vue';
|
||||
import FileManagerDir from './FileManagerDir.vue';
|
||||
import {FileManagerSymbol} from '../../model/injectionSymbols';
|
||||
|
||||
const fileManager = inject(FileManagerSymbol)!;
|
||||
const isLoading = ref(false);
|
||||
const isCollapsed = ref(true);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'openFile': [value: FileWithMeta]
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
dir: DirectoryWithFiles;
|
||||
}>();
|
||||
|
||||
async function toggleCollapse() {
|
||||
if (isCollapsed) {
|
||||
await fetchDirectoryContent();
|
||||
|
||||
}
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
}
|
||||
|
||||
async function fetchDirectoryContent() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
await fileManager?.fetchDirectoryContent(props.dir);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<style>
|
||||
|
||||
</style>
|
||||
53
src/components/FileManager/FileManagerFile.vue
Normal file
53
src/components/FileManager/FileManagerFile.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<li class="file-manager-file">
|
||||
<span @click="$emit('openFile', file)">
|
||||
{{ file.name }}
|
||||
<span
|
||||
class="file-manager-file__size"
|
||||
:class="{ 'file-manager-file__size--unknown': !file.width || !file.height }"
|
||||
>
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {FileWithMeta} from '../../model/FileManager';
|
||||
import {computed} from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
file: FileWithMeta;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'openFile': [value: FileWithMeta]
|
||||
}>();
|
||||
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.file.width && props.file.height) {
|
||||
return props.file.width + 'x' + props.file.height;
|
||||
} else {
|
||||
return '??x??';
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.file-manager-file {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-manager-file__size {
|
||||
display: inline;
|
||||
padding: 0em 0.25em;
|
||||
border-radius: 4px;
|
||||
background-color: #088a1e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-manager-file__size--unknown {
|
||||
background-color: #801e1e;
|
||||
}
|
||||
|
||||
</style>
|
||||
21
src/main.ts
Normal file
21
src/main.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import ControlApp from './modules/controls/ControlApp.vue'
|
||||
import { FileManager } from './model/FileManager'
|
||||
import { FileManagerSymbol, SenderSymbol } from './model/injectionSymbols'
|
||||
import { BroadcastSender } from './model/BroadcastListener';
|
||||
import { MultiSender } from './model/MultiListener'
|
||||
import { ChromecastSender } from './model/ChromecastListener'
|
||||
|
||||
|
||||
const app = createApp(ControlApp);
|
||||
|
||||
app
|
||||
.provide(SenderSymbol, new MultiSender([
|
||||
new BroadcastSender(),
|
||||
new ChromecastSender(),
|
||||
]))
|
||||
.provide(FileManagerSymbol, new FileManager())
|
||||
.mount('#app')
|
||||
|
||||
|
||||
16
src/map.ts
Normal file
16
src/map.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import './style.css';
|
||||
import MapApp from './modules/map/MapApp.vue';
|
||||
import { ReceiverSymbol } from './model/injectionSymbols';
|
||||
import { BroadcastReceiver } from './model/BroadcastListener';
|
||||
import { MultiReceiver } from './model/MultiListener';
|
||||
import { ChromecastReceiver } from './model/ChromecastListener';
|
||||
|
||||
const app = createApp(MapApp)
|
||||
// @ts-ignore
|
||||
app.provide(ReceiverSymbol, new MultiReceiver([
|
||||
new BroadcastReceiver(),
|
||||
new ChromecastReceiver(),
|
||||
]));
|
||||
|
||||
app.mount('#app')
|
||||
30
src/model/BroadcastListener.ts
Normal file
30
src/model/BroadcastListener.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Message, Sender } from "./CommunicationChannel";
|
||||
|
||||
const BROADCAST_CHANNEL = 'map-screen';
|
||||
|
||||
export class BroadcastSender implements Sender {
|
||||
name = 'broadcast';
|
||||
private channel: BroadcastChannel;
|
||||
|
||||
constructor() {
|
||||
this.channel = new BroadcastChannel(BROADCAST_CHANNEL);
|
||||
}
|
||||
|
||||
sendMessage(message: any): void {
|
||||
this.channel.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class BroadcastReceiver extends EventTarget implements BroadcastReceiver {
|
||||
private channel: BroadcastChannel;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.channel = new BroadcastChannel(BROADCAST_CHANNEL);
|
||||
this.channel.onmessage = (event) => {
|
||||
const msg = event.data as Message;
|
||||
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg }));
|
||||
this.dispatchEvent(new CustomEvent('all', { detail: msg }));
|
||||
};
|
||||
}
|
||||
}
|
||||
241
src/model/ChromecastListener.ts
Normal file
241
src/model/ChromecastListener.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Message, Sender, Receiver, SendMesssageOptions } from "./CommunicationChannel";
|
||||
let logCounter = 0;
|
||||
|
||||
const log = (...params: any) => {
|
||||
if (location.search.includes('debug')) {
|
||||
const pre = document.createElement('pre');
|
||||
document.body.prepend(pre);
|
||||
pre.textContent = [logCounter++, ...params].join(' ');
|
||||
pre.style.zIndex = '10000';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// fix resolution
|
||||
function fixResolution() {
|
||||
const overridePixelDensity = undefined;
|
||||
const pixelDensity = overridePixelDensity || window.devicePixelRatio;
|
||||
const width = window.innerWidth * pixelDensity;
|
||||
const height = window.innerHeight * pixelDensity;
|
||||
|
||||
log('pixel density', window.devicePixelRatio);
|
||||
log('fixResolution', width, height);
|
||||
document.body.style.width = `${width}px`;
|
||||
document.body.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
function loadScript(src: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load script'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
const CHANNEL = 'urn:x-cast:eu.fisoft.battlemaps.custom';
|
||||
|
||||
export class ChromecastSender implements Sender {
|
||||
name = 'chromecast';
|
||||
private context?: cast.framework.CastContext;
|
||||
private webrtcPeer?: RTCPeerConnection;
|
||||
private dataChannel?: RTCDataChannel;
|
||||
private sessionStarted = false;
|
||||
|
||||
constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
async initializeWebRTC() {
|
||||
this.webrtcPeer = new RTCPeerConnection();
|
||||
window
|
||||
this.dataChannel = this.webrtcPeer.createDataChannel('data')
|
||||
this.dataChannel.addEventListener('message', (event) => {
|
||||
console.log('data channel message', event.data);
|
||||
});
|
||||
window['webrtcPeer'] = this.webrtcPeer;
|
||||
window['dataChannel'] = this.dataChannel;
|
||||
const offer = await this.webrtcPeer.createOffer();
|
||||
await this.webrtcPeer.setLocalDescription(offer);
|
||||
// @ts-ignore
|
||||
this.sendMessage({ type: 'RTC_OFFER', offer });
|
||||
this.webrtcPeer.onicecandidate = (event: any) => {
|
||||
if (event.candidate) {
|
||||
this.sendMessage({ type: 'RTC_ICE', candidate: event.candidate });
|
||||
// @ts-ignore
|
||||
console.log('sending ice candidate', event.candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
window['__onGCastApiAvailable'] = (isAvailable) => {
|
||||
console.log('__onGCastApiAvailable', isAvailable);
|
||||
if (isAvailable) {
|
||||
console.log('cast available');
|
||||
this.initializeCastApi();
|
||||
}
|
||||
};
|
||||
this.loadCastScript();
|
||||
}
|
||||
|
||||
private async loadCastScript() {
|
||||
if (typeof cast !== 'undefined') {
|
||||
return; // SDK already loaded
|
||||
}
|
||||
return loadScript('https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1')
|
||||
}
|
||||
|
||||
private initializeCastApi() {
|
||||
this.context = cast.framework.CastContext.getInstance();
|
||||
this.context.setOptions({
|
||||
receiverApplicationId: '0D9CC4B6',
|
||||
androidReceiverCompatible: true,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.CUSTOM_CONTROLLER_SCOPED
|
||||
});
|
||||
this.context.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
this.onSessionStateChange.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
private onSessionStateChange(event: cast.framework.SessionStateEventData) {
|
||||
console.log('onSessionStateChange', event);
|
||||
if (event.sessionState === cast.framework.SessionState.SESSION_STARTED) {
|
||||
this.sessionStarted = true;
|
||||
console.log('session started');
|
||||
const session = this.context?.getCurrentSession();
|
||||
// @ts-ignore
|
||||
this.sendMessage({ type: 'INIT', data: 'Hello, receiver!' });
|
||||
session?.addMessageListener(CHANNEL, (namespace, message) => {
|
||||
const msg = JSON.parse(message);
|
||||
console.log('Message received', namespace, msg);
|
||||
if (msg.type === 'RTC_ICE') {
|
||||
this.webrtcPeer?.addIceCandidate(msg.candidate);
|
||||
} else if (msg.type === 'RTC_ANSWER') {
|
||||
this.webrtcPeer?.setRemoteDescription(new RTCSessionDescription(msg.answer));
|
||||
}
|
||||
});
|
||||
this.initializeWebRTC();
|
||||
|
||||
const heartbeat = () => {
|
||||
const session = this.context?.getCurrentSession();
|
||||
if (session) {
|
||||
setTimeout(heartbeat, 10000);
|
||||
session?.sendMessage(CHANNEL, { type: 'HEARTBEAT' })
|
||||
.then(() => console.log('Heartbeat sent'))
|
||||
.catch(error => console.error('Error sending heartbeat:', error));
|
||||
}
|
||||
};
|
||||
heartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(message: Message, options?: SendMesssageOptions): void {
|
||||
if (!this.sessionStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.webRTC) {
|
||||
this.dataChannel?.send(JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.context?.getCurrentSession();
|
||||
if (!session) {
|
||||
console.error('No session');
|
||||
return;
|
||||
}
|
||||
|
||||
this.context?.getCurrentSession()?.sendMessage(CHANNEL, message)
|
||||
.then(() => console.log('Message sent successfully'))
|
||||
.catch(error => console.error('Error sending message:', error));
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
export class ChromecastReceiver extends EventTarget implements Receiver {
|
||||
|
||||
private webrtcPeer?: RTCPeerConnection
|
||||
private dataChannel: RTCDataChannel;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const isCast = new URLSearchParams(location.search).has('cast');
|
||||
if (isCast) {
|
||||
fixResolution();
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
await loadScript('//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js');
|
||||
// loadScript('//www.gstatic.com/cast/sdk/libs/devtools/debug_layer/caf_receiver_logger.js');
|
||||
this.initializeCastApi();
|
||||
}
|
||||
|
||||
|
||||
private async initializeWebRTC(offer: RTCSessionDescriptionInit, sendMessage: any) {
|
||||
const peer = new RTCPeerConnection();
|
||||
this.webrtcPeer = peer;
|
||||
this.webrtcPeer.ondatachannel = (event) => {
|
||||
this.dataChannel = event.channel;
|
||||
this.dataChannel.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
log('data channel message', msg);
|
||||
this.dispatchEvent(new CustomEvent(msg.type, {detail: msg}));
|
||||
this.dispatchEvent(new CustomEvent('all', {detail: msg}));
|
||||
// log('data channel message', event.data);
|
||||
this.dataChannel.send(JSON.stringify({ type: 'ACK', data: 'Hello, sender!' }));
|
||||
} catch (err) {
|
||||
log('data channel error', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
await peer.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await peer.createAnswer();
|
||||
await peer.setLocalDescription(answer);
|
||||
sendMessage({ type: 'RTC_ANSWER', answer });
|
||||
peer.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
// @ts-ignore
|
||||
sendMessage({ type: 'RTC_ICE', candidate: event.candidate });
|
||||
console.log('sending ice candidate', event.candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initializeCastApi() {
|
||||
try {
|
||||
const context = cast.framework.CastReceiverContext.getInstance();
|
||||
const options = new cast.framework.CastReceiverOptions();
|
||||
const playerManager = context.getPlayerManager();
|
||||
|
||||
context.addCustomMessageListener(CHANNEL, (event) => {
|
||||
const msg = event.data;
|
||||
log('msg', event.data.type);
|
||||
if (msg.type === 'RTC_OFFER') {
|
||||
this.initializeWebRTC(msg.offer, (data) => context.sendCustomMessage(CHANNEL, event.senderId, data));
|
||||
} else if (msg.type === 'RTC_ICE') {
|
||||
this.webrtcPeer?.addIceCandidate(msg.candidate);
|
||||
} else {
|
||||
if (msg.type === 'showFile') {
|
||||
context.setApplicationState('Map ' + msg.file.name);
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent(event.data.type, {detail: event.data}));
|
||||
this.dispatchEvent(new CustomEvent('all', {detail: event.data}));
|
||||
}
|
||||
})
|
||||
options.disableIdleTimeout = true;
|
||||
options.maxInactivity = 3600; // Development only
|
||||
context.start(options);
|
||||
} catch (err) {
|
||||
log('initializeCastApi error', err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
58
src/model/CommunicationChannel.ts
Normal file
58
src/model/CommunicationChannel.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FileWithMeta } from "./FileManager";
|
||||
|
||||
export interface MessageShowFile {
|
||||
type: 'showFile';
|
||||
file: FileWithMeta;
|
||||
}
|
||||
|
||||
export interface MessageChangeDiameter {
|
||||
type: 'changeDiameter';
|
||||
diameter: number;
|
||||
}
|
||||
|
||||
export interface MessageChangeWidth {
|
||||
type: 'changeWidth';
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface MessageChangePosition {
|
||||
type: 'changePosition';
|
||||
x: number;
|
||||
y: number;
|
||||
flipped: boolean;
|
||||
}
|
||||
|
||||
export interface MessageStreamImage {
|
||||
type: 'streamImage';
|
||||
imageData: {
|
||||
width: number;
|
||||
height: number;
|
||||
data: number[];
|
||||
};
|
||||
canvas: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type Message = MessageShowFile | MessageChangeDiameter | MessageChangeWidth | MessageChangePosition | MessageStreamImage;
|
||||
|
||||
export interface Receiver {
|
||||
addEventListener(type: "showFile", listener: (ev: CustomEvent<MessageShowFile>) => any): void;
|
||||
addEventListener(type: "changeDiameter", listener: (ev: CustomEvent<MessageChangeDiameter>) => any): void;
|
||||
addEventListener(type: "changeWidth", listener: (ev: CustomEvent<MessageChangeWidth>) => any): void;
|
||||
addEventListener(type: "changePosition", listener: (ev: CustomEvent<MessageChangePosition>) => any): void;
|
||||
addEventListener(type: "streamImage", listener: (ev: CustomEvent<MessageStreamImage>) => any): void;
|
||||
addEventListener(type: "all", listener: (ev: CustomEvent<Message>) => any): void;
|
||||
}
|
||||
|
||||
export interface SendMesssageOptions {
|
||||
webRTC?: boolean;
|
||||
}
|
||||
|
||||
export interface Sender {
|
||||
name: string;
|
||||
sendMessage(message: Message, options?: SendMesssageOptions): void;
|
||||
}
|
||||
86
src/model/FileManager.ts
Normal file
86
src/model/FileManager.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
type DateString = string & {};
|
||||
|
||||
interface File {
|
||||
name: string;
|
||||
type: 'file'
|
||||
mtime: DateString;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Directory {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
mtime: DateString;
|
||||
}
|
||||
|
||||
export interface DirectoryWithFiles extends Directory {
|
||||
path: string;
|
||||
files?: Array<FileWithMeta | DirectoryWithFiles>;
|
||||
}
|
||||
|
||||
export interface FileWithMeta extends File {
|
||||
path: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
|
||||
export class FileManager {
|
||||
|
||||
private _root:DirectoryWithFiles;
|
||||
|
||||
constructor(baseUrl: string = '/maps') {
|
||||
this._root = {
|
||||
path: `${baseUrl}/`,
|
||||
name: baseUrl,
|
||||
type: 'directory',
|
||||
mtime: new Date().toISOString() as DateString,
|
||||
}
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this._root;
|
||||
}
|
||||
|
||||
async fetchDirectoryContent(dir: DirectoryWithFiles): Promise<DirectoryWithFiles> {
|
||||
try {
|
||||
const filesResponse = await fetch(dir.path)
|
||||
const files = await filesResponse.json() as Array<File | Directory>;
|
||||
dir.files = this.convertApiListing(dir.path, files);
|
||||
return dir;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
private extractMetaFromFileName(name: string): { width: number, height: number } {
|
||||
const czepeku = new RegExp(/\[([0-9]+)x([0-9]+)\]/);
|
||||
const czepekuMatches = name?.match(czepeku);
|
||||
if (czepekuMatches) {
|
||||
return {
|
||||
width: parseInt(czepekuMatches![1]),
|
||||
height: parseInt(czepekuMatches![2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private convertApiListing(basePath: string, listing: Array<File | Directory>) {
|
||||
return listing.map(item => {
|
||||
if (item.type === 'directory') {
|
||||
return {
|
||||
...item,
|
||||
path: `${basePath}${item.name}/`,
|
||||
};
|
||||
} else {
|
||||
const path = `${basePath}${item.name}`;
|
||||
return {
|
||||
...item,
|
||||
path,
|
||||
...this.extractMetaFromFileName(path),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
32
src/model/MultiListener.ts
Normal file
32
src/model/MultiListener.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Message, Receiver, Sender, SendMesssageOptions } from "./CommunicationChannel";
|
||||
|
||||
|
||||
export class MultiSender implements Sender {
|
||||
name = 'multi'
|
||||
|
||||
constructor(private senders: Sender[]) {
|
||||
|
||||
}
|
||||
|
||||
sendMessage(message: any, options?: SendMesssageOptions): void {
|
||||
for (const sender of this.senders) {
|
||||
sender.sendMessage(message, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export class MultiReceiver extends EventTarget implements Receiver {
|
||||
|
||||
constructor(private receivers: Receiver[]) {
|
||||
super();
|
||||
this.receivers.forEach(receiver => {
|
||||
receiver.addEventListener('all', ({detail}) => {
|
||||
// console.log('event', detail);
|
||||
const msg = detail as Message;
|
||||
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg }));
|
||||
this.dispatchEvent(new CustomEvent('all', { detail: msg }));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
0
src/model/WebRtcListener.ts
Normal file
0
src/model/WebRtcListener.ts
Normal file
13
src/model/injectionSymbols.ts
Normal file
13
src/model/injectionSymbols.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { InjectionKey } from "vue";
|
||||
import type { FileManager } from "./FileManager";
|
||||
import type { CastSender } from "./CastSender.ts_";
|
||||
import type { CastReceiver } from "./CastReceiver.ts_";
|
||||
import { Receiver, Sender } from "./CommunicationChannel";
|
||||
|
||||
|
||||
export const FileManagerSymbol = Symbol("FileManager") as InjectionKey<FileManager>;
|
||||
export const CastSenderSymbol = Symbol("CastSender") as InjectionKey<CastSender>;
|
||||
export const CastReceiverSymbol = Symbol("CastReceiver") as InjectionKey<CastReceiver>;
|
||||
export const CastTypeSymbol = Symbol("CastType") as InjectionKey<"sender" | "receiver">;
|
||||
export const SenderSymbol = Symbol('Sender') as InjectionKey<Sender>;
|
||||
export const ReceiverSymbol = Symbol('Receiver') as InjectionKey<Receiver>;
|
||||
136
src/modules/controls/ControlApp.vue
Normal file
136
src/modules/controls/ControlApp.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="background-color: white; display: flex; flex-direction: row;gap: 0.5rem;padding: 0.5rem">
|
||||
<div style="max-width: 32px; height: 32px; overflow: hidden;">
|
||||
<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>
|
||||
<fieldset>
|
||||
<legend>Image settings</legend>
|
||||
<label>Width</label>
|
||||
<input type="number" v-model="imageWidth" />
|
||||
<label>Screen Diameter</label>
|
||||
<input type="number" v-model="screenDiameterInch" />
|
||||
<div>DPI: {{ screenDPI }}</div>
|
||||
<label>Flip <input type="checkbox" v-model="flipped"></label>
|
||||
<label>Position (X/Y)</label>
|
||||
<input type="range" min="0" max="100" v-model="positionX" />
|
||||
<input type="range" min="0" max="100" v-model="positionY" />
|
||||
<button @click="positionX = 50;positionY = 50">center</button>
|
||||
</fieldset>
|
||||
<FileManager @openFile="openFile" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import FileManager from '../../components/FileManager/FileManager.vue';
|
||||
import type { FileWithMeta } from '../../model/FileManager';
|
||||
import {ref, inject, watch} from 'vue';
|
||||
import {SenderSymbol} from '../../model/injectionSymbols';
|
||||
|
||||
const openedFile = ref<FileWithMeta>()
|
||||
const sender = inject(SenderSymbol);
|
||||
|
||||
const imageWidth = ref(20);
|
||||
const screenDiameterInch = ref(65);
|
||||
const flipped = ref(false);
|
||||
const positionX = ref(50);
|
||||
const positionY = ref(50);
|
||||
|
||||
|
||||
watch(() => imageWidth.value, (value) => {
|
||||
sender?.sendMessage({
|
||||
type: 'changeWidth',
|
||||
width: value
|
||||
})
|
||||
});
|
||||
|
||||
watch(() => screenDiameterInch.value, (value) => {
|
||||
sender?.sendMessage({
|
||||
type: 'changeDiameter',
|
||||
diameter: value
|
||||
})
|
||||
});
|
||||
|
||||
watch(() => [positionX.value, positionY.value, flipped.value], (value) => {
|
||||
sender?.sendMessage({
|
||||
type: 'changePosition',
|
||||
x: positionX.value,
|
||||
y: positionY.value,
|
||||
flipped: flipped.value,
|
||||
})
|
||||
});
|
||||
|
||||
function openFile(file: FileWithMeta) {
|
||||
sender?.sendMessage({
|
||||
type: 'showFile',
|
||||
file,
|
||||
})
|
||||
imageWidth.value = file.width ?? 20;
|
||||
openedFile.value = file;
|
||||
}
|
||||
|
||||
function openMapWindow() {
|
||||
window.open('/map/', 'map');
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function onStreamImage() {
|
||||
const image = new Image()
|
||||
image.onload = async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.setAttribute('willReadFrequently', 'true');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const [maxWidth, maxHeight] = (image.width > image.height) ? [3840, 2160] : [2160, 3840];
|
||||
if (image.width > maxWidth || image.height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / image.width, maxHeight / image.height);
|
||||
canvas.width = image.width * ratio;
|
||||
canvas.height = image.height * ratio;
|
||||
}
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const chunkSize = {width: 128, height: 128};
|
||||
|
||||
for (let x = 0; x < canvas.width; x += chunkSize.width) {
|
||||
for (let y = 0; y < canvas.height; y += chunkSize.height) {
|
||||
const imageData = ctx.getImageData(x, y, chunkSize.width, chunkSize.height);
|
||||
sender?.sendMessage({
|
||||
type: 'streamImage',
|
||||
imageData: {
|
||||
data: [...new Uint32Array(imageData.data.buffer)],
|
||||
width: imageData.width,
|
||||
height: imageData.height,
|
||||
},
|
||||
canvas: {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
},
|
||||
x,
|
||||
y,
|
||||
}, {webRTC: true});
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
image.src = openedFile.value?.path;
|
||||
}
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.aside {
|
||||
position: fixed;
|
||||
overflow: auto;
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
right:0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
border-left: #555 2px solid;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
</style>
|
||||
132
src/modules/controls/OldApp.vue
Normal file
132
src/modules/controls/OldApp.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend>Image settings</legend>
|
||||
<label>Width</label>
|
||||
<input type="number" v-model="imageWidth" />
|
||||
<label>Screen Diameter</label>
|
||||
<input type="number" v-model="screenDiameterInch" />
|
||||
<div>DPI: {{ screenDPI }}</div>
|
||||
<div style="max-width: 32px; height: 32px; overflow: hidden;">
|
||||
<google-cast-launcher style="max-width: 2rem" ></google-cast-launcher>
|
||||
</div>
|
||||
</fieldset>
|
||||
<FileManager @openFile="openFile" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import FileManager from '../../components/FileManager/FileManager.vue';
|
||||
import type { FileWithMeta } from '../../model/FileManager';
|
||||
import {ref, computed, inject, watch} from 'vue';
|
||||
import {CastTypeSymbol, CastReceiverSymbol, CastSenderSymbol} from '../../model/injectionSymbols';
|
||||
|
||||
const openedFile = ref<FileWithMeta>()
|
||||
const castType = inject(CastTypeSymbol);
|
||||
const receiver = inject(CastReceiverSymbol);
|
||||
const sender = inject(CastSenderSymbol);
|
||||
const canvas = ref<HTMLCanvasElement>();
|
||||
|
||||
const imageWidth = ref(20);
|
||||
const screenDiameterInch = ref(65);
|
||||
|
||||
const screenDPI = computed(() => {
|
||||
const diaPx = Math.sqrt(window.screen.width**2 + window.screen.height ** 2);
|
||||
return diaPx / screenDiameterInch.value;
|
||||
})
|
||||
|
||||
watch(() => imageWidth.value, (value) => {
|
||||
if (sender) {
|
||||
sender.sendMessage({
|
||||
type: 'imageWidth',
|
||||
imageWidth: value
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
function openFile(file: FileWithMeta) {
|
||||
console.log('open file', file)
|
||||
openedFile.value = file
|
||||
imageWidth.value = file.width ?? 20;
|
||||
if (sender) {
|
||||
sender.sendMessage({
|
||||
type: 'loadMap',
|
||||
file
|
||||
})
|
||||
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
canvas.value!.width = img.naturalWidth;
|
||||
canvas.value!.height = img.naturalHeight;
|
||||
const ctx = canvas.value!.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const msgs = [];
|
||||
const chunkWidth = 32;
|
||||
const chunkHeight = 32;
|
||||
|
||||
for (let x = 0; x< 1920; x += chunkWidth) {
|
||||
for (let y = 0; y < 1080; y += chunkHeight) {
|
||||
const data = ctx.getImageData(x, y, chunkWidth, chunkHeight);
|
||||
const msg = {
|
||||
type: 'imageData',
|
||||
imageData: {
|
||||
data: [...data.data],
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
};
|
||||
msgs.push(msg);
|
||||
sender.sendMessage(msg);
|
||||
}
|
||||
}
|
||||
ctx.clearRect(0, 0, img.naturalWidth, img.naturalHeight);
|
||||
for (const msg of msgs) {
|
||||
loadImageData(msg);
|
||||
}
|
||||
};
|
||||
img.src = file.path;
|
||||
}
|
||||
}
|
||||
|
||||
function loadImageData(detail) {
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(detail.imageData.data),
|
||||
detail.imageData.width,
|
||||
detail.imageData.height
|
||||
)
|
||||
const ctx = canvas.value!.getContext('2d')!;
|
||||
ctx.putImageData(imageData, detail.imageData.x, detail.imageData.y);
|
||||
}
|
||||
|
||||
if (receiver) {
|
||||
receiver.addEventListener('message', ({detail}) => {
|
||||
if (detail.type === 'loadMap') {
|
||||
openFile(detail.file);
|
||||
}
|
||||
if (detail.type === 'imageWidth') {
|
||||
imageWidth.value = detail.imageWidth;
|
||||
}
|
||||
if (detail.type === 'imageData') {
|
||||
loadImageData(detail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.aside {
|
||||
position: fixed;
|
||||
overflow: auto;
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
right:0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
border-left: #555 2px solid;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
</style>
|
||||
64
src/modules/map/MapApp.vue
Normal file
64
src/modules/map/MapApp.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="map-app">
|
||||
<MapScene
|
||||
ref="map"
|
||||
:src="src"
|
||||
:dpi="screenDPI"
|
||||
:flipped="flipped"
|
||||
:width="imageWidth"
|
||||
:position="{x: positionX, y: positionY}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import MapScene from './MapScene.vue';
|
||||
import {inject, ref, computed} from 'vue';
|
||||
import { ReceiverSymbol } from '../../model/injectionSymbols';
|
||||
|
||||
const imageWidth = ref(20);
|
||||
const screenDiameterInch = ref(65);
|
||||
const positionX = ref(50);
|
||||
const positionY = ref(50);
|
||||
const flipped = ref(false);
|
||||
const src = ref<string>()
|
||||
const map = ref<typeof MapScene>();
|
||||
|
||||
const screenDPI = computed(() => {
|
||||
const diaPx = Math.sqrt(window.screen.width**2 + window.screen.height ** 2);
|
||||
return diaPx / screenDiameterInch.value;
|
||||
})
|
||||
|
||||
|
||||
const receiver = inject(ReceiverSymbol);
|
||||
|
||||
receiver?.addEventListener('changeDiameter', ({detail}) => {
|
||||
screenDiameterInch.value = detail.diameter;
|
||||
});
|
||||
|
||||
receiver?.addEventListener('changeWidth', ({detail}) => {
|
||||
imageWidth.value = detail.width;
|
||||
});
|
||||
|
||||
receiver?.addEventListener('showFile', ({detail}) => {
|
||||
src.value = detail.file.path;
|
||||
if (detail.file.width) {
|
||||
imageWidth.value = detail.file.width;
|
||||
}
|
||||
});
|
||||
|
||||
receiver?.addEventListener('changePosition', ({detail}) => {
|
||||
positionX.value = detail.x;
|
||||
positionY.value = detail.y;
|
||||
flipped.value = detail.flipped;
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.map-app {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
148
src/modules/map/MapScene.vue
Normal file
148
src/modules/map/MapScene.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<video
|
||||
v-if="type === 'video'"
|
||||
:src="src"
|
||||
:style="dynamicStyle"
|
||||
class="map-scene--video map-scene"
|
||||
ref="video"
|
||||
@loadedmetadata="onVideoLoaded"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
></video>
|
||||
<canvas
|
||||
v-else
|
||||
:style="dynamicStyle"
|
||||
ref="canvas"
|
||||
class="map-scene--image map-scene"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, inject, ref, watch } from 'vue';
|
||||
import {ReceiverSymbol} from '../../model/injectionSymbols';
|
||||
|
||||
const video = ref<HTMLVideoElement>();
|
||||
const image = ref<HTMLImageElement>();
|
||||
const canvas = ref<HTMLCanvasElement>();
|
||||
|
||||
const props = defineProps<{
|
||||
src?: string;
|
||||
width: number;
|
||||
dpi: number;
|
||||
position: {x: number, y: number};
|
||||
flipped: boolean;
|
||||
}>();
|
||||
|
||||
const isImage = computed(() => props.src?.endsWith('.png') || props.src?.endsWith('.jpg') || props.src?.endsWith('.jpeg') || props.src?.endsWith('.webp'));
|
||||
const isVideo = computed(() => props.src?.endsWith('.mp4') || props.src?.endsWith('.webm'));
|
||||
|
||||
const mediaDimensions = ref({width: 0, height: 0});
|
||||
const virtualMediaDimensions = ref({width: 0, height: 0});
|
||||
|
||||
const windowSize = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
|
||||
watch(() => props.src, (src) => {
|
||||
if (isImage.value) {
|
||||
const img = new Image();
|
||||
img.src = src!;
|
||||
img.onload = () => {
|
||||
mediaDimensions.value = {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
canvas.value!.width = img.naturalWidth;
|
||||
canvas.value!.height = img.naturalHeight;
|
||||
const ctx = canvas.value!.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
};
|
||||
} else if (isVideo.value) {
|
||||
video.value!.src = src!;
|
||||
}
|
||||
});
|
||||
|
||||
const receiver = inject(ReceiverSymbol);
|
||||
receiver?.addEventListener('streamImage', ({detail}) => {
|
||||
if (canvas.value) {
|
||||
if (canvas.value.width !== detail.canvas.width || canvas.value.height !== detail.canvas.height) {
|
||||
const backup = document.createElement('canvas');
|
||||
backup.width = canvas.value.width;
|
||||
backup.height = canvas.value.height;
|
||||
const backupCtx = backup.getContext('2d')!;
|
||||
backupCtx.drawImage(canvas.value, 0, 0);
|
||||
canvas.value.width = detail.canvas.width;
|
||||
canvas.value.height = detail.canvas.height;
|
||||
const ctx = canvas.value.getContext('2d')!;
|
||||
ctx.drawImage(backup, 0, 0, detail.canvas.width, detail.canvas.height);
|
||||
}
|
||||
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(new Uint32Array(detail.imageData.data).buffer),
|
||||
detail.imageData.width,
|
||||
detail.imageData.height
|
||||
);
|
||||
const ctx = canvas.value.getContext('2d')!;
|
||||
ctx.putImageData(imageData, detail.x, detail.y);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const type = computed(() => {
|
||||
if (isImage.value) {
|
||||
return 'image';
|
||||
} else if (isVideo.value) {
|
||||
return 'video';
|
||||
} else {
|
||||
return 'unknown';
|
||||
}
|
||||
});
|
||||
|
||||
const dynamicStyle = computed(() => {
|
||||
const width = props.dpi * props.width;
|
||||
return {
|
||||
'--width': `${width}px`,
|
||||
'--height': `${(width) * (mediaDimensions.value.height / mediaDimensions.value.width)}px`,
|
||||
'--x': `${props.position.x/100}`,
|
||||
'--y': `${props.position.y/100}`,
|
||||
'--rotate': mediaDimensions.value.width > mediaDimensions.value.height ? (props.flipped ? '180deg' : '0deg') :
|
||||
(props.flipped ? '90deg' : '-90deg'),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function onImageLoaded(event: Event) {
|
||||
console.log('image loaded', event.target);
|
||||
mediaDimensions.value = {
|
||||
width: image.value!.naturalWidth,
|
||||
height: image.value!.naturalHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function onVideoLoaded(event: Event) {
|
||||
console.log('video loaded', event.target);
|
||||
mediaDimensions.value = {
|
||||
width: video.value!.videoWidth,
|
||||
height: video.value!.videoHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function updateImageSize() {
|
||||
if (image.value) {
|
||||
image.value.width = props.width;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.map-scene {
|
||||
position: absolute;
|
||||
left: calc(var(--x) * 100vw - var(--width) / 2);
|
||||
top: calc(var(--y) * 100vh - var(--height) / 2);
|
||||
width: var(--width);
|
||||
transform: rotate(var(--rotate));
|
||||
transform-origin: center;
|
||||
}
|
||||
</style>
|
||||
25
src/style.css
Normal file
25
src/style.css
Normal file
@@ -0,0 +1,25 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/model/CastReceiver.ts_", "src/model/CastSender"]
|
||||
}
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
tsconfig.node.json
Normal file
13
tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
30
vite.config.ts
Normal file
30
vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
map: resolve(__dirname, 'map/index.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag === 'google-cast-launcher'
|
||||
}
|
||||
}
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/maps': 'http://localhost:8080'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user