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

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