From a1f4d6b15cf1e6222ed1611de8b98ec12db2149a Mon Sep 17 00:00:00 2001 From: neonjava Date: Fri, 30 Oct 2026 21:35:17 +0530 Subject: [PATCH] feat: add portrait mode, dynamic monitor positioning, and stream flow control - Dynamic monitor positioning (--position auto|left|right) and rotation/transform support (--transform, --portrait) - Non-negative coordinate shifting when placing displays on the left, keeping layer-shell daemons (waybar, swww) fully intact - Multi-worker frame pacing and dynamic WebSocket transmission interval matching target FPS to eliminate TCP socket bufferbloat and lag - Made scripts fully portable and dynamic without hardcoded user paths or monitor coordinates - Added fullSensor orientation and FillBounds stretch toggle support to Android app and HTML5 web client - Asynchronous mDNS registration thread to prevent startup blocking --- android/app/src/main/AndroidManifest.xml | 3 +- .../linuxextend/ui/screens/DisplayScreen.kt | 3 +- scripts/install_apk.sh | 13 +++- scripts/linuxextend.sh | 33 +++++++--- scripts/switch_monitor.sh | 59 +++++++++++++----- server/linuxextend/__main__.py | 39 ++++++++++-- server/linuxextend/capture.py | 6 +- server/linuxextend/config.py | 4 +- server/linuxextend/discovery.py | 61 +++++++++++-------- server/linuxextend/display.py | 60 +++++++++++++----- server/linuxextend/server.py | 59 ++++++++++++------ server/tests/test_config.py | 4 ++ server/tests/test_display.py | 16 ++--- 13 files changed, 257 insertions(+), 103 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 69e0967..b93cbef 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,7 +15,8 @@ diff --git a/android/app/src/main/java/com/linuxextend/ui/screens/DisplayScreen.kt b/android/app/src/main/java/com/linuxextend/ui/screens/DisplayScreen.kt index 8eecd9d..00cbe5f 100644 --- a/android/app/src/main/java/com/linuxextend/ui/screens/DisplayScreen.kt +++ b/android/app/src/main/java/com/linuxextend/ui/screens/DisplayScreen.kt @@ -59,6 +59,7 @@ fun DisplayScreen( var showOverlay by remember { mutableStateOf(false) } var showFps by remember { mutableStateOf(true) } + var fillBounds by remember { mutableStateOf(true) } // Keep screen on val context = LocalContext.current @@ -100,7 +101,7 @@ fun DisplayScreen( bitmap = bitmap.asImageBitmap(), contentDescription = "Second Screen", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, + contentScale = if (fillBounds) ContentScale.FillBounds else ContentScale.Fit, ) } diff --git a/scripts/install_apk.sh b/scripts/install_apk.sh index 39af6c3..72276ae 100755 --- a/scripts/install_apk.sh +++ b/scripts/install_apk.sh @@ -17,7 +17,18 @@ fi if [ ! -f "$APK_PATH" ]; then echo "📦 APK not found. Building with Gradle..." - export JAVA_HOME="${JAVA_HOME:-$HOME/.jdks/jbr-17.0.14}" + if [ -z "$JAVA_HOME" ] || [ ! -d "$JAVA_HOME" ]; then + for candidate in \ + "$HOME/.jdks/jbr-17.0.14" \ + "/usr/lib/jvm/java-17-openjdk" \ + "/usr/lib/jvm/java-17" \ + "/usr/lib/jvm/default-java"; do + if [ -d "$candidate" ]; then + export JAVA_HOME="$candidate" + break + fi + done + fi export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" cd "$SCRIPT_DIR/android" ./gradlew assembleDebug diff --git a/scripts/linuxextend.sh b/scripts/linuxextend.sh index fab1511..074f1e9 100755 --- a/scripts/linuxextend.sh +++ b/scripts/linuxextend.sh @@ -5,11 +5,11 @@ set -eo pipefail -INSTALL_DIR="/home/neonjava/LinuxExtend" -LOG_DIR="$HOME/.local/state/linuxextend" +INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/linuxextend" LOG_FILE="$LOG_DIR/server.log" PID_FILE="$LOG_DIR/server.pid" -ADB_BIN="/home/neonjava/Android/Sdk/platform-tools/adb" +ADB_BIN="${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools/adb" [ -x "$ADB_BIN" ] || ADB_BIN="$(command -v adb 2>/dev/null || true)" mkdir -p "$LOG_DIR" @@ -44,7 +44,7 @@ get_pid() { return fi fi - pgrep -f "python3 -m linuxextend" | head -1 + pgrep -f "python3 -m linuxextend" 2>/dev/null | head -1 || true } cleanup_displays() { @@ -68,10 +68,13 @@ setup_usb() { "$ADB_BIN" reverse tcp:8080 tcp:8080 >/dev/null 2>&1 || true echo -e " ${C_GREEN}✓ USB ADB reverse active${C_RESET}" - # Check if app is installed and launch it + # Launch on tablet: native app if installed, otherwise browser if "$ADB_BIN" shell pm list packages 2>/dev/null | grep -q "com.linuxextend"; then echo -e " ${C_CYAN}Launching LinuxExtend on tablet...${C_RESET}" "$ADB_BIN" shell am start -n com.linuxextend/.MainActivity >/dev/null 2>&1 || true + elif "$ADB_BIN" shell pm list packages 2>/dev/null | grep -q "com.android.chrome"; then + echo -e " ${C_CYAN}Launching stream in Chrome on tablet...${C_RESET}" + "$ADB_BIN" shell am start -n com.android.chrome/com.google.android.apps.chrome.Main -d "http://127.0.0.1:8080" >/dev/null 2>&1 || true fi fi fi @@ -92,10 +95,17 @@ cmd_start() { echo -e "${C_BOLD}${C_BLUE}Starting LinuxExtend server...${C_RESET}" cd "$INSTALL_DIR/server" - # Start server in background - nohup python3 -m linuxextend "$@" > "$LOG_FILE" 2>&1 & - local s_pid=$! - echo "$s_pid" > "$PID_FILE" + # Start server in background (using systemd-run if available, else nohup) + if command -v systemd-run >/dev/null 2>&1; then + systemd-run --user --unit=linuxextend-server --working-directory="$INSTALL_DIR/server" python3 -u -m linuxextend "$@" >/dev/null 2>&1 + local s_pid + s_pid=$(pgrep -f "python3 -u -m linuxextend" 2>/dev/null | head -1 || true) + [ -n "$s_pid" ] && echo "$s_pid" > "$PID_FILE" + else + nohup python3 -u -m linuxextend "$@" < /dev/null > "$LOG_FILE" 2>&1 & + local s_pid=$! + echo "$s_pid" > "$PID_FILE" + fi # Wait for virtual display initialization local timeout=10 @@ -143,6 +153,11 @@ cmd_stop() { echo -e "${C_BOLD}${C_BLUE}Stopping LinuxExtend...${C_RESET}" local stopped=0 + # Stop systemd unit if running + if command -v systemctl >/dev/null 2>&1; then + systemctl --user stop linuxextend-server 2>/dev/null || true + fi + local pid pid=$(get_pid) if [ -n "$pid" ]; then diff --git a/scripts/switch_monitor.sh b/scripts/switch_monitor.sh index 2879e1c..aca8907 100755 --- a/scripts/switch_monitor.sh +++ b/scripts/switch_monitor.sh @@ -1,19 +1,46 @@ #!/usr/bin/env bash -# Switch mouse cursor and monitor focus between laptop (eDP-1) and tablet (HEADLESS-*) - -CURSOR_POS=$(hyprctl cursorpos | tr -d ' ') -CURSOR_X=$(echo "$CURSOR_POS" | cut -d',' -f1) - -# Laptop width is 1920 -if [ "${CURSOR_X:-0}" -lt 1920 ]; then - # Currently on Laptop -> Jump to Tablet (centered at 2880, 540) - hyprctl dispatch movecursor 2880 540 - HEADLESS_NAME=$(hyprctl monitors -j | jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1) - if [ -n "$HEADLESS_NAME" ]; then - hyprctl dispatch focusmonitor "$HEADLESS_NAME" - fi +# Switch mouse cursor and monitor focus between primary monitor and virtual headless monitor + +if ! command -v hyprctl >/dev/null 2>&1; then + exit 0 +fi + +FOCUSED_MON=$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.focused) | .name') +HEADLESS_MON=$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1) +PRIMARY_MON=$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select((.name | startswith("HEADLESS") | not)) | .name' | head -1) + +if [ -z "$HEADLESS_MON" ]; then + # No virtual monitor active + exit 0 +fi + +if [ "$FOCUSED_MON" = "$HEADLESS_MON" ]; then + TARGET_MON="$PRIMARY_MON" else - # Currently on Tablet -> Jump to Laptop (centered at 960, 540) - hyprctl dispatch movecursor 960 540 - hyprctl dispatch focusmonitor eDP-1 + TARGET_MON="$HEADLESS_MON" fi + +[ -z "$TARGET_MON" ] && exit 0 + +# Calculate center coordinates of target monitor +TARGET_INFO=$(hyprctl monitors -j 2>/dev/null | jq -r ".[] | select(.name == \"$TARGET_MON\")") +TARGET_X=$(echo "$TARGET_INFO" | jq -r '.x // 0') +TARGET_Y=$(echo "$TARGET_INFO" | jq -r '.y // 0') +TARGET_W=$(echo "$TARGET_INFO" | jq -r '.width // 1920') +TARGET_H=$(echo "$TARGET_INFO" | jq -r '.height // 1080') +TARGET_TRANSFORM=$(echo "$TARGET_INFO" | jq -r '.transform // 0') + +# Account for rotated orientation +if [ "$TARGET_TRANSFORM" -eq 1 ] || [ "$TARGET_TRANSFORM" -eq 3 ]; then + EFF_W=$TARGET_H + EFF_H=$TARGET_W +else + EFF_W=$TARGET_W + EFF_H=$TARGET_H +fi + +CENTER_X=$(( TARGET_X + EFF_W / 2 )) +CENTER_Y=$(( TARGET_Y + EFF_H / 2 )) + +hyprctl dispatch focusmonitor "$TARGET_MON" >/dev/null 2>&1 || true +hyprctl dispatch movecursor "$CENTER_X" "$CENTER_Y" >/dev/null 2>&1 || true diff --git a/server/linuxextend/__main__.py b/server/linuxextend/__main__.py index c2182e6..6135929 100644 --- a/server/linuxextend/__main__.py +++ b/server/linuxextend/__main__.py @@ -100,14 +100,31 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--fps", "-f", type=int, - default=25, - help="Target capture FPS (default: 25)", + default=30, + help="Target capture FPS (default: 30)", ) parser.add_argument( "--quality", "-q", type=int, - default=75, - help="JPEG quality 1-100 (default: 75)", + default=60, + help="JPEG quality 1-100 (default: 60)", + ) + parser.add_argument( + "--position", + choices=["left", "right", "auto"], + default="auto", + help="Monitor position relative to primary: left, right, or auto (default: auto)", + ) + parser.add_argument( + "--transform", "-t", + type=int, + default=0, + help="Display transform (0=normal, 1=portrait 90deg, 2=180deg, 3=270deg) (default: 0)", + ) + parser.add_argument( + "--portrait", + action="store_true", + help="Quick setup: portrait orientation on the left (transform=1, position=left)", ) parser.add_argument( "--port", "-p", @@ -140,6 +157,11 @@ def _parse_args() -> argparse.Namespace: def main() -> None: args = _parse_args() + if args.portrait: + if args.transform == 0: + args.transform = 1 + if args.position == "auto": + args.position = "left" _setup_logging(args.verbose) _print_banner() @@ -150,6 +172,8 @@ def main() -> None: config = Config( resolution=args.resolution, + position=args.position, + transform=args.transform, target_fps=args.fps, jpeg_quality=args.quality, port=args.port, @@ -196,9 +220,11 @@ def shutdown(signum: int = 0, frame: object = None) -> None: display = VirtualDisplay( resolution=config.resolution, refresh_rate=config.refresh_rate, + position=config.position, + transform=config.transform, ) display.create() - print(f" {_GREEN}✓ {display.name}{_RESET} — {config.resolution}@{config.refresh_rate}Hz") + print(f" {_GREEN}✓ {display.name}{_RESET} — {config.resolution}@{config.refresh_rate}Hz (pos: {config.position}, transform: {config.transform})") # Step 2: Start screen capture print(f"{_CYAN}📸 Starting screen capture...{_RESET}") @@ -206,6 +232,7 @@ def shutdown(signum: int = 0, frame: object = None) -> None: output_name=display.name, fps=config.target_fps, quality=config.jpeg_quality, + num_workers=config.num_workers, ) capture.start() print(f" {_GREEN}✓ Capturing at {config.target_fps} FPS, JPEG quality {config.jpeg_quality}{_RESET}") @@ -218,7 +245,7 @@ def shutdown(signum: int = 0, frame: object = None) -> None: name=config.service_name, resolution=config.resolution, ) - advertiser.start() + advertiser.start(background=True) local_ip = advertiser.local_ip print(f" {_GREEN}✓ Advertising as {config.service_name}._linuxextend._tcp.local.{_RESET}") else: diff --git a/server/linuxextend/capture.py b/server/linuxextend/capture.py index f491151..d3b9830 100644 --- a/server/linuxextend/capture.py +++ b/server/linuxextend/capture.py @@ -61,7 +61,7 @@ def _check_grim(self) -> None: def _worker_loop(self, worker_id: int) -> None: """Staggered worker loop that continuously grabs frames.""" # Stagger worker starts to space out frame captures evenly - time.sleep((worker_id * self.frame_interval) / self.num_workers) + time.sleep(worker_id * (self.frame_interval / max(1, self.num_workers))) cmd = [ "grim", @@ -72,6 +72,8 @@ def _worker_loop(self, worker_id: int) -> None: "-", ] + worker_interval = self.frame_interval * self.num_workers + while self._running: t0 = time.monotonic() try: @@ -86,7 +88,7 @@ def _worker_loop(self, worker_id: int) -> None: # Frame pacing to prevent worker overlap elapsed = time.monotonic() - t0 - sleep_time = max(0.005, self.frame_interval - elapsed) + sleep_time = max(0.005, worker_interval - elapsed) time.sleep(sleep_time) def _fps_tracker_loop(self) -> None: diff --git a/server/linuxextend/config.py b/server/linuxextend/config.py index bee3803..9200d4b 100644 --- a/server/linuxextend/config.py +++ b/server/linuxextend/config.py @@ -10,11 +10,13 @@ class Config: # Virtual display settings resolution: str = "1920x1080" refresh_rate: int = 60 - position: str = "auto" # "auto" = right of primary monitor + position: str = "auto" # "auto" (right of primary), "left", "right", or custom XxY + transform: int = 0 # 0 = normal, 1 = 90deg, 2 = 180deg, 3 = 270deg # Capture settings target_fps: int = 25 jpeg_quality: int = 75 + num_workers: int = 2 # Server settings host: str = "0.0.0.0" diff --git a/server/linuxextend/discovery.py b/server/linuxextend/discovery.py index aaa2787..0a14b85 100644 --- a/server/linuxextend/discovery.py +++ b/server/linuxextend/discovery.py @@ -51,34 +51,41 @@ def __init__( self._service_info: ServiceInfo | None = None self._local_ip = _get_local_ip() - def start(self) -> None: + def start(self, background: bool = False) -> None: """Register the mDNS service.""" - try: - hostname = socket.gethostname() - - self._service_info = ServiceInfo( - type_="_linuxextend._tcp.local.", - name=f"{self.name}._linuxextend._tcp.local.", - addresses=[socket.inet_aton(self._local_ip)], - port=self.port, - properties={ - "version": "1.0", - "resolution": self.resolution, - "hostname": hostname, - }, - server=f"{hostname}.local.", - ) - - self._zeroconf = Zeroconf() - self._zeroconf.register_service(self._service_info) - - logger.info( - "mDNS service advertised: %s._linuxextend._tcp.local. at %s:%d", - self.name, self._local_ip, self.port, - ) - except Exception as e: - logger.error("Failed to start mDNS advertising: %s", e) - self._zeroconf = None + def _do_register(): + try: + hostname = socket.gethostname() + + self._service_info = ServiceInfo( + type_="_linuxextend._tcp.local.", + name=f"{self.name}._linuxextend._tcp.local.", + addresses=[socket.inet_aton(self._local_ip)], + port=self.port, + properties={ + "version": "1.0", + "resolution": self.resolution, + "hostname": hostname, + }, + server=f"{hostname}.local.", + ) + + self._zeroconf = Zeroconf() + self._zeroconf.register_service(self._service_info) + + logger.info( + "mDNS service advertised: %s._linuxextend._tcp.local. at %s:%d", + self.name, self._local_ip, self.port, + ) + except Exception as e: + logger.error("Failed to start mDNS advertising: %s", e) + self._zeroconf = None + + if background: + import threading + threading.Thread(target=_do_register, daemon=True).start() + else: + _do_register() def stop(self) -> None: """Unregister the mDNS service and close Zeroconf.""" diff --git a/server/linuxextend/display.py b/server/linuxextend/display.py index 4fe2450..8f2ae45 100644 --- a/server/linuxextend/display.py +++ b/server/linuxextend/display.py @@ -33,12 +33,15 @@ def __init__( resolution: str = "1920x1080", refresh_rate: int = 60, position: str = "auto", + transform: int = 0, ): self.resolution = resolution self.refresh_rate = refresh_rate self.position = position + self.transform = transform self.name: str | None = None self._created = False + self._original_primary_rule: str | None = None def __enter__(self) -> "VirtualDisplay": self.create() @@ -119,10 +122,10 @@ def create(self) -> str: # Calculate target position in advance to prevent initial 0x0 overlap pos_str = self._calc_position() - self._run_hyprctl( - "keyword", "monitor", - f"HEADLESS-*,{self.resolution}@{self.refresh_rate},{pos_str},1" - ) + init_rule = f"HEADLESS-*,{self.resolution}@{self.refresh_rate},{pos_str},1" + if self.transform != 0: + init_rule += f",transform,{self.transform}" + self._run_hyprctl("keyword", "monitor", init_rule) # Create the headless output result = self._run_hyprctl("output", "create", "headless") @@ -175,15 +178,28 @@ def _sync_wallpaper(self) -> None: def _calc_position(self) -> str: """Calculate the target monitor position string.""" - if self.position == "auto": - primary = self._get_primary_monitor() + primary = self._get_primary_monitor() + p_name = primary.get("name", "eDP-1") if primary else "eDP-1" + p_w = primary.get("width", 1920) if primary else 1920 + p_h = primary.get("height", 1080) if primary else 1080 + p_r = int(primary.get("refreshRate", 60)) if primary else 60 + p_x = primary.get("x", 0) if primary else 0 + p_y = primary.get("y", 0) if primary else 0 + p_scale = primary.get("scale", 1.0) if primary else 1.0 + + if primary and self._original_primary_rule is None: + self._original_primary_rule = f"{p_name},{p_w}x{p_h}@{p_r},{p_x}x{p_y},{p_scale}" + + if self.position == "left": + w, h = map(int, self.resolution.split("x")) + eff_w = h if self.transform in (1, 3) else w + if primary: + # Shift primary monitor right to keep all coords non-negative (supports layer-shell like swww/waybar) + self._run_hyprctl("keyword", "monitor", f"{p_name},{p_w}x{p_h}@{p_r},{eff_w}x0,{p_scale}") + return "0x0" + elif self.position == "auto" or self.position == "right": if primary: - # Lock primary monitor at 0x0 - p_name = primary.get("name", "eDP-1") - p_w = primary.get("width", 1920) - p_h = primary.get("height", 1080) - p_r = int(primary.get("refreshRate", 144)) - self._run_hyprctl("keyword", "monitor", f"{p_name},{p_w}x{p_h}@{p_r},0x0,1") + self._run_hyprctl("keyword", "monitor", f"{p_name},{p_w}x{p_h}@{p_r},0x0,{p_scale}") return f"{p_w}x0" return "1920x0" return self.position @@ -202,14 +218,16 @@ def _configure(self) -> None: f"{position_str}," f"1" ) + if self.transform != 0: + monitor_rule += f",transform,{self.transform}" result = self._run_hyprctl("keyword", "monitor", monitor_rule) if "ok" not in result.lower() and "error" in result.lower(): logger.warning("Monitor configuration warning: %s", result) else: logger.info( - "Configured %s: %s@%dHz at %s", - self.name, self.resolution, self.refresh_rate, position_str, + "Configured %s: %s@%dHz at %s (transform %d)", + self.name, self.resolution, self.refresh_rate, position_str, self.transform, ) def remove(self) -> None: @@ -218,14 +236,26 @@ def remove(self) -> None: return try: + if self._original_primary_rule: + self._run_hyprctl("keyword", "monitor", self._original_primary_rule) + elif self.position == "left": + primary = self._get_primary_monitor() + if primary: + p_name = primary.get("name", "eDP-1") + p_w = primary.get("width", 1920) + p_h = primary.get("height", 1080) + p_r = int(primary.get("refreshRate", 60)) + p_scale = primary.get("scale", 1.0) + self._run_hyprctl("keyword", "monitor", f"{p_name},{p_w}x{p_h}@{p_r},0x0,{p_scale}") result = self._run_hyprctl("output", "remove", self.name) if "ok" in result.lower(): logger.info("Removed headless output: %s", self.name) else: logger.warning("Unexpected result removing %s: %s", self.name, result) - except VirtualDisplayError as e: + except Exception as e: logger.error("Failed to remove display %s: %s", self.name, e) finally: + self.name = None self._created = False def get_info(self) -> dict: diff --git a/server/linuxextend/server.py b/server/linuxextend/server.py index 9ffb11a..ba982f6 100644 --- a/server/linuxextend/server.py +++ b/server/linuxextend/server.py @@ -93,25 +93,29 @@ } .dot.connected { background: #4ecdc4; box-shadow: 0 0 6px #4ecdc4; } .canvas-container { + position: fixed; + top: 0; + left: 0; width: 100vw; height: 100vh; + margin: 0; + padding: 0; display: flex; align-items: center; justify-content: center; background: #000; - contain: strict; overflow: hidden; } canvas { - width: 100%; - height: 100%; - object-fit: contain; + position: absolute; + top: 0; + left: 0; + width: 100vw !important; + height: 100vh !important; + object-fit: fill !important; background: #000; image-rendering: -webkit-optimize-contrast; - image-rendering: pixelated; transform: translateZ(0); - backface-visibility: hidden; - will-change: transform; } .overlay { position: absolute; @@ -130,8 +134,11 @@