🎉
This commit is contained in:
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>;
|
||||
Reference in New Issue
Block a user