Shared KMP core and UI, switch to Koin

This commit is contained in:
2026-10-25 20:28:07 +03:00
parent f63b09a9d3
commit 69048452b8
161 changed files with 6626 additions and 4485 deletions

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""L0: как обычный mpv ведёт себя плавающим окном в Cinnamon (эталон для L1/L8).
float_test.py <037|041>
Запускает mpv --ontop --no-border --geometry=640x360-24-24 и по IPC переключает режимы,
после каждого шага печатает геометрию окна, _NET_WM_STATE, рамку (_NET_FRAME_EXTENTS) и активное окно.
"""
import json, os, socket, subprocess, sys, time
from Xlib import X, display
# mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x)
M037 = os.environ.get("MPV037", "mpv")
RUN = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000"), "l0")
os.makedirs(RUN, exist_ok=True)
ver = sys.argv[1]
sock = os.path.join(RUN, f"float{ver}.sock")
if os.path.exists(sock): os.unlink(sock)
item = "00000000000000000000000000000000"
url = f"http://192.168.1.10:8096/Videos/{item}/stream?static=true&mediaSourceId={item}"
opts = ["--config=no", "--mute=yes", "--start=30%", "--hwdec=auto-safe", "--vo=gpu", "--gpu-api=opengl",
"--ontop", "--no-border", "--geometry=640x360-24-24", "--screen=0", "--keep-open=yes",
"--title=L0 float", f"--input-ipc-server={sock}"]
cmd = ([M037] if ver == "037" else ["flatpak", "run", "--filesystem=xdg-run/l0", "io.mpv.Mpv"]) + opts + [url]
d = display.Display(); root = d.screen().root
A = lambda n: d.intern_atom(n)
NET_ACTIVE, NET_STATE, FRAME, NAME = A("_NET_ACTIVE_WINDOW"), A("_NET_WM_STATE"), A("_NET_FRAME_EXTENTS"), A("_NET_WM_NAME")
def active():
p = root.get_full_property(NET_ACTIVE, X.AnyPropertyType)
return p.value[0] if p else None
def find_win():
for _ in range(100):
p = root.get_full_property(A("_NET_CLIENT_LIST"), X.AnyPropertyType)
for wid in (p.value if p else []):
w = d.create_resource_object("window", wid)
n = w.get_full_property(NAME, 0)
if n and b"L0 float" in (n.value if isinstance(n.value, bytes) else n.value.encode()): return w
time.sleep(0.1)
def geom(w):
g = w.get_geometry(); t = w.translate_coords(root, 0, 0)
st = w.get_full_property(NET_STATE, X.AnyPropertyType)
states = [d.get_atom_name(x).replace("_NET_WM_STATE_", "") for x in (st.value if st else [])]
fe = w.get_full_property(FRAME, X.AnyPropertyType)
return {"x": -t.x, "y": -t.y, "w": g.width, "h": g.height, "state": states,
"frame": list(fe.value) if fe else None, "focused": active() == w.id}
class Ipc:
def __init__(self):
for _ in range(200):
try: self.s = socket.socket(socket.AF_UNIX); self.s.connect(sock); break
except OSError: time.sleep(0.05)
self.f = self.s.makefile("rb")
def cmd(self, *a):
self.s.sendall(json.dumps({"command": list(a), "request_id": 1}).encode() + b"\n")
while True:
m = json.loads(self.f.readline())
if m.get("request_id") == 1: return m
before = active()
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ipc = Ipc(); w = find_win(); time.sleep(1.5)
print("active before launch:", hex(before) if before else None)
steps = [("start (ontop, no border)", None),
("ontop=no", ("set", "ontop", "no")),
("border=yes", ("set", "border", "yes")),
("fullscreen=yes", ("set", "fullscreen", "yes")),
("fullscreen=no", ("set", "fullscreen", "no")),
("border=no + ontop=yes", ("set", "border", "no")),
("screen=1 portrait (geometry 640x360-24-24)", None),
("resize 960x540 via geometry", ("set", "geometry", "960x540-24-24"))]
for label, c in steps:
if c: ipc.cmd(*c)
if label.startswith("border=no"): ipc.cmd("set", "ontop", "yes")
if label.startswith("screen=1"):
ipc.cmd("set", "screen", "1"); ipc.cmd("set", "geometry", "640x360-24-24")
time.sleep(1.2)
print(f"{label:45s}", json.dumps(geom(w)))
ipc.cmd("quit"); proc.wait(timeout=10)

15
tools/linux/l0/run_all.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# L0 (LINUX_PLAN.md §3, итоги §6). Нужны: mpv 0.37 ($MPV037 или mpv в PATH), Flatpak io.mpv.Mpv (0.41), python3-xlib для float_test.py.
# Прогон всех файлов: 0.37 и 0.41 в vo=gpu (как render API), HDR/DV ещё в gpu-next 0.41, чересстрочные с деинтерлейсом.
D=$(dirname "$0"); OUT=${L0_OUT:-${XDG_RUNTIME_DIR:-/tmp}/l0/out}; mkdir -p $OUT; R=$OUT/results.jsonl
run() { timeout 150 python3 -I $D/run_case.py "$@" --play 15 >> $R || echo "{\"name\":\"$4\",\"ver\":\"$1\",\"vo\":\"$2\",\"error\":\"timeout\"}" >> $R; }
while read -r name id rest; do
for ver in 037 041; do run $ver gpu $id $name $rest; done
case $name in dv*|hlg*|hdr*|av1_4k*) run 041 gpu-next $id $name $rest;; esac
case $name in dvd_ps_576i|h264_1080i50)
run 037 gpu $id ${name}_deint --extra=--deinterlace=yes
run 041 gpu $id ${name}_deint --extra=--deinterlace=yes
run 041 gpu $id ${name}_deintauto --extra=--deinterlace=auto;;
esac
done < $D/cases.txt
echo DONE

209
tools/linux/l0/run_case.py Normal file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""L0: один прогон файла в mpv с замерами через IPC.
run_case.py <mpv: 037|041> <vo: gpu|gpu-next> <item_id> <name> [--sid N] [--aid N] [--sub-url URL]
[--start SEC] [--play SEC] [--seek SEC] [--extra "--opt=val ..."]
Печатает одну строку JSON с результатами в stdout.
"""
import argparse, json, os, shlex, socket, subprocess, sys, threading, time
OUT = os.environ.get("L0_OUT") or os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "l0", "out")
# mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x)
M037 = os.environ.get("MPV037", "mpv")
SERVER = "http://192.168.1.10:8096"
# Токен-заглушка: сервер отдаёт static-поток и без токена; проверяем, что заголовок доходит и не ломает запрос.
HEADER = 'Authorization: MediaBrowser Token="0123456789abcdef0123456789abcdef"'
p = argparse.ArgumentParser()
p.add_argument("ver"); p.add_argument("vo"); p.add_argument("item"); p.add_argument("name")
p.add_argument("--sid"); p.add_argument("--aid"); p.add_argument("--sub-url")
p.add_argument("--start", type=float); p.add_argument("--play", type=float, default=20)
p.add_argument("--seek", type=float, default=300); p.add_argument("--extra", default="")
a = p.parse_args()
os.makedirs(OUT, exist_ok=True)
tag = f"{a.name}_{a.ver}_{a.vo}"
RUN = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000"), "l0")
os.makedirs(RUN, exist_ok=True)
sock = os.path.join(RUN, f"{a.ver}{a.vo}.sock") # путь сокета ≤ 108 символов
log = os.path.join(OUT, f"{tag}.log")
shot = os.path.join(OUT, f"{tag}.png")
for f in (sock, log, shot):
if os.path.exists(f): os.unlink(f)
url = f"{SERVER}/Videos/{a.item}/stream?static=true&mediaSourceId={a.item}"
opts = [
"--config=no", "--idle=no", "--force-window=yes", "--keep-open=yes",
"--screen=0", "--geometry=640x360-24-24", f"--title=L0 {tag}",
f"--vo={a.vo}", "--gpu-api=opengl", "--gpu-context=x11egl", "--hwdec=auto-safe",
"--mute=yes", "--ao=pipewire,pulse,alsa", "--audio-channels=auto-safe",
"--hr-seek=yes", "--cache=yes", "--demuxer-max-bytes=150MiB", "--demuxer-max-back-bytes=50MiB",
"--osc=no", "--input-default-bindings=no", "--ytdl=no", "--tone-mapping=auto",
f"--http-header-fields={HEADER}",
f"--input-ipc-server={sock}", f"--log-file={log}",
"--screenshot-format=png", "--screenshot-png-compression=1",
]
if a.vo == "gpu-next":
opts = [o for o in opts if not o.startswith("--gpu-api") and not o.startswith("--gpu-context")]
if a.start is not None: opts.append(f"--start={a.start}")
else: opts.append("--start=40%")
if a.sid: opts.append(f"--sid={a.sid}")
if a.aid: opts.append(f"--aid={a.aid}")
if a.sub_url: opts += [f"--sub-files-append={a.sub_url}", "--sid=auto"]
opts += shlex.split(a.extra)
if a.ver == "037":
cmd = [M037] + opts + [url]
else:
cmd = ["flatpak", "run", f"--filesystem={OUT}", "--filesystem=xdg-run/l0", "io.mpv.Mpv"] + opts + [url]
t_launch = time.time()
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def find_pid():
# Хост-PID процесса mpv (во Flatpak он не совпадает с pid из IPC)
for _ in range(100):
out = subprocess.run(["pgrep", "-f", sock], capture_output=True, text=True).stdout.split()
for pid in out:
try:
if open(f"/proc/{pid}/comm").read().strip() in ("mpv", "mpv-bin"): return int(pid)
except OSError: pass
time.sleep(0.05)
return None
class Ipc:
def __init__(self, path):
for _ in range(200):
try:
self.s = socket.socket(socket.AF_UNIX); self.s.connect(path); break
except OSError: time.sleep(0.05)
else: raise RuntimeError("no ipc")
self.buf = b""; self.rid = 0; self.lock = threading.Lock()
def cmd(self, *args):
with self.lock:
self.rid += 1; rid = self.rid
self.s.sendall(json.dumps({"command": list(args), "request_id": rid}).encode() + b"\n")
while True:
while b"\n" not in self.buf:
d = self.s.recv(65536)
if not d: raise RuntimeError("ipc closed")
self.buf += d
line, self.buf = self.buf.split(b"\n", 1)
m = json.loads(line)
if m.get("request_id") == rid: return m.get("data") if m.get("error") == "success" else None
def get(self, prop): return self.cmd("get_property", prop)
def proc_stats(pid):
try:
st = open(f"/proc/{pid}/stat").read().rsplit(")", 1)[1].split()
cpu = (int(st[11]) + int(st[12])) / os.sysconf("SC_CLK_TCK")
except OSError: cpu = None
try:
io = dict(l.split(": ") for l in open(f"/proc/{pid}/io").read().splitlines())
rchar = int(io["rchar"])
except (OSError, KeyError): rchar = None
return cpu, rchar
def gpu_sampler(stop, samples):
q = subprocess.Popen(["nvidia-smi", "--query-gpu=utilization.gpu,utilization.decoder",
"--format=csv,noheader,nounits", "-lms", "250"],
stdout=subprocess.PIPE, text=True)
while not stop.is_set():
line = q.stdout.readline()
if not line: break
try: samples.append([int(x) for x in line.split(",")])
except ValueError: pass
q.terminate()
res = {"name": a.name, "ver": a.ver, "vo": a.vo, "item": a.item}
try:
ipc = Ipc(sock)
pid = find_pid()
# Первый кадр: VO получил параметры и время пошло
first = None
for _ in range(600):
if ipc.get("video-out-params") and ipc.get("time-pos") is not None:
first = time.time() - t_launch; break
if proc.poll() is not None: break
time.sleep(0.05)
res["first_frame_s"] = round(first, 2) if first else None
def counters():
return {k: ipc.get(k) for k in ("frame-drop-count", "decoder-frame-drop-count",
"vo-delayed-frame-count", "mistimed-frame-count")}
def window(sec, label):
global pid
time.sleep(1.5)
if pid is None: pid = find_pid() # Flatpak стартует дольше, чем ждёт find_pid
c0 = counters(); cpu0, r0 = proc_stats(pid); t0 = time.time(); pos0 = ipc.get("time-pos") or 0
stop = threading.Event(); gs = []
th = threading.Thread(target=gpu_sampler, args=(stop, gs)); th.start()
av, pauses, was_paused, speeds = [], 0, False, []
while time.time() - t0 < sec:
cs = ipc.get("cache-speed")
if cs is not None: speeds.append(cs)
v = ipc.get("avsync")
if v is not None: av.append(abs(v))
pc = bool(ipc.get("paused-for-cache"))
if pc and not was_paused: pauses += 1
was_paused = pc
time.sleep(0.2)
stop.set(); th.join()
c1 = counters(); cpu1, r1 = proc_stats(pid); dt = time.time() - t0; pos1 = ipc.get("time-pos") or 0
w = {k.replace("-count", ""): (c1[k] - c0[k]) if c0[k] is not None and c1[k] is not None else None for k in c0}
w["played_s"] = round(pos1 - pos0, 1); w["wall_s"] = round(dt, 1)
w["cpu_pct"] = round(100 * (cpu1 - cpu0) / dt) if cpu0 is not None and cpu1 is not None else None
w["net_mbit"] = round(sum(speeds) * 8 / len(speeds) / 1e6, 1) if speeds else None
w["avsync_max_ms"] = round(1000 * max(av), 1) if av else None
w["cache_pauses"] = pauses
if gs:
w["gpu_pct"] = round(sum(x[0] for x in gs) / len(gs)); w["nvdec_pct"] = round(sum(x[1] for x in gs) / len(gs))
res[label] = w
window(a.play, "play")
info = {}
for k in ("hwdec-current", "video-codec", "video-format", "container-fps", "estimated-vf-fps", "display-fps",
"audio-codec-name", "current-ao", "seekable", "duration", "file-format", "vf"):
info[k] = ipc.get(k)
vp = ipc.get("video-params") or {}
info["video-params"] = {k: vp.get(k) for k in ("pixelformat", "w", "h", "colormatrix", "primaries", "gamma", "sig-peak", "light")}
vop = ipc.get("video-out-params") or {}
info["video-out-params"] = {k: vop.get(k) for k in ("pixelformat", "gamma", "primaries", "sig-peak")}
ap = ipc.get("audio-params") or {}
info["audio-params"] = {k: ap.get(k) for k in ("format", "samplerate", "channel-count", "hr-channels")}
tl = ipc.get("track-list") or []
info["tracks"] = [f"{t['type'][0]}{t['id']}{'*' if t.get('selected') else ''}:{t.get('codec')}{'(ext)' if t.get('external') else ''}" for t in tl]
res["info"] = info
ipc.cmd("screenshot-to-file", shot, "window")
# Перемотка вперёд (точная, как в приложении)
pos = ipc.get("time-pos") or 0
t0 = time.time()
ipc.cmd("seek", str(a.seek), "relative+exact")
seek_done = None
time.sleep(0.1)
for _ in range(600):
tp = ipc.get("time-pos")
if not ipc.get("seeking") and not ipc.get("paused-for-cache") and tp is not None and tp > pos + a.seek - 1:
# дождаться, что время пошло
t1 = ipc.get("time-pos"); time.sleep(0.25); t2 = ipc.get("time-pos")
if t2 and t1 and t2 > t1: seek_done = time.time() - t0 - 0.25; break
time.sleep(0.05)
res["seek_s"] = round(seek_done, 2) if seek_done else None
window(10, "after_seek")
ipc.cmd("quit")
except Exception as e:
res["error"] = repr(e)
try: proc.wait(timeout=10)
except subprocess.TimeoutExpired: proc.kill()
# Предупреждения и ошибки из лога
errs = []
try:
for line in open(log, errors="replace"):
parts = line.split(" ", 3)
if "][e][" in line[:24] or "][w][" in line[:24]:
errs.append(line.strip()[:200])
except OSError: pass
res["log_warn"] = errs[:15]; res["log_warn_n"] = len(errs)
print(json.dumps(res, ensure_ascii=False))

View File

@@ -3,18 +3,22 @@
Builds the app's logo resources from the source picture design/logo/sleepyowl.png (owl over the "SleepyOwl"
wordmark on a flat navy background, 1254×1254):
drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on the logo's navy
drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is @color/ic_launcher_background
drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
app/src/main/res/drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on navy
app/src/main/res/drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is
@color/ic_launcher_background
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
classpath.
The navy is keyed out ("color to alpha"): the glow stays as translucent amber, so the owl and the wordmark sit on the
app's own dark background without a square. The owl's body is made opaque so it does not take the colour of what is
behind it. The in-app pictures are made at the TV's density (xhdpi, 2 px per dp) and at the size they are drawn
(see ui/components/Brand.kt), so they are not resampled on the TV.
(see ui/components/Brand.kt in ui:shared), so they are not resampled on the TV.
Usage:
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--res app/src/main/res] [--preview DIR]
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--root REPO] [--preview DIR]
"""
import argparse
import os
@@ -27,6 +31,9 @@ BG = np.array([5, 9, 39]) / 255 # the logo's background
DEAD_ZONE = 7 / 255 # background noise and vignette, keyed out as well
OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882
WORD_ROWS = (870, 1080)
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
APP_RES = "app/src/main/res/"
SHARED_RES = "ui/shared/src/androidMain/res/"
DP = 2 # xhdpi
@@ -107,7 +114,7 @@ def build(src):
word_b = fit(word, 32 * DP)
gap = 6 * DP
x = (bw - owl_b.width - gap - word_b.width) // 2
out["drawable-xhdpi/banner.png"] = on_navy(
out[APP_RES + "drawable-xhdpi/banner.png"] = on_navy(
(bw, bh),
(owl_b, (x, (bh - owl_b.height) // 2)),
(word_b, (x + owl_b.width + gap, (bh - word_b.height) // 2 + 2 * DP)),
@@ -119,11 +126,11 @@ def build(src):
owl_i = fit(owl, round(owl.height * scale))
fg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
out["drawable-xxxhdpi/ic_launcher_owl.png"] = fg
out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
# In-app marks, drawn at exactly these sizes (Brand.kt).
out["drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
out["drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
return out
@@ -131,12 +138,12 @@ def main():
root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--src", default=os.path.join(root, "design", "logo", "sleepyowl.png"))
p.add_argument("--res", default=os.path.join(root, "app", "src", "main", "res"))
p.add_argument("--root", default=root, help="repository root the resource paths are relative to")
p.add_argument("--preview", help="also write the pictures over the app's background into this directory")
args = p.parse_args()
for path, img in build(args.src).items():
dst = os.path.join(args.res, path)
dst = os.path.join(args.root, path)
os.makedirs(os.path.dirname(dst), exist_ok=True)
img.save(dst, optimize=True)
print(f"{dst} {img.width}×{img.height}")