Initial commit
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
# Virtual Camera HAL — KernelSU Module
|
||||||
|
|
||||||
|
Replaces physical cameras on Pixel 9A (Android 16) with virtual cameras
|
||||||
|
backed by v4l2loopback, fed via RTSP stream or MP4 file.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Boot → post-fs-data.sh → insmod v4l2loopback.ko → /dev/video0
|
||||||
|
→ copy binary + AIDL .so files → /data/local/tmp/
|
||||||
|
→ start virtual_camera_provider with LD_LIBRARY_PATH
|
||||||
|
→ registers ICameraProvider/virtual/0
|
||||||
|
→ cameraserver discovers 2 virtual cameras
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `module.prop` | KernelSU metadata |
|
||||||
|
| `post-fs-data.sh` | Boot startup (insmod, copy, start) |
|
||||||
|
| `service.sh` | Watchdog (30s delay) |
|
||||||
|
| `sepolicy.rule` | SELinux permissions |
|
||||||
|
| `v4l2loopback.ko` | Kernel module (0x440 struct, ARM64) |
|
||||||
|
| `vendor/bin/virtual_camera_provider` | AOSP-built AIDL HAL3 binary |
|
||||||
|
| `vendor/lib64/*.so` | AIDL NDK shared libraries (V3) |
|
||||||
|
| `system/etc/vintf/manifest/*.xml` | VINTF manifest overlay |
|
||||||
|
| `vendor/etc/vintf/manifest/*.xml` | VINTF manifest overlay (vendor) |
|
||||||
|
| `control.sh` | WebUI backend |
|
||||||
|
| `rtsp_feeder.sh` | RTSP/MP4 → /dev/video0 bridge |
|
||||||
|
| `webui/` | KSU WebUI (index.html, style.css, script.js) |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Install module via KernelSU app or `ksud module install`
|
||||||
|
2. Reboot
|
||||||
|
3. Open KernelSU → Modules → Virtual Camera → WebUI
|
||||||
|
4. Configure RTSP URL or MP4 file path
|
||||||
|
5. Tap "Start" (provides 2 virtual cameras at IDs 10 and 11)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check provider registered
|
||||||
|
adb shell su -c 'service list | grep camera.provider'
|
||||||
|
# Expected: ICameraProvider/virtual/0
|
||||||
|
|
||||||
|
# Check virtual cameras
|
||||||
|
adb shell su -c 'dumpsys media.camera | grep "Number of camera"'
|
||||||
|
# Expected: Number of camera devices: 4
|
||||||
|
|
||||||
|
# Check camera characteristics
|
||||||
|
adb shell su -c 'dumpsys media.camera | grep -A25 "virtual/10"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/opslag/aosp-16-platform
|
||||||
|
export TARGET_RELEASE=bp2a
|
||||||
|
source build/envsetup.sh
|
||||||
|
lunch aosp_arm64-bp2a-userdebug
|
||||||
|
cd external/virtualcam && mma -j10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Fixes
|
||||||
|
|
||||||
|
- **-3 registration error**: Overrode `createBinder()` to skip
|
||||||
|
`AIBinder_markVintfStability` which caused STATUS_BAD_VALUE
|
||||||
|
- **CANNOT LINK**: Bundled AIDL V3 .so files with module
|
||||||
|
- **AOSP build memory**: Use `-j10` for 10 cores max, incremental builds
|
||||||
|
are fast after initial Soong analysis
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/system/bin/sh
|
||||||
|
CONFIG_FILE="/data/local/tmp/virtualcam_config.json"
|
||||||
|
MODDIR=$(dirname "$0")
|
||||||
|
|
||||||
|
load_config() {
|
||||||
|
[ -f "$CONFIG_FILE" ] && . /data/adb/modules/alias/system/bin/shflags 2>/dev/null || true
|
||||||
|
SOURCE_TYPE=$(grep -o '"source_type":"[^"]*"' "$CONFIG_FILE" 2>/dev/null | cut -d'"' -f4)
|
||||||
|
RTSP_URL=$(grep -o '"rtsp_url":"[^"]*"' "$CONFIG_FILE" 2>/dev/null | cut -d'"' -f4)
|
||||||
|
MP4_PATH=$(grep -o '"mp4_path":"[^"]*"' "$CONFIG_FILE" 2>/dev/null | cut -d'"' -f4)
|
||||||
|
RES_W=$(grep -o '"res_w":[0-9]*' "$CONFIG_FILE" 2>/dev/null | cut -d: -f2)
|
||||||
|
RES_H=$(grep -o '"res_h":[0-9]*' "$CONFIG_FILE" 2>/dev/null | cut -d: -f2)
|
||||||
|
FPS=$(grep -o '"fps":[0-9]*' "$CONFIG_FILE" 2>/dev/null | cut -d: -f2)
|
||||||
|
: ${SOURCE_TYPE:=rtsp} ${RTSP_URL:=rtsp://192.168.1.100:8554/stream}
|
||||||
|
: ${MP4_PATH:=/sdcard/Download/video.mp4} ${RES_W:=1920} ${RES_H:=1080} ${FPS:=30}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-help}" in
|
||||||
|
status)
|
||||||
|
PROVIDER_PID=$(pgrep -f virtual_camera_provider | head -1)
|
||||||
|
FEEDER_PID=$(pgrep -f "ffmpeg.*video0\|gst-launch.*video0" | head -1)
|
||||||
|
V4L2=$(lsmod 2>/dev/null | grep -q v4l2loopback && echo 1 || echo 0)
|
||||||
|
SVC=$(service list 2>/dev/null | grep -q "virtual/0" && echo 1 || echo 0)
|
||||||
|
VIDEO=$(cat /sys/class/video4linux/video0/name 2>/dev/null || echo "not found")
|
||||||
|
echo "{\"provider_pid\":${PROVIDER_PID:-0},\"feeder_pid\":${FEEDER_PID:-0},\"v4l2_loaded\":$V4L2,\"service_registered\":$SVC,\"video0\":\"$VIDEO\"}"
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
insmod "$MODDIR/v4l2loopback.ko" devices=1 video_nr=0 card_label="VirtualCam" exclusive_caps=1 2>/dev/null || true
|
||||||
|
pkill -9 -f virtual_camera_provider 2>/dev/null || true
|
||||||
|
"$MODDIR/vendor/bin/virtual_camera_provider" -d /dev/video0 -w 1920 -h 1080 -i virtual &
|
||||||
|
echo '{"status":"started"}'
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
pkill -9 -f virtual_camera_provider 2>/dev/null || true
|
||||||
|
echo '{"status":"stopped"}'
|
||||||
|
;;
|
||||||
|
start_feeder)
|
||||||
|
load_config
|
||||||
|
pkill -9 -f "ffmpeg.*video0\|gst-launch.*video0" 2>/dev/null || true
|
||||||
|
if [ "$SOURCE_TYPE" = "rtsp" ]; then
|
||||||
|
ffmpeg -rtsp_transport tcp -i "$RTSP_URL" -f v4l2 -input_format nv21 \
|
||||||
|
-video_size "${RES_W}x${RES_H}" -framerate "$FPS" -pix_fmt nv21 /dev/video0 &
|
||||||
|
elif [ "$SOURCE_TYPE" = "mp4" -a -f "$MP4_PATH" ]; then
|
||||||
|
ffmpeg -stream_loop -1 -re -i "$MP4_PATH" -f v4l2 -input_format nv21 \
|
||||||
|
-video_size "${RES_W}x${RES_H}" -framerate "$FPS" -pix_fmt nv21 /dev/video0 &
|
||||||
|
fi
|
||||||
|
echo '{"status":"feeder_started"}'
|
||||||
|
;;
|
||||||
|
stop_feeder)
|
||||||
|
pkill -9 -f "ffmpeg.*video0\|gst-launch.*video0" 2>/dev/null || true
|
||||||
|
echo '{"status":"feeder_stopped"}'
|
||||||
|
;;
|
||||||
|
get_config)
|
||||||
|
load_config
|
||||||
|
echo "{\"source_type\":\"$SOURCE_TYPE\",\"rtsp_url\":\"$RTSP_URL\",\"mp4_path\":\"$MP4_PATH\",\"res_w\":$RES_W,\"res_h\":$RES_H,\"fps\":$FPS}"
|
||||||
|
;;
|
||||||
|
set_config|save)
|
||||||
|
cat > "$CONFIG_FILE" << DATA
|
||||||
|
{"source_type":"$2","rtsp_url":"$3","mp4_path":"$4","res_w":$5,"res_h":$6,"fps":$7}
|
||||||
|
DATA
|
||||||
|
echo '{"status":"saved"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id=virtualcam
|
||||||
|
name=Virtual Camera (RTSP/MP4)
|
||||||
|
version=3.0
|
||||||
|
versionCode=3
|
||||||
|
author=sisyphus
|
||||||
|
description=Replaces Pixel 9A cameras with virtual cameras backed by v4l2loopback. Feeds RTSP stream or MP4 video. FULL hardware level with 25+ metadata entries, FPS ranges, AE/AWB/AF modes, stabilization. WebUI for configuration. AOSP-built AIDL HAL3 provider.
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/system/bin/sh
|
||||||
|
MODDIR=${0%/*}
|
||||||
|
exec >> "$MODDIR/boot.log" 2>&1
|
||||||
|
echo "[$(date)] post-fs-data starting"
|
||||||
|
|
||||||
|
# Stop the stock Google camera HAL so it doesn't re-register its cameras.
|
||||||
|
# This is an APEX-managed service that auto-restarts, so we need to keep it down.
|
||||||
|
setprop ctl.stop vendor.camera-provider-2-7-google 2>/dev/null || true
|
||||||
|
kill -9 $(pgrep -f camera-provider-2-7-google) 2>/dev/null || true
|
||||||
|
|
||||||
|
# Reload v4l2loopback (rmmod first to clear stale params like exclusive_caps)
|
||||||
|
rmmod v4l2loopback 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
insmod "$MODDIR/v4l2loopback.ko" devices=1 video_nr=0 card_label="VirtualCam" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy provider binary and AIDL libs
|
||||||
|
PROVIDER_DST="/data/local/tmp/virtual_camera_provider"
|
||||||
|
LIB_DST="/data/local/tmp/virtualcam_libs"
|
||||||
|
cp "$MODDIR/vendor/bin/virtual_camera_provider" "$PROVIDER_DST" 2>/dev/null || true
|
||||||
|
chmod 755 "$PROVIDER_DST" 2>/dev/null || true
|
||||||
|
mkdir -p "$LIB_DST" 2>/dev/null || true
|
||||||
|
cp "$MODDIR/vendor/lib64"/*.so "$LIB_DST/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Start our provider
|
||||||
|
if [ -x "$PROVIDER_DST" ]; then
|
||||||
|
nohup env LD_LIBRARY_PATH="$LIB_DST" "$PROVIDER_DST" \
|
||||||
|
-d /dev/video0 -w 1920 -h 1080 -i virtual \
|
||||||
|
</dev/null >/dev/null 2>&1 &
|
||||||
|
echo "[$(date)] provider started"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fork a background watchdog to keep the stock HAL dead
|
||||||
|
(
|
||||||
|
while sleep 30; do
|
||||||
|
# Re-kill stock HAL if it came back
|
||||||
|
if pgrep -f camera-provider-2-7-google >/dev/null 2>&1; then
|
||||||
|
setprop ctl.stop vendor.camera-provider-2-7-google 2>/dev/null || true
|
||||||
|
kill -9 $(pgrep -f camera-provider-2-7-google) 2>/dev/null || true
|
||||||
|
echo "[$(date)] re-killed stock HAL"
|
||||||
|
fi
|
||||||
|
# Restart our provider if it died
|
||||||
|
if ! pgrep -f virtual_camera_provider >/dev/null 2>&1; then
|
||||||
|
if [ -x "$PROVIDER_DST" ]; then
|
||||||
|
nohup env LD_LIBRARY_PATH="$LIB_DST" "$PROVIDER_DST" \
|
||||||
|
-d /dev/video0 -w 1920 -h 1080 -i virtual \
|
||||||
|
</dev/null >/dev/null 2>&1 &
|
||||||
|
echo "[$(date)] provider restarted"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
echo "[$(date)] post-fs-data done"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/system/bin/sh
|
||||||
|
# Launched from WebUI. Never runs at boot.
|
||||||
|
RTSP_URL="${RTSP_URL:-rtsp://192.168.1.100:8554/stream}"
|
||||||
|
V4L2_DEVICE="${V4L2_DEVICE:-/dev/video0}"
|
||||||
|
WIDTH=1920; HEIGHT=1080; FPS=30
|
||||||
|
[ -e "$V4L2_DEVICE" ] || exit 1
|
||||||
|
|
||||||
|
if command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
exec ffmpeg -rtsp_transport tcp -i "$RTSP_URL" \
|
||||||
|
-f v4l2 -input_format nv21 -video_size "${WIDTH}x${HEIGHT}" \
|
||||||
|
-framerate "$FPS" -pix_fmt nv21 "$V4L2_DEVICE"
|
||||||
|
elif command -v gst-launch-1.0 >/dev/null 2>&1; then
|
||||||
|
exec gst-launch-1.0 rtspsrc location="$RTSP_URL" latency=0 ! \
|
||||||
|
rtph264depay ! h264parse ! v4l2h264dec ! v4l2sink device="$V4L2_DEVICE"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Only what's strictly needed. No broad "allow init ..." rules.
|
||||||
|
allow virtual_camera_provider video_device:chr_file { read write ioctl open };
|
||||||
|
allow virtual_camera_provider servicemanager:binder { call transfer };
|
||||||
|
allow cameraserver virtual_camera_provider:binder { call transfer };
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/system/bin/sh
|
||||||
|
PROVIDER_DST="/data/local/tmp/virtual_camera_provider"
|
||||||
|
LIB_DST="/data/local/tmp/virtualcam_libs"
|
||||||
|
sleep 30
|
||||||
|
if ! pgrep -f virtual_camera_provider >/dev/null 2>&1 && [ -x "$PROVIDER_DST" ]; then
|
||||||
|
nohup env LD_LIBRARY_PATH="$LIB_DST" "$PROVIDER_DST" \
|
||||||
|
-d /dev/video0 -w 1920 -h 1080 -i virtual \
|
||||||
|
</dev/null >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest version="2.0" type="device">
|
||||||
|
<hal format="aidl">
|
||||||
|
<name>android.hardware.camera.provider</name>
|
||||||
|
<version>1</version>
|
||||||
|
<fqname>ICameraProvider/virtual/0</fqname>
|
||||||
|
</hal>
|
||||||
|
</manifest>
|
||||||
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest version="2.0" type="device">
|
||||||
|
<hal format="aidl">
|
||||||
|
<name>android.hardware.camera.provider</name>
|
||||||
|
<version>1</version>
|
||||||
|
<fqname>ICameraProvider/virtual/0</fqname>
|
||||||
|
</hal>
|
||||||
|
</manifest>
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html><html lang="en"><head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Virtual Camera</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head><body>
|
||||||
|
<div class="container">
|
||||||
|
<header><div class="title">Virtual Camera</div><div class="subtitle">Replace physical cameras</div></header>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Status</div>
|
||||||
|
<div class="dashboard" id="status-dash"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Video Source</div>
|
||||||
|
<div class="source-selector">
|
||||||
|
<button class="src-btn active" data-src="rtsp">RTSP</button>
|
||||||
|
<button class="src-btn" data-src="mp4">MP4</button>
|
||||||
|
</div>
|
||||||
|
<div id="rtsp-config"><label>RTSP URL</label><input type="text" id="rtsp-url" placeholder="rtsp://192.168.1.100:8554/stream"></div>
|
||||||
|
<div id="mp4-config" class="hidden"><label>MP4 Path</label><input type="text" id="mp4-path" placeholder="/sdcard/Download/video.mp4"></div>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Width</label><input type="number" id="res-w" value="1920" class="num"></div>
|
||||||
|
<div><label>Height</label><input type="number" id="res-h" value="1080" class="num"></div>
|
||||||
|
<div><label>FPS</label><input type="number" id="fps" value="30" class="num"></div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-save" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Controls</div>
|
||||||
|
<div class="grid">
|
||||||
|
<button id="btn-start" class="btn">Start Provider</button>
|
||||||
|
<button id="btn-stop" class="btn danger">Stop</button>
|
||||||
|
<button id="btn-feed" class="btn">Start Feeder</button>
|
||||||
|
<button id="btn-unfeed" class="btn danger">Stop Feeder</button>
|
||||||
|
<button id="btn-refresh" class="btn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Log</div>
|
||||||
|
<div id="console" class="console"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const CMD='/data/adb/modules/virtualcam/control.sh';
|
||||||
|
function log(m,t){const e=document.getElementById('console');const d=new Date();
|
||||||
|
e.innerHTML+=`<div class="${t||'log'}">[${d.toLocaleTimeString()}] ${m}</div>`;e.scrollTop=e.scrollHeight}
|
||||||
|
async function exec(c){try{return await KSU.exec(c)}catch(e){log('exec error','err');return null}}
|
||||||
|
|
||||||
|
async function status(){
|
||||||
|
const r=await exec(CMD+' status');if(!r)return;
|
||||||
|
try{
|
||||||
|
const s=JSON.parse(r);const d=document.getElementById('status-dash');
|
||||||
|
const items=[['Provider',s.provider_pid>0?'green':'red',s.provider_pid>0?'Running':'Stopped'],
|
||||||
|
['Feeder',s.feeder_pid>0?'green':'red',s.feeder_pid>0?'Running':'Stopped'],
|
||||||
|
['Service',s.service_registered==1?'green':'red',s.service_registered==1?'Registered':'Not found'],
|
||||||
|
['v4l2',s.v4l2_loaded==1?'green':'red',s.v4l2_loaded==1?'Loaded':'Missing'],
|
||||||
|
['Device','yellow',s.video0||'N/A']];
|
||||||
|
d.innerHTML=items.map(i=>`<div class="stat"><span class="l">${i[0]}</span><span class="v ${i[1]}">${i[2]}</span></div>`).join('');
|
||||||
|
log('Status updated','ok');
|
||||||
|
}catch(e){log('Parse error','err')}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded',()=>{
|
||||||
|
status();
|
||||||
|
document.querySelectorAll('.src-btn').forEach(b=>{
|
||||||
|
b.addEventListener('click',()=>{
|
||||||
|
document.querySelectorAll('.src-btn').forEach(x=>x.classList.remove('active'));
|
||||||
|
b.classList.add('active');
|
||||||
|
document.getElementById('rtsp-config').classList.toggle('hidden',b.dataset.src!=='rtsp');
|
||||||
|
document.getElementById('mp4-config').classList.toggle('hidden',b.dataset.src!=='mp4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('btn-save').addEventListener('click',async()=>{
|
||||||
|
const src=document.querySelector('.src-btn.active').dataset.src;
|
||||||
|
const rtsp=document.getElementById('rtsp-url').value;
|
||||||
|
const mp4=document.getElementById('mp4-path').value;
|
||||||
|
const w=document.getElementById('res-w').value||1920;
|
||||||
|
const h=document.getElementById('res-h').value||1080;
|
||||||
|
const f=document.getElementById('fps').value||30;
|
||||||
|
await exec(`echo '{"source_type":"${src}","rtsp_url":"${rtsp}","mp4_path":"${mp4}","res_w":${w},"res_h":${h},"fps":${f}}' > /data/local/tmp/virtualcam_config.json`);
|
||||||
|
log('Config saved','ok');
|
||||||
|
});
|
||||||
|
document.getElementById('btn-start').addEventListener('click',async()=>{
|
||||||
|
log('Starting provider...','info');await exec(CMD+' start');log('Provider started','ok');setTimeout(status,2000);
|
||||||
|
});
|
||||||
|
document.getElementById('btn-stop').addEventListener('click',async()=>{
|
||||||
|
log('Stopping...','info');await exec(CMD+' stop');log('Stopped','ok');status();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-feed').addEventListener('click',async()=>{
|
||||||
|
log('Starting feeder...','info');await exec(CMD+' start_feeder');log('Feeder started','ok');setTimeout(status,2000);
|
||||||
|
});
|
||||||
|
document.getElementById('btn-unfeed').addEventListener('click',async()=>{
|
||||||
|
log('Stopping feeder...','info');await exec(CMD+' stop_feeder');log('Feeder stopped','ok');status();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-refresh').addEventListener('click',status);
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;padding:16px;max-width:480px;margin:0 auto}
|
||||||
|
.container{display:flex;flex-direction:column;gap:12px}
|
||||||
|
header{text-align:center;padding:12px 0}
|
||||||
|
.title{font-size:22px;font-weight:700;color:#58a6ff}
|
||||||
|
.subtitle{font-size:13px;color:#8b949e}
|
||||||
|
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px}
|
||||||
|
.card-title{font-size:14px;font-weight:600;color:#58a6ff;margin-bottom:10px}
|
||||||
|
.dashboard{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||||
|
.stat{background:#0d1117;border-radius:6px;padding:8px;text-align:center}
|
||||||
|
.stat .l{font-size:11px;color:#8b949e;display:block}
|
||||||
|
.stat .v{font-size:15px;font-weight:600;display:block}
|
||||||
|
.green{color:#3fb950}.red{color:#f85149}.yellow{color:#d29922}
|
||||||
|
.source-selector{display:flex;gap:8px;margin-bottom:12px}
|
||||||
|
.src-btn{flex:1;padding:10px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#8b949e;cursor:pointer}
|
||||||
|
.src-btn.active{background:#1f6feb33;border-color:#1f6feb;color:#58a6ff}
|
||||||
|
.hidden{display:none}
|
||||||
|
label{font-size:12px;color:#8b949e;display:block;margin-bottom:4px}
|
||||||
|
input{width:100%;padding:10px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#c9d1d9;font-size:14px}
|
||||||
|
.row{display:flex;gap:8px;margin-bottom:12px}.row>div{flex:1}
|
||||||
|
.num{width:100%}
|
||||||
|
.primary{width:100%;padding:12px;border:none;border-radius:6px;background:#238636;color:#fff;font-size:14px;font-weight:600;cursor:pointer}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||||
|
.btn{padding:12px;border:1px solid #30363d;border-radius:6px;background:#21262d;color:#c9d1d9;cursor:pointer;text-align:center}
|
||||||
|
.btn.danger{border-color:#f8514944;color:#f85149}
|
||||||
|
.console{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:10px;height:150px;overflow-y:auto;font-family:monospace;font-size:12px;color:#8b949e}
|
||||||
|
.console .ok{color:#3fb950}.console .err{color:#f85149}.console .info{color:#58a6ff}
|
||||||
Reference in New Issue
Block a user