Feat: Android client Phase 0-3 full implementation

This commit was merged in pull request #3.
This commit is contained in:
yuanyuanxiang
2026-06-17 23:19:12 +02:00
parent 837d89c8b5
commit 45553ec5b6
53 changed files with 3321 additions and 27 deletions

View File

@@ -783,6 +783,21 @@
#screen-page:-webkit-full-screen .toolbar-toggle {
display: flex;
}
/* Android portrait mode: full-viewport canvas, no top toolbar */
#screen-page.android-portrait { overflow: hidden; }
#screen-page.android-portrait .screen-toolbar { display: none !important; }
#screen-page.android-portrait .canvas-container {
height: 100dvh !important; height: 100vh !important;
padding-top: 0 !important;
align-items: center !important;
}
#screen-page.android-portrait #screen-canvas {
max-width: 100vw !important;
max-height: 100dvh !important; max-height: 100vh !important;
}
#screen-page.android-portrait .toolbar-toggle { display: flex !important; }
#screen-page.android-portrait .quick-controls { display: none !important; }
.compat-warning {
background: linear-gradient(90deg, #ff9800, #f57c00);
color: #000;
@@ -1063,6 +1078,33 @@
}
.input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); }
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
/* Android virtual navigation bar - overlays bottom of canvas when controlling Android */
.android-navbar {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 44px;
display: none;
align-items: center;
justify-content: center;
gap: 56px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 102;
user-select: none;
}
.android-navbar.visible { display: flex; }
.android-navbar .nav-btn {
width: 44px; height: 44px;
display: flex; align-items: center; justify-content: center;
border: none; background: transparent;
cursor: pointer; opacity: 0.85;
transition: opacity 0.15s;
-webkit-tap-highlight-color: transparent;
padding: 0;
}
.android-navbar .nav-btn:active { opacity: 0.4; }
/* Mobile responsive */
@media (max-width: 768px) {
.page {
@@ -1195,6 +1237,18 @@
<div class="canvas-container">
<canvas id="screen-canvas"></canvas>
<div class="cursor-overlay" id="cursor-overlay"></div>
<!-- Android virtual navigation bar: Back / Home / Recents -->
<div class="android-navbar" id="android-navbar">
<button class="nav-btn" onclick="sendAndroidNav(8)" title="Back" tabindex="-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<button class="nav-btn" onclick="sendAndroidNav(36)" title="Home" tabindex="-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>
</button>
<button class="nav-btn" onclick="sendAndroidNav(93)" title="Recents" tabindex="-1">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
</button>
</div>
<div class="quick-controls" id="quick-controls">
<button class="qc-btn" id="qc-rdp" onclick="sendRdpReset()" title="RDP Reset" tabindex="-1">&#x21BB;</button>
<button class="qc-btn" id="qc-keyboard" onclick="toggleKeyboard()" title="Keyboard" tabindex="-1">&#x2328;</button>
@@ -1310,6 +1364,8 @@
let bwBytesAccum = 0; // current-second byte accumulator
let bwBytesPerSec = 0; // last second's throughput (bytes/sec)
let currentWidth = 0, currentHeight = 0; // captured at frame decode time
let isTopDown = false; // true = Android/MediaCodec top-down H.264, skip vertical flip
let isAndroidRemote = false; // true = remote is Android (client_type==="APK") → direct-touch on mobile
const canvas = document.getElementById('screen-canvas');
const ctx = canvas.getContext('2d');
@@ -1564,6 +1620,10 @@
// Resolution may not be available yet (first connection)
if (msg.width && msg.height) {
updateScreenStatus('connected');
isTopDown = msg.top_down === true;
isAndroidRemote = msg.client_type === 'APK';
updateAndroidNavbar();
if (isTouchDevice) updateUIForOrientation();
initDecoder(msg.width, msg.height);
} else {
// Wait for resolution_changed message
@@ -1609,6 +1669,15 @@
break;
case 'resolution_changed':
updateScreenStatus('connected');
isTopDown = msg.top_down === true;
if (msg.client_type !== undefined) {
isAndroidRemote = msg.client_type === 'APK';
updateAndroidNavbar();
// Refresh cursor overlay and immersive layout
document.getElementById('cursor-overlay').classList.toggle(
'active', controlEnabled && isTouchDevice && !isAndroidRemote);
if (isTouchDevice) updateUIForOrientation();
}
initDecoder(msg.width, msg.height);
break;
case 'cursor':
@@ -1694,8 +1763,13 @@
// Update input shortcuts position after canvas resize
requestAnimationFrame(updateInputShortcutsPosition);
// Set up vertical flip transform once (BMP is bottom-up)
ctx.setTransform(1, 0, 0, -1, 0, height);
// Windows/Linux/macOS clients send bottom-up H.264 (BMP convention) → flip.
// Android MediaCodec sends top-down H.264 → no flip needed.
if (isTopDown) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
} else {
ctx.setTransform(1, 0, 0, -1, 0, height);
}
if (decoder) { try { decoder.close(); } catch(e) {} }
// Reset FPS sliding window on decoder (re)init so a resolution
// change or codec switch doesn't carry over stale counts.
@@ -2923,7 +2997,8 @@
btnRdpResetBar, btnMouseBar, btnKeyboardBar, inputShortcuts } = uiElements;
if (isLandscape()) {
// Landscape mode
// Landscape mode: always clear android-portrait immersive class
document.getElementById('screen-page').classList.remove('android-portrait');
quickControls.classList.remove('visible');
inputShortcuts.classList.remove('visible');
@@ -2942,8 +3017,21 @@
btnMouseBar.classList.remove('ui-hidden');
btnKeyboardBar.classList.remove('ui-hidden');
}
} else if (isAndroidRemote && isFullscreen()) {
// Android portrait fullscreen: immersive mode — hide top toolbar, show three-dot toggle.
// Canvas fills the full viewport via .android-portrait CSS class.
document.getElementById('screen-page').classList.add('android-portrait');
quickControls.classList.remove('visible');
inputShortcuts.classList.remove('visible');
toolbarToggle.classList.remove('ui-hidden');
floatingToolbar.classList.remove('visible');
toolbarVisible = false;
btnRdpResetBar.classList.add('ui-hidden');
btnMouseBar.classList.add('ui-hidden');
btnKeyboardBar.classList.add('ui-hidden');
} else {
// Portrait mode: show quick controls
// Portrait mode (non-Android): show quick controls
document.getElementById('screen-page').classList.remove('android-portrait');
quickControls.classList.add('visible');
// Input shortcuts only visible when keyboard is open
const keyboardOpen = document.activeElement === mobileKeyboard;
@@ -3038,7 +3126,8 @@
} else if (!controlEnabled) {
canvas.style.cursor = 'default';
}
cursorOverlay.classList.toggle('active', controlEnabled && isTouchDevice);
// Android direct mode: no cursor overlay (finger IS the pointer)
cursorOverlay.classList.toggle('active', controlEnabled && isTouchDevice && !isAndroidRemote);
if (controlEnabled) {
applyRemoteCursor(currentCursorIndex);
}
@@ -3278,6 +3367,83 @@
const touchIndicator = document.getElementById('touch-indicator');
const mobileKeyboard = document.getElementById('mobile-keyboard');
// ── Android direct-touch state ────────────────────────────────────────
// Active when isAndroidRemote && isTouchDevice.
// Single finger maps directly to remote screen coordinates.
let ad = {
active: false,
startX: 0, startY: 0,
lastX: 0, lastY: 0,
hasMoved: false,
longPressTimer: null,
lastTapX: 0, lastTapY: 0,
lastTapTime: 0
};
function adTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const pos = getTouchPos(touch);
const now = Date.now();
if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; }
// Double-tap detection
const dx = pos.x - ad.lastTapX, dy = pos.y - ad.lastTapY;
if (now - ad.lastTapTime < 300 && dx*dx + dy*dy < 1600) {
ad.lastTapTime = 0;
ad.active = false;
sendMouse('dblclick', pos.x, pos.y, 0);
return;
}
ad.active = true;
ad.startX = ad.lastX = pos.x;
ad.startY = ad.lastY = pos.y;
ad.hasMoved = false;
sendMouse('down', pos.x, pos.y, 0);
// Long press → right-click (Android long-press)
ad.longPressTimer = setTimeout(function() {
if (ad.active && !ad.hasMoved) {
sendMouse('up', ad.lastX, ad.lastY, 0);
sendMouse('down', ad.lastX, ad.lastY, 2);
sendMouse('up', ad.lastX, ad.lastY, 2);
ad.active = false;
showTouchIndicator(touch.clientX, touch.clientY);
}
ad.longPressTimer = null;
}, 600);
}
function adTouchMove(e) {
if (e.touches.length !== 1 || !ad.active) return;
const pos = getTouchPos(e.touches[0]);
const dx = pos.x - ad.startX, dy = pos.y - ad.startY;
if (!ad.hasMoved && dx*dx + dy*dy > 64) { // 8px threshold
ad.hasMoved = true;
if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; }
}
if (ad.hasMoved) sendMouse('move', pos.x, pos.y, 0);
ad.lastX = pos.x;
ad.lastY = pos.y;
}
function adTouchEnd() {
if (!ad.active) return;
if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; }
ad.active = false;
if (!ad.hasMoved) {
ad.lastTapTime = Date.now();
ad.lastTapX = ad.lastX;
ad.lastTapY = ad.lastY;
}
sendMouse('up', ad.lastX, ad.lastY, 0);
}
// ─────────────────────────────────────────────────────────────────────
// Initialize cursor to center of screen
function initCursor() {
if (!cursorState.initialized && canvas.width > 0) {
@@ -3337,6 +3503,17 @@
}
}
function sendAndroidNav(keyCode) {
sendKey(keyCode, true);
setTimeout(() => sendKey(keyCode, false), 50);
}
function updateAndroidNavbar() {
const nb = document.getElementById('android-navbar');
if (!nb) return;
nb.classList.toggle('visible', !!isAndroidRemote);
}
function sendKey(keyCode, isDown, altKey) {
if (!controlEnabled) return; // Control mode required
// Filter Windows keys (handled locally, not sent to remote)
@@ -3391,7 +3568,9 @@
return;
}
// Single finger touch
// Single finger touch — Android direct mode bypasses the touchpad state machine
if (isAndroidRemote && isTouchDevice) { adTouchStart(e); return; }
initCursor();
const touch = e.touches[0];
const oldStartX = touchState.startX;
@@ -3560,6 +3739,9 @@
return;
}
// Single finger move — Android direct mode
if (isAndroidRemote && isTouchDevice) { adTouchMove(e); return; }
// Single finger move - state machine based
const touch = e.touches[0];
const dx = touch.clientX - touchState.lastX;
@@ -3646,6 +3828,9 @@
return;
}
// Android direct mode touchend
if (isAndroidRemote && isTouchDevice) { adTouchEnd(); return; }
// State machine based touchend
const overlay = document.getElementById('cursor-overlay');
const x = Math.round(cursorState.x);
@@ -3925,6 +4110,9 @@
ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) }));
}
currentDevice = null; // Clear current device
isAndroidRemote = false;
updateAndroidNavbar();
document.getElementById('screen-page').classList.remove('android-portrait');
showPage('devices-page');
getDevices();
}