Initial commit: phonestream project
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connected Dots</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: #0a0a0f;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Controls panel */
|
||||
#controls {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
min-width: 180px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#controls h3 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ctrl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ctrl-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.ctrl-row label {
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.ctrl-row input[type="range"] {
|
||||
width: 90px;
|
||||
accent-color: #7c6cf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctrl-row .val {
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-size: 11px;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#toggle-controls {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: rgba(255,255,255,0.5);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
z-index: 11;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
#toggle-controls:hover {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
#controls.hidden { display: none; }
|
||||
#controls.hidden + #toggle-controls { display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
<div id="controls">
|
||||
<h3>⚙ Settings</h3>
|
||||
<div class="ctrl-row">
|
||||
<label>Dots</label>
|
||||
<input type="range" id="dotCount" min="20" max="200" value="80">
|
||||
<span class="val" id="dotCountVal">80</span>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<label>Max speed</label>
|
||||
<input type="range" id="speed" min="0.5" max="6" step="0.1" value="2.5">
|
||||
<span class="val" id="speedVal">2.5</span>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<label>Jump chance</label>
|
||||
<input type="range" id="jumpChance" min="0" max="100" value="30">
|
||||
<span class="val" id="jumpChanceVal">30%</span>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<label>Connect dist</label>
|
||||
<input type="range" id="connDist" min="50" max="300" value="140">
|
||||
<span class="val" id="connDistVal">140</span>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<label>Trail</label>
|
||||
<input type="range" id="trail" min="0" max="95" value="80">
|
||||
<span class="val" id="trailVal">80%</span>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<label>Glow</label>
|
||||
<input type="range" id="glow" min="0" max="30" value="12">
|
||||
<span class="val" id="glowVal">12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="toggle-controls" onclick="document.getElementById('controls').classList.toggle('hidden')">⚙</button>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
let W, H;
|
||||
let dots = [];
|
||||
let mouse = { x: -9999, y: -9999 };
|
||||
|
||||
// Settings
|
||||
const settings = {
|
||||
dotCount: 80,
|
||||
maxSpeed: 2.5,
|
||||
jumpChance: 0.3,
|
||||
connDist: 140,
|
||||
trail: 0.80,
|
||||
glow: 12
|
||||
};
|
||||
|
||||
function resize() {
|
||||
W = canvas.width = window.innerWidth;
|
||||
H = canvas.height = window.innerHeight;
|
||||
}
|
||||
|
||||
class Dot {
|
||||
constructor() {
|
||||
this.x = Math.random() * W;
|
||||
this.y = Math.random() * H;
|
||||
this.vx = (Math.random() - 0.5) * settings.maxSpeed;
|
||||
this.vy = (Math.random() - 0.5) * settings.maxSpeed;
|
||||
this.radius = Math.random() * 2 + 1.5;
|
||||
this.hue = Math.random() * 60 + 240; // blue-purple range
|
||||
this.hueSpeed = (Math.random() - 0.5) * 0.5;
|
||||
this.jumpCooldown = 0;
|
||||
}
|
||||
|
||||
jump() {
|
||||
// Teleport to a random position far away
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = Math.min(W, H) * (0.3 + Math.random() * 0.5);
|
||||
this.x = W/2 + Math.cos(angle) * dist;
|
||||
this.y = H/2 + Math.sin(angle) * dist;
|
||||
// Clamp
|
||||
this.x = Math.max(0, Math.min(W, this.x));
|
||||
this.y = Math.max(0, Math.min(H, this.y));
|
||||
// Random new velocity
|
||||
this.vx = (Math.random() - 0.5) * settings.maxSpeed * 2;
|
||||
this.vy = (Math.random() - 0.5) * settings.maxSpeed * 2;
|
||||
this.jumpCooldown = 60 + Math.random() * 60;
|
||||
}
|
||||
|
||||
update() {
|
||||
const spd = settings.maxSpeed;
|
||||
|
||||
// Maybe jump (teleport)
|
||||
if (this.jumpCooldown > 0) {
|
||||
this.jumpCooldown--;
|
||||
} else if (Math.random() < settings.jumpChance * 0.002) {
|
||||
this.jump();
|
||||
} else {
|
||||
// Normal wandering movement
|
||||
this.vx += (Math.random() - 0.5) * 0.15 * spd;
|
||||
this.vy += (Math.random() - 0.5) * 0.15 * spd;
|
||||
|
||||
// Clamp speed
|
||||
const maxSpd = spd;
|
||||
const s = Math.sqrt(this.vx*this.vx + this.vy*this.vy);
|
||||
if (s > maxSpd) {
|
||||
this.vx = (this.vx / s) * maxSpd;
|
||||
this.vy = (this.vy / s) * maxSpd;
|
||||
}
|
||||
}
|
||||
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.hue = (this.hue + this.hueSpeed + 360) % 360;
|
||||
|
||||
// Soft bounce off edges
|
||||
if (this.x < 0) { this.x = 0; this.vx = Math.abs(this.vx) * 0.5; }
|
||||
if (this.x > W) { this.x = W; this.vx = -Math.abs(this.vx) * 0.5; }
|
||||
if (this.y < 0) { this.y = 0; this.vy = Math.abs(this.vy) * 0.5; }
|
||||
if (this.y > H) { this.y = H; this.vy = -Math.abs(this.vy) * 0.5; }
|
||||
}
|
||||
|
||||
draw() {
|
||||
const glow = settings.glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius + glow, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${this.hue}, 80%, 65%, 0.08)`;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${this.hue}, 90%, 75%, 1)`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function initDots() {
|
||||
dots = [];
|
||||
for (let i = 0; i < settings.dotCount; i++) {
|
||||
dots.push(new Dot());
|
||||
}
|
||||
}
|
||||
|
||||
function drawLines() {
|
||||
const dist = settings.connDist;
|
||||
const dist2 = dist * dist;
|
||||
|
||||
for (let i = 0; i < dots.length; i++) {
|
||||
for (let j = i + 1; j < dots.length; j++) {
|
||||
const dx = dots[i].x - dots[j].x;
|
||||
const dy = dots[i].y - dots[j].y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
|
||||
if (d2 < dist2) {
|
||||
const alpha = (1 - Math.sqrt(d2) / dist) * 0.6;
|
||||
const hue = (dots[i].hue + dots[j].hue) / 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dots[i].x, dots[i].y);
|
||||
ctx.lineTo(dots[j].x, dots[j].y);
|
||||
ctx.strokeStyle = `hsla(${hue}, 70%, 60%, ${alpha})`;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse interaction - dots are gently pushed away
|
||||
function mouseInteract() {
|
||||
if (mouse.x < 0) return;
|
||||
const mouseDist = 150;
|
||||
for (const dot of dots) {
|
||||
const dx = dot.x - mouse.x;
|
||||
const dy = dot.y - mouse.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
if (d < mouseDist && d > 0) {
|
||||
const force = (1 - d / mouseDist) * 0.8;
|
||||
dot.vx += (dx / d) * force;
|
||||
dot.vy += (dy / d) * force;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
// Trail effect
|
||||
ctx.fillStyle = `rgba(10, 10, 15, ${settings.trail / 100})`;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
mouseInteract();
|
||||
drawLines();
|
||||
|
||||
for (const dot of dots) {
|
||||
dot.update();
|
||||
dot.draw();
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Init
|
||||
resize();
|
||||
initDots();
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
resize();
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
mouse.x = e.clientX;
|
||||
mouse.y = e.clientY;
|
||||
});
|
||||
|
||||
window.addEventListener('mouseleave', () => {
|
||||
mouse.x = -9999;
|
||||
mouse.y = -9999;
|
||||
});
|
||||
|
||||
// Touch support
|
||||
window.addEventListener('touchmove', (e) => {
|
||||
mouse.x = e.touches[0].clientX;
|
||||
mouse.y = e.touches[0].clientY;
|
||||
});
|
||||
window.addEventListener('touchend', () => {
|
||||
mouse.x = -9999;
|
||||
mouse.y = -9999;
|
||||
});
|
||||
|
||||
// ─── Controls binding ───
|
||||
function bindSlider(id, settingKey, suffix = '') {
|
||||
const el = document.getElementById(id);
|
||||
const valEl = document.getElementById(id + 'Val');
|
||||
|
||||
el.addEventListener('input', () => {
|
||||
const val = parseFloat(el.value);
|
||||
settings[settingKey] = val;
|
||||
valEl.textContent = el.value + suffix;
|
||||
|
||||
// Reinit dots if count changed
|
||||
if (settingKey === 'dotCount') {
|
||||
initDots();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindSlider('dotCount', 'dotCount');
|
||||
bindSlider('speed', 'maxSpeed');
|
||||
bindSlider('jumpChance', 'jumpChance', '%');
|
||||
bindSlider('connDist', 'connDist');
|
||||
bindSlider('trail', 'trail', '%');
|
||||
bindSlider('glow', 'glow');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/bin/bash
|
||||
# PhoneStream Exposure — управление экспозицией камеры через ADB
|
||||
# Использование: ./exposure.sh [lock|unlock|comp|iso|ev|status] [значение]
|
||||
|
||||
PHONE_ID="c3c2c293"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
err() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
|
||||
# Проверка что стрим запущен
|
||||
check_stream() {
|
||||
if ! pgrep -f "scrcpy.*v4l2-sink" >/dev/null 2>&1; then
|
||||
err "Стрим не запущен. Сначала: ./start.sh"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Блокировка экспозиции (AE Lock) ───
|
||||
# Используем termux-camera или напрямую через service call
|
||||
# Для Android 12+ используем cmd media.camera
|
||||
|
||||
ae_lock() {
|
||||
check_stream
|
||||
# Блокируем автоэкспозицию через cmd
|
||||
adb shell "cmd media.camera set-camera-control ae-lock true" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log "Экспозизаза заблокирована (AE Lock ON)"
|
||||
else
|
||||
# Альтернативный метод через service call
|
||||
warn "cmd метод не сработал, пробуем альтернативу..."
|
||||
ae_lock_alt
|
||||
fi
|
||||
}
|
||||
|
||||
ae_lock_alt() {
|
||||
# Через camera service напрямую
|
||||
# Получаем текущие параметры экспозиции
|
||||
local exposure_info=$(adb shell "dumpsys media.camera" 2>/dev/null | grep -A 2 "android.sensor.exposureTime")
|
||||
echo "Текущая экспозиция: $exposure_info"
|
||||
warn "Для полной блокировки используй приложение камеры на телефоне:"
|
||||
echo " 1. Открой Камеру на телефоне"
|
||||
echo " 2. Нажми и удерживай на объект для фиксации AE/AF"
|
||||
echo " 3. Увидишь 'AE/AF Lock'"
|
||||
echo " 4. Теперь запусти стрим — экспозиция сохранится"
|
||||
}
|
||||
|
||||
ae_unlock() {
|
||||
check_stream
|
||||
adb shell "cmd media.camera set-camera-control ae-lock false" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log "Экспозизаза разблокирована (AE Lock OFF)"
|
||||
else
|
||||
log "Автоэкспозизация восстановлена"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Компенсация экспозиции (EV) ───
|
||||
# Шаг = 1/6 EV, диапазон обычно от -12 до +12
|
||||
set_ev() {
|
||||
check_stream
|
||||
local ev=${1:-0}
|
||||
|
||||
# Проверяем диапазон
|
||||
if [ "$ev" -lt -12 ] || [ "$ev" -gt 12 ]; then
|
||||
err "EV должен быть от -12 до +12"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
adb shell "cmd media.camera set-camera-control ae-compensation $ev" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log "Компенсация экспозиции: ${ev} EV"
|
||||
else
|
||||
warn "Не удалось установить EV через cmd"
|
||||
echo "Попробуй вручную в приложении камеры на телефоне"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── ISO вручную ───
|
||||
# Требует Android 12+ и поддержки камерой
|
||||
set_iso() {
|
||||
check_stream
|
||||
local iso=${1:-auto}
|
||||
|
||||
if [ "$iso" = "auto" ]; then
|
||||
adb shell "cmd media.camera set-camera-control ae-mode 1" 2>/dev/null
|
||||
log "ISO: автоматический"
|
||||
else
|
||||
# Переводим ISO в exposure time (ns) и sensitivity
|
||||
# ISO 100 = sensitivity 100
|
||||
adb shell "cmd media.camera set-camera-control manual-sensitivity $iso" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log "ISO установлен: $iso"
|
||||
else
|
||||
warn "Не удалось установить ISO напрямую"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Статус экспозиции ───
|
||||
exposure_status() {
|
||||
check_stream
|
||||
echo -e "${CYAN}═══ Экспозиция камеры ═══${NC}"
|
||||
echo ""
|
||||
|
||||
adb shell "dumpsys media.camera" 2>/dev/null | grep -E \
|
||||
"android.control.aeLock|android.control.aeMode|android.control.aeExposureCompensation|android.control.aeState|android.sensor.exposureTime|android.sensor.sensitivity" | \
|
||||
while read line; do
|
||||
echo " $line"
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Меню ───
|
||||
menu() {
|
||||
echo -e "${CYAN}═══ PhoneStream Exposure ═══${NC}"
|
||||
echo ""
|
||||
echo " 1) 🔒 Заблокировать экспозицию (AE Lock)"
|
||||
echo " 2) 🔓 Разблокировать экспозицию"
|
||||
echo " 3) ☀️ Установить компенсацию EV (-12..+12)"
|
||||
echo " 4) 📊 Статус экспозиции"
|
||||
echo " 5) 🔧 Установить ISO"
|
||||
echo ""
|
||||
echo " Рекомендация: открой камеру на телефоне, нажми и держи"
|
||||
echo " на объект для фиксации AE/AF, потом запускай стрим"
|
||||
echo ""
|
||||
read -p " → Выбор: " choice
|
||||
|
||||
case $choice in
|
||||
1) ae_lock ;;
|
||||
2) ae_unlock ;;
|
||||
3)
|
||||
read -p " EV значение (-12..+12, 0=норма): " ev
|
||||
set_ev "$ev"
|
||||
;;
|
||||
4) exposure_status ;;
|
||||
5)
|
||||
read -p " ISO (100-3200, auto=автомат): " iso
|
||||
set_iso "$iso"
|
||||
;;
|
||||
*) err "Неверный выбор" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Точка входа ───
|
||||
case "${1:-menu}" in
|
||||
lock) ae_lock ;;
|
||||
unlock) ae_unlock ;;
|
||||
comp|ev) set_ev "$2" ;;
|
||||
iso) set_iso "$2" ;;
|
||||
status) exposure_status ;;
|
||||
menu) menu ;;
|
||||
*)
|
||||
echo "Использование: $0 {lock|unlock|ev|iso|status|menu} [значение]"
|
||||
echo ""
|
||||
echo " Команды:"
|
||||
echo " lock — Заблокировать экспозицию"
|
||||
echo " unlock — Разблокировать (авто)"
|
||||
echo " ev <значение> — Компенсация (-12..+12, шаг 1/6 EV)"
|
||||
echo " iso <значение>— ISO (100-3200, auto)"
|
||||
echo " status — Текущие параметры"
|
||||
echo " menu — Интерактивное меню"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PhoneStream Tray — иконка в системном трее.
|
||||
Запуск: python3 phonestream-tray.py
|
||||
Зависимости: gir1.2-appindicator3-0.1 (sudo apt install gir1.2-appindicator3-0.1)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import signal
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('AppIndicator3', '0.1')
|
||||
|
||||
from gi.repository import Gtk, GLib
|
||||
from gi.repository import AppIndicator3 as AppIndicator
|
||||
|
||||
SCRIPT = "/home/nikolas/bin/phonestream/phonestream.sh"
|
||||
PHONE_ID = "c3c2c293"
|
||||
|
||||
PROFILES = [
|
||||
(0, "▶ Максимум (5MP 4:3)", "start", "0"),
|
||||
(1, "▶ Широкий (4MP 16:9)", "start", "1"),
|
||||
(2, "▶ 2.5K (5MP 4:3)", "start", "2"),
|
||||
(3, "▶ Full HD", "start", "3"),
|
||||
(4, "▶ Full HD 60fps", "start", "4"),
|
||||
(5, "▶ HD 120fps (slow-mo)", "start", "5"),
|
||||
(6, "▶ HD 240fps (slow-mo)", "start", "6"),
|
||||
]
|
||||
|
||||
|
||||
def is_stream_active():
|
||||
try:
|
||||
r = subprocess.run(["pgrep", "-f", "scrcpy.*v4l2-sink"],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_phone_connected():
|
||||
try:
|
||||
r = subprocess.run(["adb", "devices"],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
return PHONE_ID in r.stdout
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def run_script(args):
|
||||
def _run():
|
||||
try:
|
||||
subprocess.run([SCRIPT] + args, capture_output=True, text=True, timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
|
||||
def make_menu():
|
||||
phone_ok = is_phone_connected()
|
||||
stream_active = is_stream_active()
|
||||
|
||||
menu = Gtk.Menu()
|
||||
|
||||
# Статус
|
||||
phone_item = Gtk.MenuItem(label=f"📱 Телефон: {'Подключён' if phone_ok else 'Нет'}")
|
||||
phone_item.set_sensitive(False)
|
||||
menu.append(phone_item)
|
||||
|
||||
stream_item = Gtk.MenuItem(label=f"📷 Стрим: {'Активен ●' if stream_active else 'Стоп ○'}")
|
||||
stream_item.set_sensitive(False)
|
||||
menu.append(stream_item)
|
||||
|
||||
menu.append(Gtk.SeparatorMenuItem())
|
||||
|
||||
if not stream_active:
|
||||
# Запуск с профилем
|
||||
for idx, label, action, profile_idx in PROFILES:
|
||||
item = Gtk.MenuItem(label=label)
|
||||
item.connect("activate", lambda i, p=profile_idx: run_script(["start", p]))
|
||||
menu.append(item)
|
||||
else:
|
||||
# Остановка
|
||||
stop_item = Gtk.MenuItem(label="⏹ Остановить")
|
||||
stop_item.connect("activate", lambda i: run_script(["stop"]))
|
||||
menu.append(stop_item)
|
||||
|
||||
# Перезапуск
|
||||
menu.append(Gtk.SeparatorMenuItem())
|
||||
for idx, label, action, profile_idx in PROFILES:
|
||||
restart_label = label.replace("▶", "🔄")
|
||||
item = Gtk.MenuItem(label=restart_label)
|
||||
item.connect("activate", lambda i, p=profile_idx: run_script(["restart", p]))
|
||||
menu.append(item)
|
||||
|
||||
menu.append(Gtk.SeparatorMenuItem())
|
||||
|
||||
status_item = Gtk.MenuItem(label="📊 Статус")
|
||||
status_item.connect("activate", lambda i: run_script(["status"]))
|
||||
menu.append(status_item)
|
||||
|
||||
quit_item = Gtk.MenuItem(label="✕ Выход из трея")
|
||||
quit_item.connect("activate", lambda i: Gtk.main_quit())
|
||||
menu.append(quit_item)
|
||||
|
||||
menu.show_all()
|
||||
return menu
|
||||
|
||||
|
||||
def main():
|
||||
indicator = AppIndicator.Indicator.new(
|
||||
"phonestream",
|
||||
"camera-web",
|
||||
AppIndicator.IndicatorCategory.HARDWARE
|
||||
)
|
||||
indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
|
||||
indicator.set_title("PhoneStream")
|
||||
indicator.set_menu(make_menu())
|
||||
|
||||
# Обновление меню каждые 3 сек
|
||||
def refresh(ind):
|
||||
ind.set_menu(make_menu())
|
||||
return True
|
||||
|
||||
GLib.timeout_add_seconds(3, refresh, indicator)
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
Gtk.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/bin/bash
|
||||
# PhoneStream — Redmi Note 9 Pro → виртуальная веб-камера через USB
|
||||
# Использование: ./phonestream.sh [start|stop|status|menu]
|
||||
|
||||
V4L2_DEV="/dev/video20"
|
||||
MODULE="v4l2loopback"
|
||||
PHONE_ID="c3c2c293"
|
||||
|
||||
# Профили: описание|разрешение|fps|битрейт
|
||||
PROFILES=(
|
||||
"Максимум (5MP 4:3)|2608x1960|30|25M"
|
||||
"Широкий (4MP 16:9)|2688x1512|30|20M"
|
||||
"2.5K (5MP 4:3)|2592x1944|30|20M"
|
||||
"Full HD (1080p)|1920x1080|30|10M"
|
||||
"Full HD 60fps|1920x1080|60|15M"
|
||||
"HD 120fps (slow-mo)|1280x720|120|10M"
|
||||
"HD 240fps (slow-mo)|1280x720|240|10M"
|
||||
)
|
||||
|
||||
DEFAULT_PROFILE=0 # Максимум по умолчанию
|
||||
|
||||
# ─── Цвета ───
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
err() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
|
||||
# ─── Проверка зависимостей ───
|
||||
check_deps() {
|
||||
local missing=()
|
||||
for cmd in scrcpy adb v4l2-ctl; do
|
||||
command -v "$cmd" &>/dev/null || missing+=("$cmd")
|
||||
done
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
err "Не найдены: ${missing[*]}"
|
||||
echo " sudo apt install scrcpy v4l2loopback-dkms v4l2loopback-utils ffmpeg adb"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Загрузка v4l2loopback ───
|
||||
load_module() {
|
||||
if lsmod | grep -q "$MODULE"; then
|
||||
log "v4l2loopback уже загружен"
|
||||
return
|
||||
fi
|
||||
log "Загружаю v4l2loopback..."
|
||||
sudo modprobe "$MODULE" devices=1 video_nr=20 card_label="PhoneCam" exclusive_caps=1
|
||||
sleep 1
|
||||
if [ -e "$V4L2_DEV" ]; then
|
||||
log "Виртуальная камера $V4L2_DEV готова"
|
||||
else
|
||||
err "Не удалось создать $V4L2_DEV"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Выгрузка модуля ───
|
||||
unload_module() {
|
||||
if ! lsmod | grep -q "$MODULE"; then
|
||||
log "v4l2loopback не загружен"
|
||||
return
|
||||
fi
|
||||
# Сначала убить все scrcpy
|
||||
pkill -f "scrcpy.*v4l2-sink" 2>/dev/null
|
||||
sleep 1
|
||||
sudo rmmod "$MODULE" 2>/dev/null
|
||||
log "v4l2loopback выгружен"
|
||||
}
|
||||
|
||||
# ─── Проверка телефона ───
|
||||
check_phone() {
|
||||
if ! adb devices 2>/dev/null | grep -q "$PHONE_ID"; then
|
||||
err "Телефон не подключён по USB"
|
||||
echo " 1. Подключи телефон по USB"
|
||||
echo " 2. Включи отладку по USB (Настройки → Для разработчиков)"
|
||||
echo " 3. Разреши отладку на телефоне"
|
||||
exit 1
|
||||
fi
|
||||
log "Телефон подключён: $PHONE_ID"
|
||||
}
|
||||
|
||||
# ─── Запуск стрима ───
|
||||
start_stream() {
|
||||
local idx=${1:-$DEFAULT_PROFILE}
|
||||
local profile="${PROFILES[$idx]}"
|
||||
local res=$(echo "$profile" | cut -d'|' -f2)
|
||||
local fps=$(echo "$profile" | cut -d'|' -f3)
|
||||
local bitrate=$(echo "$profile" | cut -d'|' -f4)
|
||||
local name=$(echo "$profile" | cut -d'|' -f1)
|
||||
|
||||
# Проверяем не запущен ли уже
|
||||
if pgrep -f "scrcpy.*v4l2-sink" >/dev/null 2>&1; then
|
||||
warn "Стрим уже запущен. Останови сначала: $0 stop"
|
||||
return
|
||||
fi
|
||||
|
||||
check_deps
|
||||
load_module
|
||||
check_phone
|
||||
|
||||
# Закрываем камеру на телефоне
|
||||
adb shell am force-stop com.android.camera 2>/dev/null
|
||||
adb shell am force-stop com.android.camera2 2>/dev/null
|
||||
adb shell am force-stop com.google.android.GoogleCamera 2>/dev/null
|
||||
sleep 0.5
|
||||
|
||||
log "Запуск: $name (${res}@${fps}fps, ${bitrate}bps)"
|
||||
|
||||
local high_speed_flag=""
|
||||
if [ "$fps" -gt 60 ]; then
|
||||
high_speed_flag="--camera-high-speed"
|
||||
fi
|
||||
|
||||
scrcpy \
|
||||
--no-audio \
|
||||
--no-playback \
|
||||
--video-source=camera \
|
||||
--camera-id=0 \
|
||||
--camera-size="$res" \
|
||||
--camera-fps="$fps" \
|
||||
--video-bit-rate="$bitrate" \
|
||||
--v4l2-sink="$V4L2_DEV" \
|
||||
$high_speed_flag \
|
||||
2>&1 &
|
||||
|
||||
local pid=$!
|
||||
sleep 3
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
log "Стрим запущен (PID: $pid)"
|
||||
log "Камера доступна: $V4L2_DEV"
|
||||
echo ""
|
||||
echo " Для остановки: $0 stop"
|
||||
else
|
||||
err "Не удалось запустить стрим"
|
||||
wait "$pid" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Остановка ───
|
||||
stop_stream() {
|
||||
pkill -f "scrcpy.*v4l2-sink" 2>/dev/null
|
||||
sleep 1
|
||||
# Принудительно если не закрылся
|
||||
pkill -9 -f "scrcpy.*v4l2-sink" 2>/dev/null
|
||||
log "Стрим остановлен"
|
||||
}
|
||||
|
||||
# ─── Статус ───
|
||||
status() {
|
||||
echo -e "${CYAN}═══ PhoneStream Status ═══${NC}"
|
||||
echo ""
|
||||
|
||||
# v4l2loopback
|
||||
if lsmod | grep -q "$MODULE"; then
|
||||
echo -e " Модуль v4l2loopback: ${GREEN}загружен${NC}"
|
||||
else
|
||||
echo -e " Модуль v4l2loopback: ${RED}не загружен${NC}"
|
||||
fi
|
||||
|
||||
# /dev/video20
|
||||
if [ -e "$V4L2_DEV" ]; then
|
||||
echo -e " Виртуальная камера: ${GREEN}$V4L2_DEV${NC}"
|
||||
local info=$(v4l2-ctl -d "$V4L2_DEV" --all 2>/dev/null | grep "Width/Height" | head -1)
|
||||
if [ -n "$info" ]; then
|
||||
echo -e " Разрешение: ${GREEN}$info${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " Виртуальная камера: ${RED}отсутствует${NC}"
|
||||
fi
|
||||
|
||||
# Телефон
|
||||
if adb devices 2>/dev/null | grep -q "$PHONE_ID"; then
|
||||
echo -e " Телефон: ${GREEN}подключён${NC}"
|
||||
else
|
||||
echo -e " Телефон: ${RED}не подключён${NC}"
|
||||
fi
|
||||
|
||||
# Процесс
|
||||
local pid=$(pgrep -f "scrcpy.*v4l2-sink" 2>/dev/null | head -1)
|
||||
if [ -n "$pid" ]; then
|
||||
echo -e " Стрим: ${GREEN}активен (PID: $pid)${NC}"
|
||||
else
|
||||
echo -e " Стрим: ${RED}не активен${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Меню выбора профиля ───
|
||||
menu() {
|
||||
echo -e "${CYAN}═══ PhoneStream — Выбери профиль ═══${NC}"
|
||||
echo ""
|
||||
for i in "${!PROFILES[@]}"; do
|
||||
local name=$(echo "${PROFILES[$i]}" | cut -d'|' -f1)
|
||||
local res=$(echo "${PROFILES[$i]}" | cut -d'|' -f2)
|
||||
local fps=$(echo "${PROFILES[$i]}" | cut -d'|' -f3)
|
||||
if [ "$i" -eq "$DEFAULT_PROFILE" ]; then
|
||||
echo -e " ${GREEN}$i)${NC} $name ${YELLOW}[по умолчанию]${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}$i)${NC} $name"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
read -p " → Выбор [0]: " choice
|
||||
choice=${choice:-$DEFAULT_PROFILE}
|
||||
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -lt "${#PROFILES[@]}" ]; then
|
||||
start_stream "$choice"
|
||||
else
|
||||
err "Неверный выбор"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Точка входа ───
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
start_stream "$2"
|
||||
;;
|
||||
stop)
|
||||
stop_stream
|
||||
;;
|
||||
restart)
|
||||
stop_stream
|
||||
sleep 1
|
||||
start_stream "$2"
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
menu)
|
||||
menu
|
||||
;;
|
||||
*)
|
||||
echo "Использование: $0 {start|stop|restart|status|menu} [номер_профиля]"
|
||||
echo ""
|
||||
echo " Профили:"
|
||||
for i in "${!PROFILES[@]}"; do
|
||||
local name=$(echo "${PROFILES[$i]}" | cut -d'|' -f1)
|
||||
echo " $i — $name"
|
||||
done
|
||||
echo ""
|
||||
echo " Примеры:"
|
||||
echo " $0 # Запуск с максимальным профилем"
|
||||
echo " $0 start 3 # Запуск Full HD"
|
||||
echo " $0 stop # Остановка"
|
||||
echo " $0 menu # Интерактивный выбор"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user