This commit is contained in:
2024-07-15 21:54:12 +00:00
commit 41bdd55358
33 changed files with 4503 additions and 0 deletions

25
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View 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
View 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
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>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
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>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
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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
View 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
View 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

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

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

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

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

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

View 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
View 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),
}
}
});
}
}

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

View File

View 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>;

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

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

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

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

13
tsconfig.node.json Normal file
View 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
View 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'
}
}
})