feat: Android TV app embedding Python server via Chaquopy
Lint & Test / test (push) Successful in 2m10s
Lint & Test / test (push) Successful in 2m10s
Adds a native Android TV application that runs the full LedGrab Python server in-process via Chaquopy. Captures the TV box screen using the MediaProjection API and exposes the existing web UI on the device's local network — users configure via phone/tablet browser. Android (new /android/ module): - Kotlin shell: MainActivity, CaptureService (foreground service), ScreenCapture (MediaProjection + ImageReader), PythonBridge (Chaquopy). - Polished Leanback-themed UI with QR code for easy web UI access. - AGP 8.9 + Chaquopy 17 + Gradle 8.11 (avoids the AGP 8.7 thread-lock bug). - Pre-built pydantic-core wheels for arm64-v8a, x86_64, x86 cross-compiled with maturin + Android NDK, linked against Chaquopy's libpython3.11.so. Python server platform guards: - New utils/platform.py with is_android()/is_windows()/is_linux() helpers. - Guard every top-level import of desktop-only packages (mss, psutil, sounddevice, pyserial, PyAudioWPatch, etc.) with try/except ImportError. - Android-incompatible calls gated with None-checks so the server runs on reduced capabilities on Android (no CPU/RAM metrics, no mss displays). - utils/image_codec.py gains a Pillow fallback for resize + JPEG encode when cv2 is unavailable; all internal cv2.resize callers migrated. - New android_entry.py start_server/stop_server invoked from Kotlin. - get_displays API falls back to best available engine when mss fails. New capture engines: - MediaProjectionEngine: receives RGBA frames pushed from Kotlin through a thread-safe queue; caches last frame for static-screen previews. - ScrcpyClientEngine: optional H.264 streaming via scrcpy-client library (priority 10, overrides the ADB-screencap engine when installed). Frontend: - Tab loaders previously required an apiKey; now correctly treat "auth disabled" as authenticated (Android has no auth by default). - Re-trigger the active tab's loader after loadServerInfo resolves authRequired, since initTabs runs earlier. - Add i18n keys for the demo / mediaprojection / scrcpy_client engines. Docs: - TODO.md: follow-ups for multi-ABI wheel rebuilds, CI pipeline, USB serial LED controllers, root-only capture, perf metrics abstraction. - CLAUDE.md: Android dependency sync policy (pip --exclude doesn't exist). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,10 @@ parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
# …but keep pre-built Android wheels (pydantic-core cross-compiled for
|
||||
# arm64-v8a / x86_64 / x86, required by the Chaquopy build)
|
||||
!android/wheels/
|
||||
!android/wheels/*
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
@@ -28,8 +28,21 @@ ast-index changed --base master # Show symbols changed in current bran
|
||||
## Project Structure
|
||||
|
||||
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
|
||||
- `/android` — Android TV app (Kotlin shell + embedded Python via Chaquopy)
|
||||
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
|
||||
|
||||
## Android Dependency Sync (CRITICAL)
|
||||
|
||||
The Android app (`android/app/build.gradle.kts`) installs the server package with `--no-deps` and lists Android-compatible dependencies **explicitly** in the Chaquopy `pip {}` block. This is because `server/pyproject.toml` includes desktop-only packages (mss, psutil, sounddevice, etc.) that have no Android wheels.
|
||||
|
||||
**When adding a new dependency to `server/pyproject.toml`:**
|
||||
|
||||
1. If the package is **pure Python or has Chaquopy wheels** (check [Chaquopy PyPI](https://chaquo.com/pypi-13.1/)), also add it to `android/app/build.gradle.kts` in the `pip { install(...) }` block
|
||||
2. If the package is **desktop-only** (native C/Rust extension without Android support), do NOT add it to `build.gradle.kts` — and guard its import with `try/except ImportError` in Python code
|
||||
3. If unsure, check Chaquopy's package index first
|
||||
|
||||
**Incident context:** Chaquopy's pip runs on the build machine (Windows), not on Android. Platform markers like `sys_platform != 'linux'` evaluate against the BUILD host, not the target device. `pip install --exclude` does not exist. The only reliable way to exclude packages is to not list them.
|
||||
|
||||
## Context Files
|
||||
|
||||
| File | When to read |
|
||||
|
||||
@@ -1,34 +1,97 @@
|
||||
# Composite Nesting Support
|
||||
# LedGrab TODO
|
||||
|
||||
## Phase 1: Store — Cycle & Depth Validation
|
||||
## Android — Restore Multi-ABI Wheels
|
||||
|
||||
- [x] Add `get_transitive_dependencies()` to ColorStripStore
|
||||
- [x] Add `validate_nesting()` with cycle detection + depth limit (MAX_DEPTH=4)
|
||||
During emulator testing, we switched the build to **x86 only** (see `android/app/build.gradle.kts` `abiFilters`) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
|
||||
|
||||
## Phase 2: API — Validation in Create/Update
|
||||
- [ ] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings:
|
||||
- `arm64-v8a` (real TV boxes — the primary target)
|
||||
- `x86_64` (modern emulators)
|
||||
- `x86` (legacy emulators)
|
||||
- [ ] Verify wheels with `readelf -d`: SONAME must be `libpython3.11.so` in NEEDED
|
||||
- [ ] Restore `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts`
|
||||
- [ ] Re-test on real ARM64 Android TV hardware
|
||||
|
||||
- [x] Call `validate_nesting()` in create handler for composite sources
|
||||
- [x] Call `validate_nesting()` in update handler for composite sources
|
||||
Build cache + scripts live in `android/build-scripts/` and `android/.build-cache/` (junction host + sysconfigdata for each ABI).
|
||||
|
||||
## Phase 3: Runtime — Depth Guard in Stream
|
||||
## Android CI Pipeline
|
||||
|
||||
- [x] Add `depth` parameter to CompositeColorStripStream
|
||||
- [x] Pass depth through ColorStripStreamManager.acquire()
|
||||
- [x] Cap depth at runtime to prevent runaway nesting
|
||||
Build the Android APK automatically on push/tag.
|
||||
|
||||
## Phase 4: Frontend — Allow Composites in Layer Dropdown
|
||||
- [ ] Generate Gradle wrapper (`gradlew`) and commit it
|
||||
- [ ] Create CI workflow (`.gitea/workflows/build-android.yaml` or `.github/workflows/`)
|
||||
- JDK 17 + Android SDK + NDK setup
|
||||
- Python 3.11 for Chaquopy build
|
||||
- Recreate the directory junction (`ln -s` on Linux, `mklink /J` on Windows)
|
||||
- `./gradlew assembleRelease`
|
||||
- Upload APK as artifact
|
||||
- [ ] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64)
|
||||
- [ ] APK signing for release builds (keystore setup)
|
||||
- [ ] Consider: publish APK to GitHub/Gitea releases on tag push
|
||||
|
||||
- [x] Remove `source_type !== 'composite'` filter (keep self-exclusion)
|
||||
- [x] Update docstring in composite_stream.py
|
||||
## Android Root Capture (No Permission Dialog, No System Indicator)
|
||||
|
||||
## Phase 5: Tests
|
||||
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path would give much better UX.
|
||||
|
||||
- [x] Cycle detection tests (A→B→A, self-reference)
|
||||
- [x] Depth limit tests (chain exceeding MAX_DEPTH)
|
||||
- [x] Valid nesting tests (A→B both composite, no cycle)
|
||||
- [ ] Detect root at runtime: check for `su` binary, `Superuser.apk`, etc.
|
||||
- [ ] Implement `SurfaceControlCaptureEngine` (new capture engine) using hidden `SurfaceControl.screenshot()` API via reflection
|
||||
- No permission dialog
|
||||
- No system capture indicator
|
||||
- Direct bitmap output (no encoder/decoder roundtrip)
|
||||
- [ ] Engine priority: higher than MediaProjection when root detected
|
||||
- [ ] Fallback chain: `SurfaceControl` (root) → `MediaProjection` (stock) → `adb screencap` (last resort)
|
||||
- [ ] Handle Android version differences in `SurfaceControl` API surface (renamed/moved across API 29, 30, 33)
|
||||
- [ ] Alternative: shell out to `screenrecord --output-format=h264 -` as root (same H.264 decode as scrcpy_client_engine, but local instead of remote ADB)
|
||||
|
||||
## Phase 6: Lint & Build
|
||||
Known projects using this approach for reference: scrcpy-hidden-api, shizuku, commercial scrcpy-derived apps.
|
||||
|
||||
- [x] Ruff check passes
|
||||
- [x] TypeScript build passes
|
||||
- [x] All existing tests pass (715/715)
|
||||
## Android USB Serial Support
|
||||
|
||||
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
|
||||
|
||||
- [ ] Add [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) dependency to `android/app/build.gradle.kts`
|
||||
- [ ] Create Kotlin `UsbSerialBridge` class that:
|
||||
- Enumerates USB serial devices via Android USB Host API
|
||||
- Requests user permission for USB device access
|
||||
- Opens a serial connection (baud rate configurable)
|
||||
- Exposes a write method callable from Python via Chaquopy
|
||||
- [ ] Create Python `AndroidSerialProvider` in `server/src/ledgrab/core/devices/` that:
|
||||
- Replaces `pyserial` on Android (which can't access USB ports)
|
||||
- Calls `UsbSerialBridge` via Chaquopy to send LED data
|
||||
- Registers as an alternative serial transport when `is_android()` is True
|
||||
- [ ] Add USB device permission dialog to `MainActivity` (auto-triggered on device connect)
|
||||
- [ ] Test with common USB-to-serial chips: CH340, CP2102, FTDI
|
||||
- [ ] Document supported USB LED controllers in README
|
||||
|
||||
## Android App — Known Issues
|
||||
|
||||
Issues discovered during first end-to-end test on emulator (2026-04-14):
|
||||
|
||||
- [ ] **Stop/Start capture produces no frames on second try.** First capture works, but after tapping Stop and Start again, no frames reach the Python pipeline. Likely causes:
|
||||
- Global MediaProjection engine state (`_active`, `_frame_queue`) not reset on service restart
|
||||
- ScreenCapture listener not properly detached from ImageReader on stop
|
||||
- Python uvicorn port reuse issue when server restarts
|
||||
- [ ] **Web UI tabs show persistent spinners** on Dashboard, Automation, Sources, Integrations, Graph tabs (Targets tab works). All API calls return 200 OK, no console errors. Probably waiting on a specific WebSocket event that never fires when no output targets are configured/active.
|
||||
- [ ] **Dead keyboard/IME handling** — password inputs lack autocomplete attributes (minor accessibility warning in browser console)
|
||||
|
||||
## Performance Metrics Abstraction
|
||||
|
||||
The codebase has direct `psutil.*` calls scattered across `api/routes/system.py` and `core/processing/metrics_history.py`, with ad-hoc `if psutil is not None` guards sprinkled in to support Android. This couples Android platform handling to every call site.
|
||||
|
||||
- [ ] Refactor: introduce `MetricsProvider` protocol in `utils/metrics.py` with methods like `cpu_percent()`, `memory_info()`, `process_info()`
|
||||
- [ ] Implement `PsutilMetricsProvider` (desktop) and `NullMetricsProvider` (fallback when psutil missing)
|
||||
- [ ] Later: `AndroidMetricsProvider` reading from `/proc` (see section below)
|
||||
- [ ] Replace all direct `psutil.*` calls with the provider; only one factory location knows about psutil availability
|
||||
|
||||
## Android Performance Metrics
|
||||
|
||||
Currently `psutil` (used for CPU/RAM monitoring in the web UI) is not available on Android via Chaquopy. Metrics calls are guarded with `if psutil is not None` so they return no data on Android.
|
||||
|
||||
- [ ] Implement Android-native metrics collection:
|
||||
- CPU usage via `/proc/self/stat` + `/proc/stat` parsing (no psutil needed)
|
||||
- RAM usage via `/proc/meminfo` or `ActivityManager.getMemoryInfo()` through Chaquopy bridge
|
||||
- App-specific memory via `Debug.getMemoryInfo()` (Kotlin → Python)
|
||||
- [ ] Create `AndroidMetricsProvider` in `server/src/ledgrab/utils/` that implements the same interface as the psutil-based provider
|
||||
- [ ] Wire into existing metrics endpoints (`/api/v1/system/metrics`) with platform detection
|
||||
- [ ] Consider: device battery/temperature readings for TV boxes (some have thermal throttling)
|
||||
- [ ] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/app/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# Chaquopy build cache
|
||||
.build-cache/
|
||||
|
||||
# Python source junction (points at ../server/src/ledgrab — do not commit)
|
||||
/app/src/main/python/ledgrab
|
||||
@@ -0,0 +1,92 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.chaquo.python")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ledgrab.android"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ledgrab.android"
|
||||
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.3.0"
|
||||
|
||||
ndk {
|
||||
// Temporarily x86 only for emulator testing with new wheel
|
||||
abiFilters += listOf("x86")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
chaquopy {
|
||||
defaultConfig {
|
||||
version = "3.11"
|
||||
|
||||
pip {
|
||||
// Pre-built wheels directory (for pydantic-core etc.)
|
||||
// Must use absolute file:// URI with forward slashes
|
||||
options("--find-links", "file:///${rootDir.absolutePath.replace("\\", "/")}/wheels/")
|
||||
|
||||
// ── Android-compatible dependencies ─────────────────
|
||||
// Listed explicitly because pyproject.toml includes
|
||||
// desktop-only packages with no Android wheels.
|
||||
// See CLAUDE.md "Android Dependency Sync" for policy.
|
||||
install("fastapi")
|
||||
install("uvicorn") // without [standard] — no uvloop/httptools
|
||||
install("httpx")
|
||||
install("numpy")
|
||||
install("pydantic") // needs pydantic-core wheel in wheels/
|
||||
install("pydantic-settings")
|
||||
install("PyYAML")
|
||||
install("structlog")
|
||||
install("python-json-logger")
|
||||
install("python-dateutil")
|
||||
install("python-multipart")
|
||||
install("jinja2")
|
||||
install("zeroconf")
|
||||
install("aiomqtt")
|
||||
install("openrgb-python")
|
||||
// opencv-python-headless: no cp311 Android wheel on Chaquopy.
|
||||
// LedGrab's cv2 usage is guarded with try/except ImportError
|
||||
// and falls back to numpy/Pillow alternatives on Android.
|
||||
install("Pillow")
|
||||
install("websockets")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LedGrab Python source is included via a directory junction:
|
||||
// android/app/src/main/python/ledgrab -> server/src/ledgrab
|
||||
// This is the standard Chaquopy way to include local Python packages.
|
||||
// Create the junction (run from repo root, no admin needed):
|
||||
// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||
// QR code generation for displaying server URL on TV
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- MediaProjection requires a foreground service -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
|
||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Android TV declarations -->
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".LedGrabApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/ic_launcher"
|
||||
android:theme="@style/Theme.LedGrab">
|
||||
|
||||
<!-- TV launcher activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service for screen capture + Python server -->
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground service that runs the Python LedGrab server and captures
|
||||
* the screen via MediaProjection.
|
||||
*/
|
||||
class CaptureService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CaptureService"
|
||||
private const val CHANNEL_ID = "ledgrab_capture"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val EXTRA_RESULT_CODE = "result_code"
|
||||
private const val EXTRA_RESULT_DATA = "result_data"
|
||||
private const val SERVER_PORT = 8080
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
resultCode: Int,
|
||||
resultData: Intent,
|
||||
): Intent {
|
||||
return Intent(context, CaptureService::class.java).apply {
|
||||
putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||
putExtra(EXTRA_RESULT_DATA, resultData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bridge: PythonBridge? = null
|
||||
private var screenCapture: ScreenCapture? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// CRITICAL: startForeground must be called IMMEDIATELY —
|
||||
// before any other work, especially before getMediaProjection().
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
startForeground(NOTIFICATION_ID, buildNotification(url))
|
||||
|
||||
if (intent == null) {
|
||||
Log.w(TAG, "Service restarted without intent — stopping")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
@Suppress("DEPRECATION")
|
||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||
}
|
||||
|
||||
if (resultData == null) {
|
||||
Log.e(TAG, "No MediaProjection result data")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
try {
|
||||
// Create MediaProjection from the consent token
|
||||
val projectionManager =
|
||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
mediaProjection = projectionManager.getMediaProjection(resultCode, resultData)
|
||||
|
||||
if (mediaProjection == null) {
|
||||
Log.e(TAG, "Failed to create MediaProjection")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Get screen metrics
|
||||
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val metrics = DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(metrics)
|
||||
|
||||
// Initialize Python bridge and configure capture engine
|
||||
bridge = PythonBridge(this).also { b ->
|
||||
b.configureCapture(480, 270) // Downscaled for LED use
|
||||
b.startServer(SERVER_PORT)
|
||||
}
|
||||
|
||||
// Start screen capture
|
||||
screenCapture = ScreenCapture(
|
||||
projection = mediaProjection!!,
|
||||
metrics = metrics,
|
||||
bridge = bridge!!,
|
||||
targetWidth = 480,
|
||||
targetHeight = 270,
|
||||
targetFps = 30,
|
||||
).also { it.start() }
|
||||
|
||||
Log.i(TAG, "LedGrab service started — web UI at $url")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start capture", e)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
screenCapture?.stop()
|
||||
screenCapture = null
|
||||
|
||||
bridge?.stopServer()
|
||||
bridge = null
|
||||
|
||||
mediaProjection?.stop()
|
||||
mediaProjection = null
|
||||
|
||||
Log.i(TAG, "LedGrab service destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"LedGrab Screen Capture",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Shows while LedGrab is capturing the screen"
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(url: String): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LedGrab Running")
|
||||
.setContentText("Web UI: $url")
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Application
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
|
||||
/**
|
||||
* Application class — initializes the Chaquopy Python runtime.
|
||||
*
|
||||
* `Python.start()` must be called once before any Python code runs.
|
||||
* It loads libpython, extracts stdlib + pip packages from APK assets
|
||||
* (first launch only), and sets up `sys.path`.
|
||||
*/
|
||||
class LedGrabApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
|
||||
/**
|
||||
* Main (and only) Activity for the Android TV app.
|
||||
*
|
||||
* Two-state UI: stopped (Start button) and running (URL + QR + Stop).
|
||||
* Navigable with D-pad / USB controller.
|
||||
*/
|
||||
class MainActivity : Activity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
}
|
||||
|
||||
private lateinit var stoppedPanel: View
|
||||
private lateinit var runningPanel: View
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var urlText: TextView
|
||||
private lateinit var qrImage: ImageView
|
||||
private lateinit var toggleButton: Button
|
||||
private lateinit var stopButtonRunning: Button
|
||||
private lateinit var versionText: TextView
|
||||
private var serviceRunning = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
stoppedPanel = findViewById(R.id.stopped_panel)
|
||||
runningPanel = findViewById(R.id.running_panel)
|
||||
statusText = findViewById(R.id.status_text)
|
||||
urlText = findViewById(R.id.url_text)
|
||||
qrImage = findViewById(R.id.qr_image)
|
||||
toggleButton = findViewById(R.id.toggle_button)
|
||||
stopButtonRunning = findViewById(R.id.stop_button_running)
|
||||
versionText = findViewById(R.id.version_text)
|
||||
|
||||
val versionName = packageManager
|
||||
.getPackageInfo(packageName, 0).versionName
|
||||
versionText.text = "v$versionName"
|
||||
|
||||
toggleButton.setOnClickListener { requestMediaProjection() }
|
||||
stopButtonRunning.setOnClickListener { stopCaptureService() }
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun requestMediaProjection() {
|
||||
val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
|
||||
}
|
||||
|
||||
@Deprecated("Using deprecated API for plain Activity compatibility")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_MEDIA_PROJECTION) {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
startCaptureService(resultCode, data)
|
||||
} else {
|
||||
statusText.text = "Permission denied — screen capture requires authorization"
|
||||
Log.w(TAG, "MediaProjection permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
startForegroundService(intent)
|
||||
serviceRunning = true
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun stopCaptureService() {
|
||||
stopService(Intent(this, CaptureService::class.java))
|
||||
serviceRunning = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
if (serviceRunning) {
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
|
||||
urlText.text = url
|
||||
qrImage.setImageBitmap(generateQrCode(url))
|
||||
|
||||
stoppedPanel.visibility = View.GONE
|
||||
versionText.visibility = View.GONE
|
||||
runningPanel.visibility = View.VISIBLE
|
||||
stopButtonRunning.requestFocus()
|
||||
} else {
|
||||
urlText.text = ""
|
||||
qrImage.setImageBitmap(null)
|
||||
|
||||
runningPanel.visibility = View.GONE
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
toggleButton.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrCode(text: String): Bitmap {
|
||||
val size = 560
|
||||
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
|
||||
for (x in 0 until size) {
|
||||
for (y in 0 until size) {
|
||||
bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt())
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
* Network utilities for discovering the device's local IP address.
|
||||
*/
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Return the device's local IPv4 address on the active network,
|
||||
* or `null` if unavailable.
|
||||
*/
|
||||
fun getLocalIpAddress(context: Context): String? {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return null
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
|
||||
return props.linkAddresses
|
||||
.map { it.address }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
?.hostAddress
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.chaquo.python.Python
|
||||
|
||||
/**
|
||||
* Bridge between Kotlin and the LedGrab Python server.
|
||||
*
|
||||
* All Python calls go through Chaquopy's `Python.getInstance()`.
|
||||
* Frame data crosses the JNI boundary as a `ByteArray`.
|
||||
*/
|
||||
class PythonBridge(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PythonBridge"
|
||||
}
|
||||
|
||||
private var serverThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
/**
|
||||
* Configure the MediaProjection engine with screen dimensions.
|
||||
* Must be called before [startServer].
|
||||
*/
|
||||
fun configureCapture(width: Int, height: Int) {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
|
||||
engine.callAttr("configure", width, height)
|
||||
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the LedGrab FastAPI server on a background thread.
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own thread.
|
||||
*/
|
||||
fun startServer(port: Int = 8080) {
|
||||
if (running) {
|
||||
Log.w(TAG, "Server already running")
|
||||
return
|
||||
}
|
||||
|
||||
running = true
|
||||
val dataDir = context.filesDir.absolutePath
|
||||
|
||||
serverThread = Thread({
|
||||
try {
|
||||
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
entry.callAttr("start_server", dataDir, port)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Python server error", e)
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}, "ledgrab-python-server")
|
||||
serverThread?.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the Python server to shut down gracefully.
|
||||
*/
|
||||
fun stopServer() {
|
||||
if (!running) return
|
||||
|
||||
try {
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
entry.callAttr("stop_server")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping server", e)
|
||||
}
|
||||
|
||||
serverThread?.join(10_000)
|
||||
serverThread = null
|
||||
running = false
|
||||
Log.i(TAG, "Server stopped")
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a captured RGBA frame to the Python MediaProjection engine.
|
||||
*
|
||||
* Called from [ScreenCapture] on the capture thread. The byte array
|
||||
* crosses the JNI boundary — keep frames small (downscale to 480p
|
||||
* before calling).
|
||||
*/
|
||||
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||
if (!running) return
|
||||
|
||||
try {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
|
||||
engine.callAttr("push_frame", rgbaBytes, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to push frame: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val isRunning: Boolean get() = running
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Captures the Android screen via MediaProjection and feeds frames
|
||||
* to [PythonBridge].
|
||||
*
|
||||
* Frames are downscaled to [targetWidth] x [targetHeight] before
|
||||
* crossing the JNI boundary to minimize overhead. For LED ambient
|
||||
* lighting, even 480x270 contains far more data than needed.
|
||||
*/
|
||||
class ScreenCapture(
|
||||
private val projection: MediaProjection,
|
||||
private val metrics: DisplayMetrics,
|
||||
private val bridge: PythonBridge,
|
||||
private val targetWidth: Int = 480,
|
||||
private val targetHeight: Int = 270,
|
||||
private val targetFps: Int = 30,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ScreenCapture"
|
||||
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
|
||||
}
|
||||
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var captureThread: HandlerThread? = null
|
||||
private var captureHandler: Handler? = null
|
||||
@Volatile private var running = false
|
||||
private var lastFrameTimeMs = 0L
|
||||
private val frameIntervalMs = 1000L / targetFps
|
||||
|
||||
/**
|
||||
* Start capturing the screen.
|
||||
*/
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
|
||||
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
|
||||
captureHandler = Handler(captureThread!!.looper)
|
||||
|
||||
// Android 14+ requires registering a callback before createVirtualDisplay
|
||||
projection.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.i(TAG, "MediaProjection stopped")
|
||||
stop()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
imageReader = ImageReader.newInstance(
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
2, // maxImages — double buffer
|
||||
)
|
||||
|
||||
imageReader?.setOnImageAvailableListener({ reader ->
|
||||
if (!running) return@setOnImageAvailableListener
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastFrameTimeMs < frameIntervalMs) {
|
||||
// Skip frame to maintain target FPS
|
||||
reader.acquireLatestImage()?.close()
|
||||
return@setOnImageAvailableListener
|
||||
}
|
||||
|
||||
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
|
||||
// Handle row padding: rowStride may be > width * pixelStride
|
||||
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
|
||||
// No padding — direct copy
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
bytes
|
||||
} else {
|
||||
// Strip row padding
|
||||
val rowBytes = targetWidth * pixelStride
|
||||
val bytes = ByteArray(targetWidth * targetHeight * 4)
|
||||
for (row in 0 until targetHeight) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(bytes, row * rowBytes, rowBytes)
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
|
||||
lastFrameTimeMs = now
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame processing error: ${e.message}")
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
metrics.densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader?.surface,
|
||||
null,
|
||||
captureHandler,
|
||||
)
|
||||
|
||||
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop capturing and release all resources.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
captureThread?.quitSafely()
|
||||
captureThread = null
|
||||
captureHandler = null
|
||||
Log.i(TAG, "Screen capture stopped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true" android:color="#ffffff" />
|
||||
<item android:color="@color/purple_accent" />
|
||||
</selector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<layer-list>
|
||||
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#4064ffda" />
|
||||
<corners android:radius="44dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#64ffda" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#3dccb0" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/teal_accent" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<layer-list>
|
||||
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#40bb86fc" />
|
||||
<corners android:radius="44dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#7c4dff" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#9966d4" />
|
||||
<corners android:radius="36dp" />
|
||||
<stroke android:width="2dp" android:color="@color/purple_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#1Abb86fc" />
|
||||
<corners android:radius="36dp" />
|
||||
<stroke android:width="2dp" android:color="@color/purple_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<color android:color="@color/bg_navy" />
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="900dp"
|
||||
android:centerX="0.5"
|
||||
android:centerY="-0.2"
|
||||
android:startColor="#1A64ffda"
|
||||
android:endColor="#000d1117" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="600dp"
|
||||
android:centerX="1.1"
|
||||
android:centerY="1.2"
|
||||
android:startColor="#12bb86fc"
|
||||
android:endColor="#000d1117" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:left="-8dp" android:top="-8dp" android:right="-8dp" android:bottom="-8dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#2064ffda" />
|
||||
<corners android:radius="24dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#ffffff" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="3dp" android:color="@color/teal_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/green_status" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_surface_elevated" />
|
||||
<corners android:radius="12dp" />
|
||||
<stroke android:width="1dp" android:color="#2264ffda" />
|
||||
</shape>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Background circle -->
|
||||
<path
|
||||
android:fillColor="#0d1117"
|
||||
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
|
||||
<!-- Border ring -->
|
||||
<path
|
||||
android:strokeColor="#2264ffda"
|
||||
android:strokeWidth="1.5"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54,54m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0" />
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow - top (teal) -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<!-- LED glow - left (purple) -->
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<!-- LED glow - right (red) -->
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<!-- LED glow - bottom (yellow) -->
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_main">
|
||||
|
||||
<!-- STOPPED STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stopped_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingStart="160dp"
|
||||
android:paddingEnd="160dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:src="@drawable/ic_launcher"
|
||||
android:contentDescription="@null"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="64sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:fontFamily="sans-serif-light" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tagline"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginBottom="64dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggle_button"
|
||||
style="@style/Widget.LedGrab.Button.Primary"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="72dp"
|
||||
android:text="@string/btn_start"
|
||||
android:textSize="22sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Version at bottom -->
|
||||
<TextView
|
||||
android:id="@+id/version_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:textColor="@color/text_hint"
|
||||
android:textSize="18sp"
|
||||
tools:text="v0.1.0" />
|
||||
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/running_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="120dp"
|
||||
android:paddingEnd="120dp"
|
||||
android:paddingTop="80dp"
|
||||
android:paddingBottom="80dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Left: status + URL + stop -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingEnd="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<View
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:background="@drawable/bg_status_dot"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status_running"
|
||||
android:textColor="@color/green_status"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.05" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_web_ui"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/url_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="30sp"
|
||||
android:maxLines="1"
|
||||
android:textStyle="bold"
|
||||
android:background="@drawable/bg_url_chip"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
tools:text="http://192.168.1.5:8080" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/stop_button_running"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/btn_stop"
|
||||
android:textSize="20sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_qr_container"
|
||||
android:padding="20dp"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_image"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:contentDescription="@string/qr_description"
|
||||
android:scaleType="fitXY" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_to_configure"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="bg_navy">#0d1117</color>
|
||||
<color name="bg_navy_mid">#111827</color>
|
||||
<color name="bg_surface">#161b22</color>
|
||||
<color name="bg_surface_elevated">#1c2333</color>
|
||||
<color name="teal_accent">#64ffda</color>
|
||||
<color name="teal_accent_dim">#3399aa</color>
|
||||
<color name="teal_accent_alpha30">#4D64ffda</color>
|
||||
<color name="teal_accent_alpha15">#2664ffda</color>
|
||||
<color name="purple_accent">#bb86fc</color>
|
||||
<color name="purple_accent_dim">#7c4dff</color>
|
||||
<color name="purple_accent_alpha30">#4Dbb86fc</color>
|
||||
<color name="purple_accent_alpha15">#26bb86fc</color>
|
||||
<color name="green_status">#4caf50</color>
|
||||
<color name="green_status_dim">#1b5e20</color>
|
||||
<color name="text_primary">#e6edf3</color>
|
||||
<color name="text_secondary">#8b949e</color>
|
||||
<color name="text_hint">#484f58</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Ambient lighting for your TV</string>
|
||||
<string name="btn_start">Start Capture</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="label_web_ui">Web UI address</string>
|
||||
<string name="scan_to_configure">Scan to configure</string>
|
||||
<string name="qr_description">QR code for web UI</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.LedGrab" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowBackground">@color/bg_navy</item>
|
||||
<item name="android:colorBackground">@color/bg_navy</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:textColorPrimary">@color/text_primary</item>
|
||||
<item name="android:textColorSecondary">@color/text_secondary</item>
|
||||
<item name="android:textColorHint">@color/text_hint</item>
|
||||
<item name="android:colorAccent">@color/teal_accent</item>
|
||||
<item name="android:colorControlHighlight">@color/teal_accent_dim</item>
|
||||
<item name="android:colorControlActivated">@color/teal_accent</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_primary</item>
|
||||
<item name="android:textColor">@color/bg_navy</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:stateListAnimator">@null</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Secondary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_secondary</item>
|
||||
<item name="android:textColor">@color/btn_secondary_text</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:stateListAnimator">@null</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-compile pydantic-core for Android ARM64.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust toolchain (rustup)
|
||||
# - Android NDK (set ANDROID_NDK_HOME or let this script find it)
|
||||
# - maturin (pip install maturin)
|
||||
# - Python 3.11 (matching Chaquopy's embedded version)
|
||||
#
|
||||
# Output: ../wheels/pydantic_core-*.whl
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WHEELS_DIR="$SCRIPT_DIR/../wheels"
|
||||
BUILD_DIR="$SCRIPT_DIR/../.build-cache"
|
||||
|
||||
# ── Pydantic-core version (must match pydantic>=2.9.2 requirement) ──
|
||||
PYDANTIC_CORE_VERSION="2.27.2"
|
||||
|
||||
# ── Find Android NDK ────────────────────────────────────────────────
|
||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||
# Try common locations
|
||||
for candidate in \
|
||||
"$HOME/Library/Android/sdk/ndk"/* \
|
||||
"$HOME/Android/Sdk/ndk"/* \
|
||||
"$LOCALAPPDATA/Android/Sdk/ndk"/* \
|
||||
"/usr/local/lib/android/sdk/ndk"/*; do
|
||||
if [ -d "$candidate" ]; then
|
||||
ANDROID_NDK_HOME="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||
echo "ERROR: Android NDK not found. Set ANDROID_NDK_HOME or install via Android Studio."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using Android NDK: $ANDROID_NDK_HOME"
|
||||
|
||||
# ── Determine NDK API level and toolchain ───────────────────────────
|
||||
API_LEVEL=24
|
||||
HOST_TAG=""
|
||||
case "$(uname -s)" in
|
||||
Linux*) HOST_TAG="linux-x86_64" ;;
|
||||
Darwin*) HOST_TAG="darwin-x86_64" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;;
|
||||
*) echo "Unsupported host OS"; exit 1 ;;
|
||||
esac
|
||||
|
||||
TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG"
|
||||
CC="$TOOLCHAIN/bin/aarch64-linux-android${API_LEVEL}-clang"
|
||||
AR="$TOOLCHAIN/bin/llvm-ar"
|
||||
|
||||
if [ ! -f "$CC" ] && [ ! -f "${CC}.cmd" ]; then
|
||||
echo "ERROR: NDK compiler not found at $CC"
|
||||
echo "Check your NDK installation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Install Rust Android target ─────────────────────────────────────
|
||||
echo "Adding Rust target aarch64-linux-android..."
|
||||
rustup target add aarch64-linux-android
|
||||
|
||||
# ── Configure Cargo for cross-compilation ───────────────────────────
|
||||
mkdir -p "$BUILD_DIR"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$CC"
|
||||
export CC_aarch64_linux_android="$CC"
|
||||
export AR_aarch64_linux_android="$AR"
|
||||
|
||||
# ── Clone pydantic-core ─────────────────────────────────────────────
|
||||
REPO_DIR="$BUILD_DIR/pydantic-core"
|
||||
if [ -d "$REPO_DIR" ]; then
|
||||
echo "Updating existing pydantic-core checkout..."
|
||||
cd "$REPO_DIR"
|
||||
git fetch --tags
|
||||
git checkout "v$PYDANTIC_CORE_VERSION"
|
||||
else
|
||||
echo "Cloning pydantic-core v$PYDANTIC_CORE_VERSION..."
|
||||
git clone --depth 1 --branch "v$PYDANTIC_CORE_VERSION" \
|
||||
https://github.com/pydantic/pydantic-core.git "$REPO_DIR"
|
||||
cd "$REPO_DIR"
|
||||
fi
|
||||
|
||||
# ── Build with maturin ──────────────────────────────────────────────
|
||||
echo "Building pydantic-core for aarch64-linux-android..."
|
||||
maturin build \
|
||||
--release \
|
||||
--target aarch64-linux-android \
|
||||
--interpreter python3.11 \
|
||||
--out "$WHEELS_DIR"
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
echo "Wheels:"
|
||||
ls -la "$WHEELS_DIR"/pydantic_core-*.whl 2>/dev/null || echo "WARNING: No wheel found!"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Set up the cross-compilation environment for building Python native
|
||||
# extensions targeting Android ARM64.
|
||||
#
|
||||
# This script:
|
||||
# 1. Verifies Android NDK is installed
|
||||
# 2. Installs the Rust aarch64-linux-android target
|
||||
# 3. Installs maturin (Python wheel builder for Rust extensions)
|
||||
# 4. Runs a quick test compile to verify the toolchain works
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== LedGrab Android NDK Setup ==="
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
if ! command -v rustc &>/dev/null; then
|
||||
echo "ERROR: Rust is not installed."
|
||||
echo "Install from: https://rustup.rs/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Rust: $(rustc --version)"
|
||||
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
echo "ERROR: Cargo is not installed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Cargo: $(cargo --version)"
|
||||
|
||||
if ! command -v python3.11 &>/dev/null && ! command -v python3 &>/dev/null; then
|
||||
echo "WARNING: Python 3.11 not found. Needed for maturin builds."
|
||||
fi
|
||||
|
||||
# ── Install Rust target ─────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Installing Rust Android target..."
|
||||
rustup target add aarch64-linux-android
|
||||
echo "Installed targets:"
|
||||
rustup target list --installed | grep android
|
||||
|
||||
# ── Install maturin ─────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Installing maturin..."
|
||||
pip install maturin 2>/dev/null || pip3 install maturin 2>/dev/null || {
|
||||
echo "WARNING: Could not install maturin. Install manually: pip install maturin"
|
||||
}
|
||||
|
||||
if command -v maturin &>/dev/null; then
|
||||
echo "maturin: $(maturin --version)"
|
||||
fi
|
||||
|
||||
# ── Verify NDK ──────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
if [ -n "${ANDROID_NDK_HOME:-}" ]; then
|
||||
echo "ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
|
||||
else
|
||||
echo "ANDROID_NDK_HOME is not set."
|
||||
echo "Set it to your NDK installation path, e.g.:"
|
||||
echo " export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/26.1.10909125"
|
||||
echo ""
|
||||
echo "Or install NDK via Android Studio:"
|
||||
echo " SDK Manager → SDK Tools → NDK (Side by side)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Ensure ANDROID_NDK_HOME is set"
|
||||
echo " 2. Run: ./build-pydantic-core.sh"
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.9.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.chaquo.python") version "17.0.0" apply false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
org.gradle.unsafe.isolated-projects=false
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "LedGrab"
|
||||
include(":app")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -66,6 +66,10 @@ camera = [
|
||||
# opencv-python-headless is now a core dependency (used for image encoding)
|
||||
# camera extra kept for backwards compatibility
|
||||
]
|
||||
# High-performance Android capture via scrcpy H.264 streaming
|
||||
scrcpy = [
|
||||
"scrcpy-client>=0.5.0",
|
||||
]
|
||||
# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
|
||||
notifications = [
|
||||
"winrt-Windows.UI.Notifications>=3.0.0; sys_platform == 'win32'",
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
# Fallback version — kept in sync with pyproject.toml.
|
||||
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||
# on Android, where the source is included directly via source sets).
|
||||
_FALLBACK_VERSION = "0.3.0"
|
||||
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
# Running from source without pip install (e.g. dev, embedded Python)
|
||||
__version__ = "0.0.0-dev"
|
||||
__version__ = _FALLBACK_VERSION
|
||||
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Android entry point for LedGrab.
|
||||
|
||||
Called from Kotlin via Chaquopy to start/stop the FastAPI server
|
||||
inside an Android application. Sets up Android-specific paths
|
||||
(app-private storage) before importing the main application.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
_server_thread: Optional[threading.Thread] = None
|
||||
_shutdown_event: Optional[asyncio.Event] = None
|
||||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
|
||||
def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
"""Start the LedGrab uvicorn server.
|
||||
|
||||
Called from Kotlin's ``PythonBridge.startServer()``. This function
|
||||
blocks until ``stop_server()`` is called, so Kotlin should invoke it
|
||||
on a background thread.
|
||||
|
||||
Args:
|
||||
data_dir: Android app-private files directory
|
||||
(e.g. ``/data/data/com.ledgrab.android/files``).
|
||||
port: HTTP port for the web UI / API.
|
||||
"""
|
||||
# ── Configure paths before any LedGrab imports ──────────────
|
||||
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
|
||||
os.makedirs(os.path.join(data_dir, "data", "assets"), exist_ok=True)
|
||||
os.makedirs(os.path.join(data_dir, "config"), exist_ok=True)
|
||||
|
||||
# Change working directory to app-private storage so relative
|
||||
# paths (e.g. "config/default_config.yaml") don't hit system dirs
|
||||
os.chdir(data_dir)
|
||||
|
||||
os.environ["LEDGRAB_STORAGE__DATABASE_FILE"] = os.path.join(data_dir, "data", "ledgrab.db")
|
||||
os.environ["LEDGRAB_ASSETS__ASSETS_DIR"] = os.path.join(data_dir, "data", "assets")
|
||||
os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
|
||||
os.environ["LEDGRAB_SERVER__PORT"] = str(port)
|
||||
|
||||
# ── Now safe to import LedGrab ──────────────────────────────
|
||||
import uvicorn # noqa: E402
|
||||
|
||||
from ledgrab.utils import setup_logging, get_logger # noqa: E402
|
||||
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
logger.info("LedGrab Android: starting server on port %d", port)
|
||||
logger.info("Data directory: %s", data_dir)
|
||||
|
||||
from ledgrab.config import get_config # noqa: E402
|
||||
|
||||
config = get_config()
|
||||
|
||||
uv_config = uvicorn.Config(
|
||||
"ledgrab.main:app",
|
||||
host=config.server.host,
|
||||
port=port,
|
||||
log_level=config.server.log_level.lower(),
|
||||
# No uvloop/httptools on Android — use pure-Python asyncio
|
||||
loop="asyncio",
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
|
||||
global _shutdown_event, _loop
|
||||
_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_loop)
|
||||
_shutdown_event = asyncio.Event()
|
||||
|
||||
logger.info("LedGrab Android: server starting")
|
||||
_loop.run_until_complete(server.serve())
|
||||
logger.info("LedGrab Android: server stopped")
|
||||
|
||||
|
||||
def stop_server() -> None:
|
||||
"""Signal the uvicorn server to shut down gracefully.
|
||||
|
||||
Called from Kotlin's ``PythonBridge.stopServer()``.
|
||||
"""
|
||||
if _shutdown_event is not None and _loop is not None:
|
||||
_loop.call_soon_threadsafe(_shutdown_event.set)
|
||||
@@ -1643,8 +1643,7 @@ async def test_color_strip_ws(
|
||||
try:
|
||||
frame = _frame_live.get_latest_frame()
|
||||
if frame is not None and frame.image is not None:
|
||||
from ledgrab.utils.image_codec import encode_jpeg
|
||||
import cv2 as _cv2
|
||||
from ledgrab.utils.image_codec import encode_jpeg, resize_image
|
||||
|
||||
img = frame.image
|
||||
# Ensure 3-channel RGB (some engines may produce BGRA)
|
||||
@@ -1668,9 +1667,7 @@ async def test_color_strip_ws(
|
||||
if scale < 1.0:
|
||||
new_w = max(1, int(w * scale))
|
||||
new_h = max(1, int(h * scale))
|
||||
img = _cv2.resize(
|
||||
img, (new_w, new_h), interpolation=_cv2.INTER_AREA
|
||||
)
|
||||
img = resize_image(img, new_w, new_h)
|
||||
# Wire format: [0xFD] [jpeg_bytes]
|
||||
await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70))
|
||||
except Exception as e:
|
||||
|
||||
@@ -12,7 +12,11 @@ from typing import Optional
|
||||
|
||||
import os
|
||||
|
||||
import psutil
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
psutil = None # type: ignore[assignment]
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ledgrab import __version__, REPO_URL, DONATE_URL
|
||||
@@ -54,9 +58,12 @@ from ledgrab.api.routes.system_settings import load_external_url # noqa: F401
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Prime psutil CPU counters (first call always returns 0.0)
|
||||
psutil.cpu_percent(interval=None)
|
||||
_process = psutil.Process(os.getpid())
|
||||
_process.cpu_percent(interval=None) # prime process-level counter
|
||||
if psutil is not None:
|
||||
psutil.cpu_percent(interval=None)
|
||||
_process = psutil.Process(os.getpid())
|
||||
_process.cpu_percent(interval=None) # prime process-level counter
|
||||
else:
|
||||
_process = None # type: ignore[assignment]
|
||||
|
||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||
from ledgrab.utils.gpu import ( # noqa: E402
|
||||
@@ -200,7 +207,18 @@ async def get_displays(
|
||||
else:
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
else:
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
# Try the mss-based detection first (desktop default).
|
||||
# If mss is unavailable (e.g. on Android), fall back to the
|
||||
# best registered engine that has its own display list.
|
||||
try:
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
except RuntimeError:
|
||||
display_dataclasses = []
|
||||
if not display_dataclasses:
|
||||
best = EngineRegistry.get_best_available_engine()
|
||||
if best:
|
||||
engine_cls = EngineRegistry.get_engine(best)
|
||||
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||
|
||||
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
||||
displays = [
|
||||
@@ -264,6 +282,21 @@ def get_system_performance(_: AuthRequired):
|
||||
and NVML calls are blocking and would stall the event loop if run
|
||||
in an ``async def`` handler.
|
||||
"""
|
||||
if psutil is None or _process is None:
|
||||
# psutil unavailable on this platform (e.g. Android)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return PerformanceResponse(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
cpu_name=_cpu_name,
|
||||
cpu_percent=0.0,
|
||||
ram_used_mb=0.0,
|
||||
ram_total_mb=0.0,
|
||||
ram_percent=0.0,
|
||||
app_cpu_percent=0.0,
|
||||
app_ram_mb=0.0,
|
||||
gpu=None,
|
||||
)
|
||||
mem = psutil.virtual_memory()
|
||||
|
||||
# App-level metrics
|
||||
|
||||
@@ -169,10 +169,13 @@ class Config(BaseSettings):
|
||||
if demo_path.exists():
|
||||
return cls.from_yaml(demo_path)
|
||||
|
||||
# Try default location
|
||||
default_path = Path("config/default_config.yaml")
|
||||
if default_path.exists():
|
||||
return cls.from_yaml(default_path)
|
||||
# Try default location (guard against permission errors on Android)
|
||||
try:
|
||||
default_path = Path("config/default_config.yaml")
|
||||
if default_path.exists():
|
||||
return cls.from_yaml(default_path)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
# Use defaults
|
||||
return cls()
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Audio capture engine abstraction layer."""
|
||||
"""Audio capture engine abstraction layer.
|
||||
|
||||
Audio engines with native dependencies (WASAPI, PortAudio) are imported
|
||||
inside try/except blocks so the package loads cleanly on platforms where
|
||||
those libraries are unavailable (e.g. Android via Chaquopy).
|
||||
"""
|
||||
|
||||
from ledgrab.core.audio.base import (
|
||||
AudioCaptureEngine,
|
||||
@@ -13,13 +18,33 @@ from ledgrab.core.audio.analysis import (
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
)
|
||||
from ledgrab.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
|
||||
from ledgrab.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
|
||||
|
||||
# ── Platform-specific audio engines ─────────────────────────────────
|
||||
|
||||
try:
|
||||
from ledgrab.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
|
||||
|
||||
_has_wasapi = True
|
||||
except ImportError:
|
||||
_has_wasapi = False
|
||||
|
||||
try:
|
||||
from ledgrab.core.audio.sounddevice_engine import (
|
||||
SounddeviceEngine,
|
||||
SounddeviceCaptureStream,
|
||||
)
|
||||
|
||||
_has_sounddevice = True
|
||||
except ImportError:
|
||||
_has_sounddevice = False
|
||||
|
||||
from ledgrab.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
|
||||
|
||||
# Auto-register available engines
|
||||
AudioEngineRegistry.register(WasapiEngine)
|
||||
AudioEngineRegistry.register(SounddeviceEngine)
|
||||
if _has_wasapi:
|
||||
AudioEngineRegistry.register(WasapiEngine)
|
||||
if _has_sounddevice:
|
||||
AudioEngineRegistry.register(SounddeviceEngine)
|
||||
AudioEngineRegistry.register(DemoAudioEngine)
|
||||
|
||||
__all__ = [
|
||||
@@ -32,10 +57,11 @@ __all__ = [
|
||||
"NUM_BANDS",
|
||||
"DEFAULT_SAMPLE_RATE",
|
||||
"DEFAULT_CHUNK_SIZE",
|
||||
"WasapiEngine",
|
||||
"WasapiCaptureStream",
|
||||
"SounddeviceEngine",
|
||||
"SounddeviceCaptureStream",
|
||||
"DemoAudioEngine",
|
||||
"DemoAudioCaptureStream",
|
||||
]
|
||||
|
||||
if _has_wasapi:
|
||||
__all__ += ["WasapiEngine", "WasapiCaptureStream"]
|
||||
if _has_sounddevice:
|
||||
__all__ += ["SounddeviceEngine", "SounddeviceCaptureStream"]
|
||||
|
||||
@@ -6,7 +6,6 @@ Non-Windows: graceful degradation (returns empty results).
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -18,6 +17,9 @@ logger = get_logger(__name__)
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
if _IS_WINDOWS:
|
||||
import ctypes.wintypes
|
||||
|
||||
|
||||
class PlatformDetector:
|
||||
"""Detect running processes and the foreground window's process."""
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import mss
|
||||
except ImportError:
|
||||
mss = None # type: ignore[assignment]
|
||||
|
||||
from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -54,6 +58,8 @@ def get_available_displays() -> List[DisplayInfo]:
|
||||
Raises:
|
||||
RuntimeError: If unable to detect displays
|
||||
"""
|
||||
if mss is None:
|
||||
return []
|
||||
try:
|
||||
# Get friendly monitor names (Windows only, falls back to generic names)
|
||||
monitor_names = get_monitor_names()
|
||||
@@ -105,6 +111,8 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
|
||||
ValueError: If display_index is invalid
|
||||
RuntimeError: If screen capture fails
|
||||
"""
|
||||
if mss is None:
|
||||
raise RuntimeError("mss library not available on this platform")
|
||||
try:
|
||||
with mss.mss() as sct:
|
||||
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Screen capture engine abstraction layer."""
|
||||
"""Screen capture engine abstraction layer.
|
||||
|
||||
Engines with native/platform-specific dependencies are imported inside
|
||||
try/except blocks so the package loads cleanly on any platform (including
|
||||
Android via Chaquopy where desktop capture libraries are unavailable).
|
||||
"""
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -7,14 +12,61 @@ from ledgrab.core.capture_engines.base import (
|
||||
ScreenCapture,
|
||||
)
|
||||
from ledgrab.core.capture_engines.factory import EngineRegistry
|
||||
from ledgrab.core.capture_engines.mss_engine import MSSEngine, MSSCaptureStream
|
||||
from ledgrab.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream
|
||||
from ledgrab.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
|
||||
from ledgrab.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
|
||||
from ledgrab.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
||||
from ledgrab.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
|
||||
|
||||
# Camera engine requires OpenCV — optional dependency
|
||||
# ── Desktop capture engines (platform-specific native deps) ──────────
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.mss_engine import MSSEngine, MSSCaptureStream
|
||||
|
||||
_has_mss = True
|
||||
except ImportError:
|
||||
_has_mss = False
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream
|
||||
|
||||
_has_dxcam = True
|
||||
except ImportError:
|
||||
_has_dxcam = False
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.bettercam_engine import (
|
||||
BetterCamEngine,
|
||||
BetterCamCaptureStream,
|
||||
)
|
||||
|
||||
_has_bettercam = True
|
||||
except ImportError:
|
||||
_has_bettercam = False
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
|
||||
|
||||
_has_wgc = True
|
||||
except ImportError:
|
||||
_has_wgc = False
|
||||
|
||||
# ── ADB-based Android capture ───────────────────────────────────────
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
||||
|
||||
_has_scrcpy = True
|
||||
except ImportError:
|
||||
_has_scrcpy = False
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.scrcpy_client_engine import (
|
||||
ScrcpyClientEngine,
|
||||
ScrcpyClientCaptureStream,
|
||||
)
|
||||
|
||||
_has_scrcpy_client = True
|
||||
except ImportError:
|
||||
_has_scrcpy_client = False
|
||||
|
||||
# ── Camera (OpenCV) ─────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
|
||||
|
||||
@@ -22,35 +74,67 @@ try:
|
||||
except ImportError:
|
||||
_has_camera = False
|
||||
|
||||
# Auto-register available engines
|
||||
EngineRegistry.register(MSSEngine)
|
||||
EngineRegistry.register(DXcamEngine)
|
||||
EngineRegistry.register(BetterCamEngine)
|
||||
EngineRegistry.register(WGCEngine)
|
||||
EngineRegistry.register(ScrcpyEngine)
|
||||
# ── Android MediaProjection (Chaquopy bridge) ───────────────────────
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.mediaprojection_engine import (
|
||||
MediaProjectionEngine,
|
||||
MediaProjectionCaptureStream,
|
||||
)
|
||||
|
||||
_has_mediaprojection = True
|
||||
except ImportError:
|
||||
_has_mediaprojection = False
|
||||
|
||||
# ── Demo / always available ─────────────────────────────────────────
|
||||
|
||||
from ledgrab.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
|
||||
|
||||
# ── Auto-register available engines ─────────────────────────────────
|
||||
|
||||
if _has_mss:
|
||||
EngineRegistry.register(MSSEngine)
|
||||
if _has_dxcam:
|
||||
EngineRegistry.register(DXcamEngine)
|
||||
if _has_bettercam:
|
||||
EngineRegistry.register(BetterCamEngine)
|
||||
if _has_wgc:
|
||||
EngineRegistry.register(WGCEngine)
|
||||
if _has_scrcpy:
|
||||
EngineRegistry.register(ScrcpyEngine)
|
||||
if _has_scrcpy_client:
|
||||
EngineRegistry.register(ScrcpyClientEngine)
|
||||
if _has_camera:
|
||||
EngineRegistry.register(CameraEngine)
|
||||
if _has_mediaprojection:
|
||||
EngineRegistry.register(MediaProjectionEngine)
|
||||
EngineRegistry.register(DemoCaptureEngine)
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
__all__ = [
|
||||
"CaptureEngine",
|
||||
"CaptureStream",
|
||||
"DisplayInfo",
|
||||
"ScreenCapture",
|
||||
"EngineRegistry",
|
||||
"MSSEngine",
|
||||
"MSSCaptureStream",
|
||||
"DXcamEngine",
|
||||
"DXcamCaptureStream",
|
||||
"BetterCamEngine",
|
||||
"BetterCamCaptureStream",
|
||||
"WGCEngine",
|
||||
"WGCCaptureStream",
|
||||
"ScrcpyEngine",
|
||||
"ScrcpyCaptureStream",
|
||||
"DemoCaptureEngine",
|
||||
"DemoCaptureStream",
|
||||
]
|
||||
|
||||
if _has_mss:
|
||||
__all__ += ["MSSEngine", "MSSCaptureStream"]
|
||||
if _has_dxcam:
|
||||
__all__ += ["DXcamEngine", "DXcamCaptureStream"]
|
||||
if _has_bettercam:
|
||||
__all__ += ["BetterCamEngine", "BetterCamCaptureStream"]
|
||||
if _has_wgc:
|
||||
__all__ += ["WGCEngine", "WGCCaptureStream"]
|
||||
if _has_scrcpy:
|
||||
__all__ += ["ScrcpyEngine", "ScrcpyCaptureStream"]
|
||||
if _has_scrcpy_client:
|
||||
__all__ += ["ScrcpyClientEngine", "ScrcpyClientCaptureStream"]
|
||||
if _has_camera:
|
||||
__all__ += ["CameraEngine", "CameraCaptureStream"]
|
||||
if _has_mediaprojection:
|
||||
__all__ += ["MediaProjectionEngine", "MediaProjectionCaptureStream"]
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Android MediaProjection capture engine.
|
||||
|
||||
Receives screen frames pushed from Kotlin (via Chaquopy) through a
|
||||
module-level frame queue. The Kotlin layer captures the screen using
|
||||
the ``MediaProjection`` API and calls :func:`push_frame` with raw RGBA
|
||||
bytes for each frame.
|
||||
|
||||
This engine is only available when running inside an Android app that
|
||||
has set up the frame queue.
|
||||
"""
|
||||
|
||||
import queue
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
DisplayInfo,
|
||||
ScreenCapture,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame queue — the bridge between Kotlin and Python
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
|
||||
_display_info: Optional[DisplayInfo] = None
|
||||
_active = False
|
||||
_frames_received = 0
|
||||
_frames_consumed = 0
|
||||
# MediaProjection only fires onImageAvailable when the screen changes.
|
||||
# Cache the last frame so preview stays usable on static screens.
|
||||
_last_frame: Optional["ScreenCapture"] = None
|
||||
|
||||
|
||||
def configure(width: int, height: int) -> None:
|
||||
"""Set display dimensions. Called from Kotlin before server start.
|
||||
|
||||
Drains any stale frames from a previous capture session so the
|
||||
first frame after restart is actually current.
|
||||
"""
|
||||
global _display_info, _active, _last_frame, _frames_received
|
||||
# Drain the queue — frames from a previous capture session are stale
|
||||
while not _frame_queue.empty():
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
_last_frame = None
|
||||
_frames_received = 0
|
||||
_display_info = DisplayInfo(
|
||||
index=0,
|
||||
name="Android TV Screen",
|
||||
width=width,
|
||||
height=height,
|
||||
x=0,
|
||||
y=0,
|
||||
is_primary=True,
|
||||
refresh_rate=60,
|
||||
)
|
||||
_active = True
|
||||
logger.info("MediaProjection engine configured: %dx%d", width, height)
|
||||
|
||||
|
||||
def push_frame(rgba_bytes: bytes, width: int, height: int) -> None:
|
||||
"""Push a captured frame from Kotlin into the Python pipeline.
|
||||
|
||||
Called from Kotlin's ``PythonBridge.pushFrame()`` on the capture
|
||||
thread. The RGBA byte buffer is converted to an RGB NumPy array
|
||||
and placed on the queue. If the queue is full (Python consumer is
|
||||
slow), the oldest frame is dropped.
|
||||
|
||||
Args:
|
||||
rgba_bytes: Raw RGBA pixel data (width * height * 4 bytes).
|
||||
width: Frame width in pixels.
|
||||
height: Frame height in pixels.
|
||||
"""
|
||||
global _frames_received
|
||||
_frames_received += 1
|
||||
if _frames_received == 1 or _frames_received % 100 == 0:
|
||||
logger.info("MediaProjection: received %d frames", _frames_received)
|
||||
|
||||
# RGBA → RGB (drop alpha channel)
|
||||
rgba = np.frombuffer(rgba_bytes, dtype=np.uint8).reshape((height, width, 4))
|
||||
rgb = rgba[:, :, :3].copy()
|
||||
|
||||
frame = ScreenCapture(
|
||||
image=rgb,
|
||||
width=width,
|
||||
height=height,
|
||||
display_index=0,
|
||||
)
|
||||
|
||||
global _last_frame
|
||||
_last_frame = frame
|
||||
|
||||
# Drop oldest frame if queue is full (non-blocking)
|
||||
try:
|
||||
_frame_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
_frame_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Deactivate the engine. Called when the Android app stops."""
|
||||
global _active
|
||||
_active = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureStream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MediaProjectionCaptureStream(CaptureStream):
|
||||
"""Reads frames pushed by Kotlin from the module-level queue."""
|
||||
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
if not _active:
|
||||
raise RuntimeError(
|
||||
"MediaProjection engine not configured. "
|
||||
"This engine is only available inside the Android app."
|
||||
)
|
||||
self._initialized = True
|
||||
logger.info("MediaProjection capture stream initialized")
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
# Prefer fresh frames from the queue; fall back to the last
|
||||
# received frame when the screen is static (MediaProjection
|
||||
# only emits frames on actual content changes).
|
||||
try:
|
||||
return _frame_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
return _last_frame
|
||||
|
||||
def cleanup(self) -> None:
|
||||
# Drain the queue
|
||||
while not _frame_queue.empty():
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._initialized = False
|
||||
logger.info("MediaProjection capture stream cleaned up")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MediaProjectionEngine(CaptureEngine):
|
||||
"""Android MediaProjection capture engine.
|
||||
|
||||
Only available when running inside the LedGrab Android app.
|
||||
The Kotlin layer calls :func:`configure` at startup and
|
||||
:func:`push_frame` for each captured frame.
|
||||
"""
|
||||
|
||||
ENGINE_TYPE = "mediaprojection"
|
||||
ENGINE_PRIORITY = 100 # Highest priority on Android
|
||||
HAS_OWN_DISPLAYS = True
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return _active and _display_info is not None
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||
if _display_info is not None:
|
||||
return [_display_info]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def create_stream(
|
||||
cls, display_index: int, config: Dict[str, Any]
|
||||
) -> MediaProjectionCaptureStream:
|
||||
return MediaProjectionCaptureStream(display_index, config)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""High-performance Android screen capture via the scrcpy protocol.
|
||||
|
||||
Uses the ``scrcpy-client`` library to stream H.264/H.265 video from an
|
||||
Android device over ADB. Frames are decoded by PyAV and delivered as
|
||||
NumPy arrays at up to 60 FPS — a major upgrade over the ``adb screencap``
|
||||
polling approach in :mod:`scrcpy_engine` (~1-2 FPS).
|
||||
|
||||
Prerequisites (pip packages):
|
||||
- scrcpy-client>=0.5.0 (pulls in ``av``, ``adbutils``, ``numpy``)
|
||||
|
||||
The library pushes a small (~35 KB) ``scrcpy-server.jar`` to the device,
|
||||
launches it via ``app_process``, and opens an ADB socket for the encoded
|
||||
video stream. No APK installation, no root.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
DisplayInfo,
|
||||
ScreenCapture,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try:
|
||||
import scrcpy
|
||||
from adbutils import adb as adb_client
|
||||
|
||||
_HAS_SCRCPY_CLIENT = True
|
||||
except ImportError:
|
||||
_HAS_SCRCPY_CLIENT = False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _list_devices() -> List[Dict[str, Any]]:
|
||||
"""Enumerate connected ADB devices via adbutils."""
|
||||
if not _HAS_SCRCPY_CLIENT:
|
||||
return []
|
||||
|
||||
devices = []
|
||||
try:
|
||||
for dev in adb_client.device_list():
|
||||
serial = dev.serial
|
||||
# Query model name
|
||||
try:
|
||||
model = dev.prop.model or serial
|
||||
except Exception:
|
||||
model = serial
|
||||
# Query screen resolution
|
||||
width, height = 1920, 1080
|
||||
try:
|
||||
wm_output = dev.shell("wm size")
|
||||
for line in wm_output.strip().splitlines():
|
||||
if "x" in line:
|
||||
parts = line.split()[-1].split("x")
|
||||
width, height = int(parts[0]), int(parts[1])
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
devices.append(
|
||||
{
|
||||
"serial": serial,
|
||||
"model": model,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to enumerate ADB devices: %s", e)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureStream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScrcpyClientCaptureStream(CaptureStream):
|
||||
"""H.264/H.265 video stream from an Android device via scrcpy protocol.
|
||||
|
||||
The ``scrcpy.Client`` runs in a background thread. On each decoded
|
||||
frame it invokes our callback, which stores the latest frame.
|
||||
``capture_frame()`` returns it without blocking.
|
||||
"""
|
||||
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._client: Optional["scrcpy.Client"] = None
|
||||
self._latest_frame: Optional[ScreenCapture] = None
|
||||
self._frame_lock = threading.Lock()
|
||||
self._frame_event = threading.Event()
|
||||
self._client_thread: Optional[threading.Thread] = None
|
||||
self._device_serial: Optional[str] = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
devices = _list_devices()
|
||||
if not devices:
|
||||
raise RuntimeError(
|
||||
"No ADB devices found. Connect a device via USB or " "'adb connect <ip>' for WiFi."
|
||||
)
|
||||
if self.display_index >= len(devices):
|
||||
raise RuntimeError(
|
||||
f"Device index {self.display_index} out of range "
|
||||
f"(found {len(devices)} device(s))"
|
||||
)
|
||||
|
||||
device = devices[self.display_index]
|
||||
self._device_serial = device["serial"]
|
||||
|
||||
max_fps = self.config.get("max_fps", 30)
|
||||
max_size = self.config.get("max_size", 480)
|
||||
bitrate = self.config.get("bitrate", 2_000_000)
|
||||
|
||||
logger.info(
|
||||
"scrcpy-client: connecting to %s (%s, %dx%d) — " "max_fps=%d, max_size=%d, bitrate=%d",
|
||||
self._device_serial,
|
||||
device["model"],
|
||||
device["width"],
|
||||
device["height"],
|
||||
max_fps,
|
||||
max_size,
|
||||
bitrate,
|
||||
)
|
||||
|
||||
self._client = scrcpy.Client(
|
||||
device=self._device_serial,
|
||||
max_fps=max_fps,
|
||||
max_size=max_size,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
self._client.add_listener(scrcpy.EVENT_FRAME, self._on_frame)
|
||||
|
||||
# scrcpy.Client.start() blocks, so run in a thread
|
||||
self._client_thread = threading.Thread(
|
||||
target=self._run_client, daemon=True, name="scrcpy-client"
|
||||
)
|
||||
self._client_thread.start()
|
||||
|
||||
# Wait for first frame (with timeout)
|
||||
if not self._frame_event.wait(timeout=10.0):
|
||||
logger.warning(
|
||||
"scrcpy-client: no frame received within 10s from %s",
|
||||
self._device_serial,
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
logger.info("scrcpy-client: stream initialized for %s", self._device_serial)
|
||||
|
||||
def _run_client(self) -> None:
|
||||
"""Start the scrcpy client (blocking)."""
|
||||
try:
|
||||
self._client.start(threaded=False)
|
||||
except Exception as e:
|
||||
logger.error("scrcpy-client: error for %s: %s", self._device_serial, e)
|
||||
|
||||
def _on_frame(self, frame: np.ndarray) -> None:
|
||||
"""Callback invoked by scrcpy-client for each decoded frame.
|
||||
|
||||
The frame arrives as a BGR numpy array; we convert to RGB to
|
||||
match the ``ScreenCapture`` convention.
|
||||
"""
|
||||
if frame is None:
|
||||
return
|
||||
|
||||
# BGR → RGB
|
||||
rgb = frame[:, :, ::-1].copy()
|
||||
h, w = rgb.shape[:2]
|
||||
|
||||
with self._frame_lock:
|
||||
self._latest_frame = ScreenCapture(
|
||||
image=rgb,
|
||||
width=w,
|
||||
height=h,
|
||||
display_index=self.display_index,
|
||||
)
|
||||
self._frame_event.set()
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
if not self._frame_event.is_set():
|
||||
return None
|
||||
|
||||
with self._frame_lock:
|
||||
frame = self._latest_frame
|
||||
self._frame_event.clear()
|
||||
|
||||
return frame
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._client is not None:
|
||||
try:
|
||||
self._client.stop()
|
||||
except Exception as e:
|
||||
logger.debug("scrcpy-client: stop error: %s", e)
|
||||
|
||||
if self._client_thread is not None and self._client_thread.is_alive():
|
||||
self._client_thread.join(timeout=5)
|
||||
|
||||
self._client = None
|
||||
self._client_thread = None
|
||||
self._latest_frame = None
|
||||
self._frame_event.clear()
|
||||
self._initialized = False
|
||||
logger.info("scrcpy-client: stream cleaned up (%s)", self._device_serial)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScrcpyClientEngine(CaptureEngine):
|
||||
"""High-performance Android capture via scrcpy H.264 streaming.
|
||||
|
||||
Requires the ``scrcpy-client`` pip package (optional dependency).
|
||||
When available, this engine is preferred over :class:`ScrcpyEngine`
|
||||
(which falls back to ``adb screencap`` at ~1-2 FPS).
|
||||
|
||||
Prerequisites:
|
||||
- ``pip install scrcpy-client`` (pulls in PyAV, adbutils)
|
||||
- adb on PATH (for device connection)
|
||||
- USB debugging enabled on Android device
|
||||
"""
|
||||
|
||||
ENGINE_TYPE = "scrcpy_client"
|
||||
ENGINE_PRIORITY = 10 # Higher than ScrcpyEngine (5)
|
||||
HAS_OWN_DISPLAYS = True
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return _HAS_SCRCPY_CLIENT
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"max_fps": 30,
|
||||
"max_size": 480,
|
||||
"bitrate": 2_000_000,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||
devices = _list_devices()
|
||||
displays = []
|
||||
for idx, device in enumerate(devices):
|
||||
displays.append(
|
||||
DisplayInfo(
|
||||
index=idx,
|
||||
name=f"{device['model']} ({device['serial']})",
|
||||
width=device["width"],
|
||||
height=device["height"],
|
||||
x=idx * 500,
|
||||
y=0,
|
||||
is_primary=(idx == 0),
|
||||
refresh_rate=60,
|
||||
)
|
||||
)
|
||||
logger.debug("scrcpy-client: detected %d Android device(s)", len(displays))
|
||||
return displays
|
||||
|
||||
@classmethod
|
||||
def create_stream(cls, display_index: int, config: Dict[str, Any]) -> ScrcpyClientCaptureStream:
|
||||
return ScrcpyClientCaptureStream(display_index, config)
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from ledgrab.utils.image_codec import resize_image
|
||||
from ledgrab.core.filters.image_pool import ImagePool
|
||||
from ledgrab.core.filters.registry import FilterRegistry
|
||||
|
||||
@@ -44,7 +44,7 @@ class DownscalerFilter(PostprocessingFilter):
|
||||
if new_h == h and new_w == w:
|
||||
return None
|
||||
|
||||
downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
downscaled = resize_image(image, new_w, new_h)
|
||||
|
||||
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
|
||||
np.copyto(result, downscaled)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from ledgrab.utils.image_codec import resize_image
|
||||
from ledgrab.core.filters.image_pool import ImagePool
|
||||
from ledgrab.core.filters.registry import FilterRegistry
|
||||
|
||||
@@ -42,8 +42,8 @@ class PixelateFilter(PostprocessingFilter):
|
||||
# vectorized C++ instead of per-block Python loop
|
||||
small_w = max(1, w // block_size)
|
||||
small_h = max(1, h // block_size)
|
||||
small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA)
|
||||
pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
|
||||
small = resize_image(image, small_w, small_h)
|
||||
pixelated = resize_image(small, w, h)
|
||||
np.copyto(image, pixelated)
|
||||
|
||||
return None
|
||||
|
||||
@@ -10,9 +10,10 @@ import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.utils.image_codec import resize_image
|
||||
|
||||
from ledgrab.core.capture.screen_capture import (
|
||||
calculate_average_color,
|
||||
calculate_dominant_color,
|
||||
@@ -150,7 +151,7 @@ class KeyColorsColorStripStream(ColorStripStream):
|
||||
calc_fn = _CALC_FNS.get(src.interpolation_mode, calculate_average_color)
|
||||
|
||||
# Downsample
|
||||
small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
|
||||
small = resize_image(capture.image, KC_WORK_SIZE[0], KC_WORK_SIZE[1])
|
||||
|
||||
# Extract colors per rectangle
|
||||
n = len(self._rect_names)
|
||||
|
||||
@@ -6,7 +6,10 @@ from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
import psutil
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
psutil = None # type: ignore[assignment]
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.gpu import (
|
||||
@@ -21,8 +24,11 @@ MAX_SAMPLES = 120 # ~2 minutes at 1-second interval
|
||||
SAMPLE_INTERVAL = 1.0 # seconds
|
||||
|
||||
|
||||
_process = psutil.Process(os.getpid())
|
||||
_process.cpu_percent(interval=None) # prime process-level counter
|
||||
if psutil is not None:
|
||||
_process = psutil.Process(os.getpid())
|
||||
_process.cpu_percent(interval=None) # prime process-level counter
|
||||
else:
|
||||
_process = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _collect_system_snapshot() -> dict:
|
||||
@@ -30,6 +36,21 @@ def _collect_system_snapshot() -> dict:
|
||||
|
||||
Returns a dict suitable for direct JSON serialization.
|
||||
"""
|
||||
if psutil is None or _process is None:
|
||||
# psutil unavailable (e.g. Android) — return zeroed snapshot
|
||||
return {
|
||||
"t": datetime.now(timezone.utc).isoformat(),
|
||||
"cpu": 0.0,
|
||||
"ram_pct": 0.0,
|
||||
"ram_used": 0.0,
|
||||
"ram_total": 0.0,
|
||||
"app_cpu": 0.0,
|
||||
"app_ram": 0.0,
|
||||
"gpu_util": None,
|
||||
"gpu_temp": None,
|
||||
"app_gpu_mem": None,
|
||||
}
|
||||
|
||||
mem = psutil.virtual_memory()
|
||||
proc_mem = _process.memory_info()
|
||||
snapshot = {
|
||||
|
||||
@@ -198,6 +198,7 @@ import {
|
||||
|
||||
// Layer 6: tabs, navigation, command palette, settings
|
||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
|
||||
import { callTabLoader } from './core/tab-registry.ts';
|
||||
import { navigateToCard } from './core/navigation.ts';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
|
||||
import {
|
||||
@@ -761,6 +762,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
loadDisplays();
|
||||
loadTargetsTab();
|
||||
|
||||
// Trigger the active tab's loader — initTabs() ran before authRequired
|
||||
// was known, so its conditional loader call may have been skipped.
|
||||
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
||||
if (activeTab !== 'targets') {
|
||||
callTabLoader(activeTab);
|
||||
}
|
||||
|
||||
// Start global events WebSocket and auto-refresh
|
||||
startEventsWS();
|
||||
startEntityEventListeners();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tab switching — switchTab, initTabs, startAutoRefresh, hash routing.
|
||||
*/
|
||||
|
||||
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
|
||||
import { apiKey, authRequired, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
|
||||
import { getActiveSubTab, setActiveSubTab, callTabLoader, callSubTabSwitcher, getSubTabConfig, getTabConfig } from '../core/tab-registry.ts';
|
||||
|
||||
/** Parse location.hash into {tab, subTab}. */
|
||||
@@ -48,9 +48,12 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
|
||||
_setHash(name, getActiveSubTab(name));
|
||||
}
|
||||
|
||||
// Authenticated when either auth is disabled or we have an apiKey
|
||||
const isAuthed = !authRequired || !!apiKey;
|
||||
|
||||
if (name === 'dashboard') {
|
||||
// Use window.* to avoid circular imports with feature modules
|
||||
if (!skipLoad && apiKey) callTabLoader(name);
|
||||
if (!skipLoad && isAuthed) callTabLoader(name);
|
||||
} else {
|
||||
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
|
||||
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
|
||||
@@ -59,7 +62,7 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
|
||||
if (typeof window.disconnectAllKCWebSockets === 'function') window.disconnectAllKCWebSockets();
|
||||
if (typeof window.disconnectAllLedPreviewWS === 'function') window.disconnectAllLedPreviewWS();
|
||||
}
|
||||
if (!apiKey || skipLoad) return;
|
||||
if (!isAuthed || skipLoad) return;
|
||||
callTabLoader(name);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +100,8 @@ export function startAutoRefresh(): void {
|
||||
}
|
||||
|
||||
setRefreshInterval(setInterval(() => {
|
||||
if (!apiKey || document.hidden) return;
|
||||
const isAuthed = !authRequired || !!apiKey;
|
||||
if (!isAuthed || document.hidden) return;
|
||||
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
||||
const cfg = getTabConfig(activeTab);
|
||||
if (!cfg?.autoRefresh) return;
|
||||
|
||||
@@ -80,8 +80,11 @@
|
||||
"templates.engine.dxcam.desc": "DirectX, low latency",
|
||||
"templates.engine.bettercam.desc": "DirectX, high performance",
|
||||
"templates.engine.camera.desc": "USB/IP camera capture",
|
||||
"templates.engine.scrcpy.desc": "Android screen mirror",
|
||||
"templates.engine.scrcpy.desc": "Android screen mirror (adb screencap)",
|
||||
"templates.engine.scrcpy_client.desc": "Android H.264 stream, high FPS",
|
||||
"templates.engine.wgc.desc": "Windows Graphics Capture",
|
||||
"templates.engine.demo.desc": "Animated test pattern (demo mode)",
|
||||
"templates.engine.mediaprojection.desc": "Native Android screen capture",
|
||||
"templates.config": "Configuration",
|
||||
"templates.config.show": "Show configuration",
|
||||
"templates.config.none": "No additional configuration",
|
||||
|
||||
@@ -84,8 +84,11 @@
|
||||
"templates.engine.dxcam.desc": "DirectX, низкая задержка",
|
||||
"templates.engine.bettercam.desc": "DirectX, высокая производительность",
|
||||
"templates.engine.camera.desc": "Захват USB/IP камеры",
|
||||
"templates.engine.scrcpy.desc": "Зеркалирование экрана Android",
|
||||
"templates.engine.scrcpy.desc": "Зеркалирование экрана Android (adb screencap)",
|
||||
"templates.engine.scrcpy_client.desc": "Android H.264 поток, высокая частота",
|
||||
"templates.engine.wgc.desc": "Windows Graphics Capture",
|
||||
"templates.engine.demo.desc": "Тестовый анимированный шаблон (демо)",
|
||||
"templates.engine.mediaprojection.desc": "Нативный захват экрана Android",
|
||||
"templates.config": "Конфигурация",
|
||||
"templates.config.show": "Показать конфигурацию",
|
||||
"templates.config.none": "Нет дополнительных настроек",
|
||||
|
||||
@@ -84,8 +84,11 @@
|
||||
"templates.engine.dxcam.desc": "DirectX,低延迟",
|
||||
"templates.engine.bettercam.desc": "DirectX,高性能",
|
||||
"templates.engine.camera.desc": "USB/IP摄像头捕获",
|
||||
"templates.engine.scrcpy.desc": "Android屏幕镜像",
|
||||
"templates.engine.scrcpy.desc": "Android屏幕镜像 (adb screencap)",
|
||||
"templates.engine.scrcpy_client.desc": "Android H.264流,高帧率",
|
||||
"templates.engine.wgc.desc": "Windows图形捕获",
|
||||
"templates.engine.demo.desc": "动画测试图案(演示模式)",
|
||||
"templates.engine.mediaprojection.desc": "原生Android屏幕捕获",
|
||||
"templates.config": "配置",
|
||||
"templates.config.show": "显示配置",
|
||||
"templates.config.none": "无额外配置",
|
||||
|
||||
@@ -1,24 +1,62 @@
|
||||
"""Image encoding/decoding/resizing utilities using OpenCV.
|
||||
"""Image encoding/decoding/resizing utilities.
|
||||
|
||||
Uses OpenCV (cv2) when available for best performance, falls back to
|
||||
Pillow on platforms where cv2 is unavailable (e.g. Android via Chaquopy).
|
||||
|
||||
Replaces PIL/Pillow for JPEG encoding, image loading, and resizing operations.
|
||||
All functions work with numpy RGB arrays (H, W, 3) uint8.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Union
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
|
||||
_HAS_CV2 = True
|
||||
except ImportError:
|
||||
_HAS_CV2 = False
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
_HAS_PIL = True
|
||||
except ImportError:
|
||||
_HAS_PIL = False
|
||||
|
||||
|
||||
def _require_backend() -> None:
|
||||
if not _HAS_CV2 and not _HAS_PIL:
|
||||
raise ImportError(
|
||||
"Neither opencv-python-headless nor Pillow is installed. "
|
||||
"At least one is required for image operations."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def encode_jpeg(image: np.ndarray, quality: int = 85) -> bytes:
|
||||
"""Encode an RGB numpy array as JPEG bytes."""
|
||||
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||
if not ok:
|
||||
raise RuntimeError("JPEG encoding failed")
|
||||
return buf.tobytes()
|
||||
if _HAS_CV2:
|
||||
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||
if not ok:
|
||||
raise RuntimeError("JPEG encoding failed")
|
||||
return buf.tobytes()
|
||||
|
||||
if _HAS_PIL:
|
||||
img = Image.fromarray(image, "RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=quality)
|
||||
return buf.getvalue()
|
||||
|
||||
_require_backend()
|
||||
|
||||
|
||||
def encode_jpeg_data_uri(image: np.ndarray, quality: int = 85) -> str:
|
||||
@@ -28,62 +66,90 @@ def encode_jpeg_data_uri(image: np.ndarray, quality: int = 85) -> str:
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def resize_image(image: np.ndarray, width: int, height: int) -> np.ndarray:
|
||||
"""Resize an image to exact dimensions.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Uses INTER_AREA for downscaling (better quality, faster) and
|
||||
INTER_LANCZOS4 for upscaling.
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
shrinking = (width * height) < (w * h)
|
||||
interp = cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4
|
||||
return cv2.resize(image, (width, height), interpolation=interp)
|
||||
|
||||
def resize_image(image: np.ndarray, width: int, height: int) -> np.ndarray:
|
||||
"""Resize an image to exact dimensions."""
|
||||
if _HAS_CV2:
|
||||
h, w = image.shape[:2]
|
||||
shrinking = (width * height) < (w * h)
|
||||
interp = cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4
|
||||
return cv2.resize(image, (width, height), interpolation=interp)
|
||||
|
||||
if _HAS_PIL:
|
||||
img = Image.fromarray(image, "RGB")
|
||||
resized = img.resize((width, height), Image.LANCZOS)
|
||||
return np.asarray(resized)
|
||||
|
||||
_require_backend()
|
||||
|
||||
|
||||
def thumbnail(image: np.ndarray, max_width: int) -> np.ndarray:
|
||||
"""Create a thumbnail that fits within max_width, preserving aspect ratio.
|
||||
|
||||
Uses INTER_AREA (optimal for downscaling).
|
||||
"""
|
||||
"""Create a thumbnail that fits within max_width, preserving aspect ratio."""
|
||||
h, w = image.shape[:2]
|
||||
if w <= max_width:
|
||||
return image.copy()
|
||||
scale = max_width / w
|
||||
new_w = max_width
|
||||
new_h = max(1, int(h * scale))
|
||||
return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
return resize_image(image, new_w, new_h)
|
||||
|
||||
|
||||
def resize_down(image: np.ndarray, max_width: int) -> np.ndarray:
|
||||
"""Downscale if wider than max_width; return as-is otherwise.
|
||||
|
||||
Uses INTER_AREA (optimal for downscaling).
|
||||
"""
|
||||
"""Downscale if wider than max_width; return as-is otherwise."""
|
||||
h, w = image.shape[:2]
|
||||
if w <= max_width:
|
||||
return image
|
||||
scale = max_width / w
|
||||
new_w = max_width
|
||||
new_h = max(1, int(h * scale))
|
||||
return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
return resize_image(image, new_w, new_h)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_image_file(path: Union[str, Path]) -> np.ndarray:
|
||||
"""Load an image file and return as RGB numpy array."""
|
||||
path = str(path)
|
||||
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
raise FileNotFoundError(f"Cannot load image: {path}")
|
||||
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
|
||||
if _HAS_CV2:
|
||||
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
raise FileNotFoundError(f"Cannot load image: {path}")
|
||||
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
|
||||
if _HAS_PIL:
|
||||
img = Image.open(path).convert("RGB")
|
||||
return np.asarray(img)
|
||||
|
||||
_require_backend()
|
||||
|
||||
|
||||
def load_image_bytes(data: bytes) -> np.ndarray:
|
||||
"""Decode image bytes (JPEG, PNG, etc.) and return as RGB numpy array."""
|
||||
arr = np.frombuffer(data, dtype=np.uint8)
|
||||
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
raise ValueError("Cannot decode image data")
|
||||
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
if _HAS_CV2:
|
||||
arr = np.frombuffer(data, dtype=np.uint8)
|
||||
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
raise ValueError("Cannot decode image data")
|
||||
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
|
||||
if _HAS_PIL:
|
||||
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||
return np.asarray(img)
|
||||
|
||||
_require_backend()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def image_size(image: np.ndarray) -> Tuple[int, int]:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Platform detection utilities.
|
||||
|
||||
Centralizes platform checks so the rest of the codebase can use
|
||||
``is_android()``, ``is_windows()``, etc. instead of ad-hoc
|
||||
``sys.platform`` comparisons.
|
||||
|
||||
Android reports ``sys.platform == "linux"``, so a dedicated check is
|
||||
needed to distinguish it from desktop Linux.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_is_android: bool | None = None
|
||||
|
||||
|
||||
def is_android() -> bool:
|
||||
"""Return True when running inside an Android environment (Chaquopy)."""
|
||||
global _is_android
|
||||
if _is_android is None:
|
||||
_is_android = hasattr(sys, "getandroidapilevel") or bool(os.environ.get("ANDROID_ROOT"))
|
||||
return _is_android
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
"""Return True on Windows."""
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
"""Return True on desktop Linux (excludes Android)."""
|
||||
return sys.platform == "linux" and not is_android()
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
"""Return True on macOS."""
|
||||
return sys.platform == "darwin"
|
||||
Reference in New Issue
Block a user