Initial commit
This commit is contained in:
43
remote/public/js/app.js
Normal file
43
remote/public/js/app.js
Normal file
@@ -0,0 +1,43 @@
|
||||
(function() {
|
||||
const password = getCookie('auth') || '';
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.hostname;
|
||||
const wsPort = window.location.port;
|
||||
const wsUrlBase = wsPort ? `${wsProtocol}//${wsHost}:${wsPort}/ws` : `${wsProtocol}//${wsHost}/ws`;
|
||||
const WS_URL = password ? `${wsUrlBase}?password=${encodeURIComponent(password)}` : wsUrlBase;
|
||||
|
||||
let videoPlayer = null;
|
||||
let inputHandler = null;
|
||||
|
||||
function init() {
|
||||
videoPlayer = new VideoPlayer('video-canvas', WS_URL);
|
||||
videoPlayer.init();
|
||||
|
||||
inputHandler = new InputHandler(videoPlayer.getCanvas(), {
|
||||
wsUrl: WS_URL
|
||||
});
|
||||
inputHandler.init();
|
||||
|
||||
console.log('xc-remote initialized');
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (inputHandler) {
|
||||
inputHandler.destroy();
|
||||
inputHandler = null;
|
||||
}
|
||||
if (videoPlayer) {
|
||||
videoPlayer.destroy();
|
||||
videoPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', destroy);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
500
remote/public/js/file-panel.js
Normal file
500
remote/public/js/file-panel.js
Normal file
@@ -0,0 +1,500 @@
|
||||
class FilePanel {
|
||||
constructor() {
|
||||
this.isVisible = false;
|
||||
this.localPath = '';
|
||||
this.remotePath = '';
|
||||
this.localFiles = [];
|
||||
this.remoteFiles = [];
|
||||
this.selectedLocal = null;
|
||||
this.selectedRemote = null;
|
||||
this.transfers = [];
|
||||
this.chunkSize = 5 * 1024 * 1024;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createPanel();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'file-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="file-panel-header">
|
||||
<span>文件传输</span>
|
||||
<button class="close-btn" id="close-panel">×</button>
|
||||
</div>
|
||||
<div class="file-panel-body">
|
||||
<div class="file-browser" id="local-browser">
|
||||
<div class="browser-header">
|
||||
<span class="browser-title">本地文件</span>
|
||||
<div class="path-nav">
|
||||
<button class="nav-btn" id="local-up" title="上级目录">↑</button>
|
||||
<input type="text" class="path-input" id="local-path" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-list" id="local-files"></div>
|
||||
<div class="browser-actions">
|
||||
<button class="action-btn" id="select-local-file">选择文件</button>
|
||||
<button class="action-btn primary" id="upload-btn" disabled>上传 →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-browser" id="remote-browser">
|
||||
<div class="browser-header">
|
||||
<span class="browser-title">远程文件</span>
|
||||
<div class="path-nav">
|
||||
<button class="nav-btn" id="remote-up" title="上级目录">↑</button>
|
||||
<input type="text" class="path-input" id="remote-path" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-list" id="remote-files"></div>
|
||||
<div class="browser-actions">
|
||||
<button class="action-btn primary" id="download-btn" disabled>← 下载</button>
|
||||
<button class="action-btn danger" id="delete-btn" disabled>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transfer-list" id="transfer-list"></div>
|
||||
<input type="file" id="file-input" style="display:none" multiple>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
this.panel = panel;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.getElementById('close-panel').addEventListener('click', () => this.hide());
|
||||
|
||||
document.getElementById('local-up').addEventListener('click', () => this.navigateLocalUp());
|
||||
document.getElementById('remote-up').addEventListener('click', () => this.navigateRemoteUp());
|
||||
|
||||
document.getElementById('select-local-file').addEventListener('click', () => {
|
||||
document.getElementById('file-input').click();
|
||||
});
|
||||
|
||||
document.getElementById('file-input').addEventListener('change', (e) => {
|
||||
this.handleLocalFileSelect(e.target.files);
|
||||
});
|
||||
|
||||
document.getElementById('upload-btn').addEventListener('click', () => this.uploadSelected());
|
||||
document.getElementById('download-btn').addEventListener('click', () => this.downloadSelected());
|
||||
document.getElementById('delete-btn').addEventListener('click', () => this.deleteSelected());
|
||||
|
||||
document.getElementById('remote-files').addEventListener('dblclick', (e) => {
|
||||
const item = e.target.closest('.file-item');
|
||||
if (item && item.dataset.isDirectory === 'true') {
|
||||
this.navigateRemote(item.dataset.name);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('remote-files').addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.file-item');
|
||||
if (item) {
|
||||
this.selectRemote(item.dataset.name, item.dataset.isDirectory === 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
show() {
|
||||
this.isVisible = true;
|
||||
this.panel.classList.add('visible');
|
||||
this.refreshRemote();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.isVisible = false;
|
||||
this.panel.classList.remove('visible');
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isVisible) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
async refreshRemote() {
|
||||
try {
|
||||
const res = await fetch(`/api/files/browse?path=${encodeURIComponent(this.remotePath)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Browse error:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.remoteFiles = data.items || [];
|
||||
this.remotePath = data.currentPath || '';
|
||||
|
||||
document.getElementById('remote-path').value = this.remotePath || '/';
|
||||
this.renderRemoteFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh remote:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderRemoteFiles() {
|
||||
const container = document.getElementById('remote-files');
|
||||
|
||||
if (this.remotePath) {
|
||||
container.innerHTML = `<div class="file-item directory" data-name=".." data-is-directory="true">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">..</span>
|
||||
</div>`;
|
||||
} else {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
this.remoteFiles.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = `file-item ${item.isDirectory ? 'directory' : 'file'}`;
|
||||
div.dataset.name = item.name;
|
||||
div.dataset.isDirectory = item.isDirectory;
|
||||
div.innerHTML = `
|
||||
<span class="file-icon">${item.isDirectory ? '📁' : this.getFileIcon(item.name)}</span>
|
||||
<span class="file-name">${item.name}</span>
|
||||
<span class="file-size">${item.isDirectory ? '' : this.formatSize(item.size)}</span>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
this.selectedRemote = null;
|
||||
this.updateRemoteActions();
|
||||
}
|
||||
|
||||
selectRemote(name, isDirectory) {
|
||||
const items = document.querySelectorAll('#remote-files .file-item');
|
||||
items.forEach(item => item.classList.remove('selected'));
|
||||
|
||||
const selected = document.querySelector(`#remote-files .file-item[data-name="${name}"]`);
|
||||
if (selected) {
|
||||
selected.classList.add('selected');
|
||||
this.selectedRemote = { name, isDirectory };
|
||||
}
|
||||
|
||||
this.updateRemoteActions();
|
||||
}
|
||||
|
||||
updateRemoteActions() {
|
||||
const downloadBtn = document.getElementById('download-btn');
|
||||
const deleteBtn = document.getElementById('delete-btn');
|
||||
|
||||
if (this.selectedRemote && !this.selectedRemote.isDirectory) {
|
||||
downloadBtn.disabled = false;
|
||||
deleteBtn.disabled = false;
|
||||
} else {
|
||||
downloadBtn.disabled = true;
|
||||
deleteBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
navigateRemote(name) {
|
||||
if (name === '..') {
|
||||
this.remotePath = this.remotePath.split('/').slice(0, -1).join('/');
|
||||
} else {
|
||||
this.remotePath = this.remotePath ? `${this.remotePath}/${name}` : name;
|
||||
}
|
||||
this.refreshRemote();
|
||||
}
|
||||
|
||||
navigateRemoteUp() {
|
||||
if (this.remotePath) {
|
||||
this.remotePath = this.remotePath.split('/').slice(0, -1).join('/');
|
||||
this.refreshRemote();
|
||||
}
|
||||
}
|
||||
|
||||
handleLocalFileSelect(files) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
this.localFiles = Array.from(files).map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
file: file,
|
||||
isDirectory: false
|
||||
}));
|
||||
|
||||
this.renderLocalFiles();
|
||||
}
|
||||
|
||||
renderLocalFiles() {
|
||||
const container = document.getElementById('local-files');
|
||||
container.innerHTML = '';
|
||||
|
||||
this.localFiles.forEach((item, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'file-item file';
|
||||
div.dataset.index = index;
|
||||
div.innerHTML = `
|
||||
<span class="file-icon">${this.getFileIcon(item.name)}</span>
|
||||
<span class="file-name">${item.name}</span>
|
||||
<span class="file-size">${this.formatSize(item.size)}</span>
|
||||
<button class="remove-btn" data-index="${index}">×</button>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
container.querySelectorAll('.remove-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const index = parseInt(btn.dataset.index);
|
||||
this.localFiles.splice(index, 1);
|
||||
this.renderLocalFiles();
|
||||
this.updateLocalActions();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.file-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
container.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
this.selectedLocal = parseInt(item.dataset.index);
|
||||
this.updateLocalActions();
|
||||
});
|
||||
});
|
||||
|
||||
this.updateLocalActions();
|
||||
}
|
||||
|
||||
updateLocalActions() {
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
uploadBtn.disabled = this.localFiles.length === 0;
|
||||
}
|
||||
|
||||
navigateLocalUp() {
|
||||
this.localFiles = [];
|
||||
this.selectedLocal = null;
|
||||
this.renderLocalFiles();
|
||||
this.updateLocalActions();
|
||||
}
|
||||
|
||||
async uploadSelected() {
|
||||
if (this.localFiles.length === 0) return;
|
||||
|
||||
for (const item of this.localFiles) {
|
||||
await this.uploadFile(item);
|
||||
}
|
||||
|
||||
this.localFiles = [];
|
||||
this.selectedLocal = null;
|
||||
this.renderLocalFiles();
|
||||
this.updateLocalActions();
|
||||
this.refreshRemote();
|
||||
}
|
||||
|
||||
async uploadFile(item) {
|
||||
const transferId = Date.now();
|
||||
this.addTransfer(transferId, item.name, 'upload', item.size);
|
||||
|
||||
try {
|
||||
const totalChunks = Math.ceil(item.size / this.chunkSize);
|
||||
|
||||
const startRes = await fetch('/api/files/upload/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: item.name,
|
||||
totalChunks,
|
||||
fileSize: item.size
|
||||
})
|
||||
});
|
||||
|
||||
const { fileId } = await startRes.json();
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * this.chunkSize;
|
||||
const end = Math.min(start + this.chunkSize, item.size);
|
||||
const chunk = item.file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileId', fileId);
|
||||
formData.append('chunkIndex', i);
|
||||
formData.append('chunk', chunk);
|
||||
|
||||
await fetch('/api/files/upload/chunk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const progress = Math.round(((i + 1) / totalChunks) * 100);
|
||||
this.updateTransfer(transferId, progress);
|
||||
}
|
||||
|
||||
const remoteFilePath = this.remotePath ? `${this.remotePath}/${item.name}` : item.name;
|
||||
|
||||
await fetch('/api/files/upload/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileId,
|
||||
totalChunks,
|
||||
filename: remoteFilePath
|
||||
})
|
||||
});
|
||||
|
||||
this.completeTransfer(transferId);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
this.failTransfer(transferId, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadSelected() {
|
||||
if (!this.selectedRemote || this.selectedRemote.isDirectory) return;
|
||||
|
||||
const filename = this.selectedRemote.name;
|
||||
const remoteFilePath = this.remotePath ? `${this.remotePath}/${filename}` : filename;
|
||||
|
||||
const transferId = Date.now();
|
||||
this.addTransfer(transferId, filename, 'download', 0);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/files/${encodeURIComponent(remoteFilePath)}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
|
||||
const contentLength = parseInt(res.headers.get('Content-Length') || '0');
|
||||
this.updateTransferSize(transferId, contentLength);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
|
||||
const progress = contentLength > 0 ? Math.round((received / contentLength) * 100) : 0;
|
||||
this.updateTransfer(transferId, progress);
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.completeTransfer(transferId);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
this.failTransfer(transferId, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSelected() {
|
||||
if (!this.selectedRemote || this.selectedRemote.isDirectory) return;
|
||||
|
||||
if (!confirm(`确定要删除 "${this.selectedRemote.name}" 吗?`)) return;
|
||||
|
||||
const remoteFilePath = this.remotePath ? `${this.remotePath}/${this.selectedRemote.name}` : this.selectedRemote.name;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/files/${encodeURIComponent(remoteFilePath)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
this.refreshRemote();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addTransfer(id, name, type, size) {
|
||||
const transfer = { id, name, type, size, progress: 0, status: 'transferring' };
|
||||
this.transfers.push(transfer);
|
||||
this.renderTransfers();
|
||||
}
|
||||
|
||||
updateTransfer(id, progress) {
|
||||
const transfer = this.transfers.find(t => t.id === id);
|
||||
if (transfer) {
|
||||
transfer.progress = progress;
|
||||
this.renderTransfers();
|
||||
}
|
||||
}
|
||||
|
||||
updateTransferSize(id, size) {
|
||||
const transfer = this.transfers.find(t => t.id === id);
|
||||
if (transfer) {
|
||||
transfer.size = size;
|
||||
this.renderTransfers();
|
||||
}
|
||||
}
|
||||
|
||||
completeTransfer(id) {
|
||||
const transfer = this.transfers.find(t => t.id === id);
|
||||
if (transfer) {
|
||||
transfer.status = 'completed';
|
||||
transfer.progress = 100;
|
||||
this.renderTransfers();
|
||||
|
||||
setTimeout(() => {
|
||||
this.transfers = this.transfers.filter(t => t.id !== id);
|
||||
this.renderTransfers();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
failTransfer(id, error) {
|
||||
const transfer = this.transfers.find(t => t.id === id);
|
||||
if (transfer) {
|
||||
transfer.status = 'failed';
|
||||
transfer.error = error;
|
||||
this.renderTransfers();
|
||||
}
|
||||
}
|
||||
|
||||
renderTransfers() {
|
||||
const container = document.getElementById('transfer-list');
|
||||
|
||||
if (this.transfers.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.transfers.map(t => `
|
||||
<div class="transfer-item ${t.status}">
|
||||
<span class="transfer-type">${t.type === 'upload' ? '↑' : '↓'}</span>
|
||||
<span class="transfer-name">${t.name}</span>
|
||||
<div class="transfer-progress">
|
||||
<div class="progress-bar" style="width: ${t.progress}%"></div>
|
||||
</div>
|
||||
<span class="transfer-percent">${t.progress}%</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
formatSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
getFileIcon(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const icons = {
|
||||
'txt': '📄', 'doc': '📄', 'docx': '📄', 'pdf': '📄',
|
||||
'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️',
|
||||
'mp3': '🎵', 'wav': '🎵', 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬',
|
||||
'zip': '📦', 'rar': '📦', '7z': '📦',
|
||||
'exe': '⚙️', 'msi': '⚙️',
|
||||
'js': '📜', 'ts': '📜', 'py': '📜', 'html': '📜', 'css': '📜'
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
}
|
||||
|
||||
window.FilePanel = FilePanel;
|
||||
229
remote/public/js/input.js
Normal file
229
remote/public/js/input.js
Normal file
@@ -0,0 +1,229 @@
|
||||
class InputHandler {
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
this.wsUrl = options.wsUrl;
|
||||
this.screenWidth = options.screenWidth || 1280;
|
||||
this.screenHeight = options.screenHeight || 720;
|
||||
|
||||
this.ws = null;
|
||||
this.wsReady = false;
|
||||
|
||||
this.lastMoveTime = 0;
|
||||
this.MOVE_THROTTLE_MS = 33;
|
||||
this.pendingMove = null;
|
||||
this.moveThrottleTimer = null;
|
||||
|
||||
this.pressedKeys = new Set();
|
||||
|
||||
this.codeToKey = {
|
||||
'Enter': 'enter', 'Backspace': 'backspace', 'Tab': 'tab',
|
||||
'Escape': 'escape', 'Delete': 'delete', 'Home': 'home',
|
||||
'End': 'end', 'PageUp': 'pageup', 'PageDown': 'pagedown',
|
||||
'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right',
|
||||
'F1': 'f1', 'F2': 'f2', 'F3': 'f3', 'F4': 'f4',
|
||||
'F5': 'f5', 'F6': 'f6', 'F7': 'f7', 'F8': 'f8',
|
||||
'F9': 'f9', 'F10': 'f10', 'F11': 'f11', 'F12': 'f12',
|
||||
'ControlLeft': 'ctrl', 'ControlRight': 'ctrl',
|
||||
'AltLeft': 'alt', 'AltRight': 'alt',
|
||||
'ShiftLeft': 'shift', 'ShiftRight': 'shift',
|
||||
'MetaLeft': 'win', 'MetaRight': 'win',
|
||||
'Space': 'space',
|
||||
'Digit0': '0', 'Digit1': '1', 'Digit2': '2', 'Digit3': '3', 'Digit4': '4',
|
||||
'Digit5': '5', 'Digit6': '6', 'Digit7': '7', 'Digit8': '8', 'Digit9': '9',
|
||||
'KeyA': 'a', 'KeyB': 'b', 'KeyC': 'c', 'KeyD': 'd', 'KeyE': 'e',
|
||||
'KeyF': 'f', 'KeyG': 'g', 'KeyH': 'h', 'KeyI': 'i', 'KeyJ': 'j',
|
||||
'KeyK': 'k', 'KeyL': 'l', 'KeyM': 'm', 'KeyN': 'n', 'KeyO': 'o',
|
||||
'KeyP': 'p', 'KeyQ': 'q', 'KeyR': 'r', 'KeyS': 's', 'KeyT': 't',
|
||||
'KeyU': 'u', 'KeyV': 'v', 'KeyW': 'w', 'KeyX': 'x', 'KeyY': 'y', 'KeyZ': 'z',
|
||||
'Comma': ',', 'Period': '.', 'Slash': '/', 'Semicolon': ';',
|
||||
'Quote': "'", 'BracketLeft': '[', 'BracketRight': ']',
|
||||
'Backslash': '\\', 'Minus': '-', 'Equal': '=', 'Backquote': '`'
|
||||
};
|
||||
|
||||
this._boundHandlers = {
|
||||
mousemove: this._handleMouseMove.bind(this),
|
||||
mousedown: this._handleMouseDown.bind(this),
|
||||
mouseup: this._handleMouseUp.bind(this),
|
||||
contextmenu: this._handleContextMenu.bind(this),
|
||||
wheel: this._handleWheel.bind(this),
|
||||
keydown: this._handleKeyDown.bind(this),
|
||||
keyup: this._handleKeyUp.bind(this),
|
||||
blur: this._handleBlur.bind(this)
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this._initWebSocket();
|
||||
this._bindEvents();
|
||||
return this;
|
||||
}
|
||||
|
||||
_initWebSocket() {
|
||||
this.ws = createReconnectingWebSocket(this.wsUrl, {
|
||||
onOpen: () => {
|
||||
this.wsReady = true;
|
||||
console.log('Input WebSocket connected');
|
||||
},
|
||||
onClose: () => {
|
||||
this.wsReady = false;
|
||||
console.log('Input WebSocket disconnected');
|
||||
},
|
||||
onMessage: (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'screenInfo') {
|
||||
this.screenWidth = msg.width;
|
||||
this.screenHeight = msg.height;
|
||||
console.log('Screen resolution:', this.screenWidth, 'x', this.screenHeight);
|
||||
}
|
||||
} catch (err) {}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Input WebSocket error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.canvas.addEventListener('mousemove', this._boundHandlers.mousemove);
|
||||
this.canvas.addEventListener('mousedown', this._boundHandlers.mousedown);
|
||||
this.canvas.addEventListener('mouseup', this._boundHandlers.mouseup);
|
||||
this.canvas.addEventListener('contextmenu', this._boundHandlers.contextmenu);
|
||||
this.canvas.addEventListener('wheel', this._boundHandlers.wheel);
|
||||
document.addEventListener('keydown', this._boundHandlers.keydown);
|
||||
document.addEventListener('keyup', this._boundHandlers.keyup);
|
||||
window.addEventListener('blur', this._boundHandlers.blur);
|
||||
}
|
||||
|
||||
_unbindEvents() {
|
||||
this.canvas.removeEventListener('mousemove', this._boundHandlers.mousemove);
|
||||
this.canvas.removeEventListener('mousedown', this._boundHandlers.mousedown);
|
||||
this.canvas.removeEventListener('mouseup', this._boundHandlers.mouseup);
|
||||
this.canvas.removeEventListener('contextmenu', this._boundHandlers.contextmenu);
|
||||
this.canvas.removeEventListener('wheel', this._boundHandlers.wheel);
|
||||
document.removeEventListener('keydown', this._boundHandlers.keydown);
|
||||
document.removeEventListener('keyup', this._boundHandlers.keyup);
|
||||
window.removeEventListener('blur', this._boundHandlers.blur);
|
||||
}
|
||||
|
||||
sendInputEvent(event) {
|
||||
if (this.wsReady && this.ws && this.ws.isReady()) {
|
||||
this.ws.getWebSocket().send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
|
||||
_sendMouseMove(x, y) {
|
||||
this.pendingMove = { x, y };
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastMoveTime >= this.MOVE_THROTTLE_MS) {
|
||||
this._flushMouseMove();
|
||||
} else if (!this.moveThrottleTimer) {
|
||||
this.moveThrottleTimer = setTimeout(() => {
|
||||
this._flushMouseMove();
|
||||
this.moveThrottleTimer = null;
|
||||
}, this.MOVE_THROTTLE_MS - (now - this.lastMoveTime));
|
||||
}
|
||||
}
|
||||
|
||||
_flushMouseMove() {
|
||||
if (this.pendingMove) {
|
||||
this.sendInputEvent({
|
||||
type: 'mouseMove',
|
||||
x: this.pendingMove.x,
|
||||
y: this.pendingMove.y
|
||||
});
|
||||
this.lastMoveTime = Date.now();
|
||||
this.pendingMove = null;
|
||||
}
|
||||
}
|
||||
|
||||
_handleMouseMove(e) {
|
||||
const { x, y } = getScreenCoordinates(e.clientX, e.clientY, this.canvas, this.screenWidth, this.screenHeight);
|
||||
this._sendMouseMove(x, y);
|
||||
}
|
||||
|
||||
_handleMouseDown(e) {
|
||||
const button = e.button === 2 ? 'right' : 'left';
|
||||
this.sendInputEvent({
|
||||
type: 'mouseDown',
|
||||
button: button
|
||||
});
|
||||
}
|
||||
|
||||
_handleMouseUp(e) {
|
||||
const button = e.button === 2 ? 'right' : 'left';
|
||||
this.sendInputEvent({
|
||||
type: 'mouseUp',
|
||||
button: button
|
||||
});
|
||||
}
|
||||
|
||||
_handleContextMenu(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
_handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = -Math.sign(e.deltaY) * 120;
|
||||
this.sendInputEvent({
|
||||
type: 'mouseWheel',
|
||||
delta: delta
|
||||
});
|
||||
}
|
||||
|
||||
_getKeyFromEvent(e) {
|
||||
if (this.codeToKey[e.code]) {
|
||||
return this.codeToKey[e.code];
|
||||
}
|
||||
return e.key;
|
||||
}
|
||||
|
||||
_handleKeyDown(e) {
|
||||
const key = this._getKeyFromEvent(e);
|
||||
const keyId = key.toLowerCase();
|
||||
|
||||
if (!this.pressedKeys.has(keyId)) {
|
||||
this.pressedKeys.add(keyId);
|
||||
this.sendInputEvent({
|
||||
type: 'keyDown',
|
||||
key: key
|
||||
});
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' || e.key === ' ' || e.key.startsWith('Arrow')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
_handleKeyUp(e) {
|
||||
const key = this._getKeyFromEvent(e);
|
||||
const keyId = key.toLowerCase();
|
||||
|
||||
this.pressedKeys.delete(keyId);
|
||||
this.sendInputEvent({
|
||||
type: 'keyUp',
|
||||
key: key
|
||||
});
|
||||
}
|
||||
|
||||
_handleBlur() {
|
||||
this.pressedKeys.forEach(keyId => {
|
||||
this.sendInputEvent({
|
||||
type: 'keyUp',
|
||||
key: keyId
|
||||
});
|
||||
});
|
||||
this.pressedKeys.clear();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._unbindEvents();
|
||||
if (this.moveThrottleTimer) {
|
||||
clearTimeout(this.moveThrottleTimer);
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
remote/public/js/jsmpeg.min.js
vendored
Normal file
1
remote/public/js/jsmpeg.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
36
remote/public/js/player.js
Normal file
36
remote/public/js/player.js
Normal file
@@ -0,0 +1,36 @@
|
||||
class VideoPlayer {
|
||||
constructor(canvasId, wsUrl, options = {}) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
this.wsUrl = wsUrl;
|
||||
this.options = {
|
||||
autoplay: true,
|
||||
audio: false,
|
||||
videoBufferSize: 512 * 1024,
|
||||
...options
|
||||
};
|
||||
this.player = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.player = new JSMpeg.Player(this.wsUrl, {
|
||||
canvas: this.canvas,
|
||||
...this.options
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
getCanvas() {
|
||||
return this.canvas;
|
||||
}
|
||||
|
||||
getPlayer() {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
this.player = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
remote/public/js/utils.js
Normal file
88
remote/public/js/utils.js
Normal file
@@ -0,0 +1,88 @@
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return '';
|
||||
}
|
||||
|
||||
function getScreenCoordinates(clientX, clientY, canvas, screenWidth, screenHeight) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasX = clientX - rect.left;
|
||||
const canvasY = clientY - rect.top;
|
||||
|
||||
const scaleX = screenWidth / rect.width;
|
||||
const scaleY = screenHeight / rect.height;
|
||||
|
||||
const screenX = Math.floor(canvasX * scaleX);
|
||||
const screenY = Math.floor(canvasY * scaleY);
|
||||
|
||||
return { x: screenX, y: screenY };
|
||||
}
|
||||
|
||||
function createReconnectingWebSocket(url, options = {}) {
|
||||
const {
|
||||
onOpen = () => {},
|
||||
onClose = () => {},
|
||||
onMessage = () => {},
|
||||
onError = () => {},
|
||||
maxDelay = 30000
|
||||
} = options;
|
||||
|
||||
let ws = null;
|
||||
let reconnectDelay = 1000;
|
||||
let reconnectTimer = null;
|
||||
let isManualClose = false;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = 1000;
|
||||
onOpen(ws);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
onMessage(e, ws);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
onClose();
|
||||
if (!isManualClose) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
onError(err);
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
isManualClose = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getWebSocket() {
|
||||
return ws;
|
||||
}
|
||||
|
||||
function isReady() {
|
||||
return ws && ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
getWebSocket,
|
||||
isReady,
|
||||
close
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user