mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-19 14:01:13 +02:00
- assPod Model on the main menu switches between Video Gen 1 and Nano Gen 2; Color lists the current body's finishes (the nano: silver, green, blue, pink, black, (PRODUCT)RED). The choice is kept in ~/.config/glass-shell/asspod.json beside the glass tune. - The nano: a procedural 2nd-generation model (tools/nano2-model) exported as render models, drawn by podd with anodized aluminum (tinted, bead-blasted, the laser-etched back paler), white end caps and a clickable wheel. Same menus on a 240 x 180 logical screen, so its text is larger, as on the real one; it plays no video. Shown 52.5 mm wide. - podd: one box layout that holds either body, so a switch needs no restart; 64 px between atlas tiles and a 4 px clear band around each eye's image, which removes the hairline rectangle around the body (filtering at the stereo split took the other eye's edge); texture dump and debug modes. - The left stick acts like the right: Steam's d-pad directions are no longer relayed (tilting the left stick down or left pressed the centre button or Menu).
1328 lines
54 KiB
Python
1328 lines
54 KiB
Python
"""assPod: the player behind the bar's music note (device/rt/34-asspod.js, flag assPod).
|
|
|
|
The assPod itself (screen, click wheel, its place in your hand, the dimmed menu) runs in SteamVR's
|
|
systemui page (device/vr/systemui.asspod.js, at window.__LGS_VRX.asspod.api). This plugin is its
|
|
other half, the "daemon" it plays through:
|
|
|
|
- VLC, started on demand (`vlc -I http`, 127.0.0.1 only, a random password, no video window,
|
|
stopping after each item: the assPod's own player keeps the queue). The assPod shows its boot
|
|
logo while VLC is starting, and only then. When the assPod is put away and nothing is playing,
|
|
VLC is stopped again.
|
|
- The library: VLC's media library, i.e. its saved Media Library list (ml.xspf) and its My Music
|
|
and My Videos folders (the XDG music and videos folders), plus playlist files found there.
|
|
ffprobe reads the tags; ffmpeg pulls covers (embedded, or cover.jpg / folder.jpg beside the
|
|
files) and video thumbnails, on request. Results are cached in RAM (/tmp), never on disk.
|
|
- A poll of the page while the assPod is out (10 a second) or something plays (2 a second): it
|
|
takes the page's commands for VLC and hands back VLC's state.
|
|
- Steam's side: while the assPod is out, Steam's runtime swallows gamepad input and relays A and B
|
|
here (asspod.btn); the bridge value 'asspod' {open, hand, at, ttlMs} tells it when (a stale value
|
|
ends the swallowing on its own, so a dead daemon never leaves Steam's input taken).
|
|
|
|
Actions
|
|
asspod.open (ui) args {}: VLC up, library loaded, the assPod out in the laser's hand
|
|
asspod.close (ui) args {}
|
|
asspod.btn (ui) args {b: ok|menu|close, side, down}: Steam's A / B / hamburger while the assPod is out
|
|
asspod.status (read)
|
|
"""
|
|
import asyncio
|
|
import base64
|
|
import collections
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import time
|
|
import urllib.parse
|
|
import xml.etree.ElementTree as ET
|
|
|
|
ACTIONS = {
|
|
"asspod.open": {"args": {}, "sources": ["bar", "barpopup", "main"], "rate": 0.5, "kind": "ui",
|
|
"flag": "assPod", "timeout": 40},
|
|
"asspod.close": {"args": {}, "sources": ["bar", "barpopup", "main"], "rate": 0.2, "kind": "ui",
|
|
"flag": "assPod", "timeout": 10},
|
|
"asspod.btn": {"args": {"b": {"type": "enum", "values": ["ok", "menu", "close"]},
|
|
"side": {"type": "enum", "values": ["left", "right"]}, "down": {"type": "bool"}},
|
|
"sources": "*", "rate": 0.02, "kind": "ui", "flag": "assPod", "timeout": 5, "global": False},
|
|
"asspod.status": {"args": {}, "sources": "*", "rate": 0.2, "kind": "read", "flag": "assPod", "timeout": 10},
|
|
}
|
|
|
|
PAGE = "systemui"
|
|
MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "asspod", "model") # the 3D body
|
|
PODD = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))), "native", "podd", "podd")
|
|
PODD_PREBUILT = os.path.join(os.path.dirname(PODD), "prebuilt", "podd")
|
|
PODD_SPEC = "/dev/shm/lgs/podd.json"
|
|
PODD_OUT = "/dev/shm/lgs/podd-out.json"
|
|
# the bodies podd draws and their colours (device/asspod/src/models.js, native/podd/podd.cpp)
|
|
PODD_FINISHES = {"video": ("black", "white"), "nano": ("silver", "green", "blue", "pink", "black", "red")}
|
|
NANO_SCALE = 1.3125 # the nano as shown (models.js MODELS.nano.scale)
|
|
# The body you picked (assPod Model) and each body's colour, kept across launches beside the glass
|
|
# tune (lgs.TUNE): {"model": "video"|"nano", "finish": <the Video's>, "nanoFinish": <the nano's>}.
|
|
# Written only when you change them; uninstall removes it with the folder.
|
|
PREFS = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "glass-shell", "asspod.json")
|
|
PREFS_DEFAULT = {"model": "video", "finish": "black", "nanoFinish": "silver"}
|
|
API = "window.__LGS_VRX && window.__LGS_VRX.asspod && window.__LGS_VRX.asspod.api"
|
|
HOME = os.path.expanduser("~")
|
|
FLATPAK_VLC = "org.videolan.VLC"
|
|
FFPROBE = shutil.which("ffprobe") or "/usr/bin/ffprobe"
|
|
FFMPEG = shutil.which("ffmpeg") or "/usr/bin/ffmpeg"
|
|
CACHE = "/tmp/lgs/asspod-tags.json" # ffprobe results by path, size and mtime (RAM; gone at reboot)
|
|
CACHE_VERSION = 4 # bump when _entry() files things differently (re-reads every file)
|
|
AUDIO = {".mp3", ".m4a", ".m4b", ".aac", ".flac", ".ogg", ".oga", ".opus", ".wav", ".wma", ".aif", ".aiff",
|
|
".alac", ".ape", ".mka", ".mpc", ".wv"}
|
|
VIDEO = {".mp4", ".m4v", ".mkv", ".webm", ".avi", ".mov", ".wmv", ".mpg", ".mpeg", ".ts", ".m2ts", ".3gp",
|
|
".flv", ".ogv"}
|
|
PLAYLISTS = {".m3u", ".m3u8", ".xspf", ".pls"}
|
|
COVERS = ("cover", "folder", "front", "albumart", "album", "albumartsmall")
|
|
EPISODE = re.compile(r"^(.*?)[ ._-]*S(\d{1,2})[ ._-]?E(\d{1,3})", re.I) # Show.Name.S01E02
|
|
MAX_FILES = 6000
|
|
MAX_DEPTH = 8
|
|
PROBE_JOBS = 4
|
|
RESCAN_S = 60.0 # a new open rescans (cheap with the cache) once this old
|
|
VLC_START_S = 15.0
|
|
POLL_OPEN_S = 0.1
|
|
POLL_BG_S = 0.5
|
|
IDLE_STOP_S = 4.0 # away and not playing this long: VLC stops
|
|
BRIDGE_TTL_MS = 5000
|
|
BRIDGE_BEAT_S = 1.5
|
|
ART_MAX = 300 # extracted images kept in memory
|
|
# VLC's equalizer presets, in VLC's order (the page's Settings > EQ uses the same names)
|
|
EQ_PRESETS = ["Flat", "Classical", "Club", "Dance", "Full Bass", "Full Bass & Treble", "Full Treble", "Headphones",
|
|
"Large Hall", "Live", "Party", "Pop", "Reggae", "Rock", "Ska", "Soft", "Soft Rock", "Techno"]
|
|
|
|
|
|
# ------------------------------------------------------------------ helpers
|
|
|
|
def _st(ctx):
|
|
s = ctx.state
|
|
if not s:
|
|
s.update({
|
|
"vlc": None, "port": 0, "pw": "", "vlc_path": None, "vlc_state": "stopped", "vlc_status": None,
|
|
"starting": None, "lock": asyncio.Lock(), "poll": None, "open": False, "hand": None,
|
|
"playing": False, "idle_since": None, "lib": None, "lib_at": 0.0, "lib_sig": None, "lib_task": None,
|
|
"art_src": {}, "art": collections.OrderedDict(), "art_jobs": set(), "vol": None, "eq": None,
|
|
"bridge_at": 0.0, "log": collections.deque(maxlen=30), "errors": collections.deque(maxlen=10),
|
|
"page_lib": None, "polls": 0, "paths": set(), "podd": None, "podd_spec": None,
|
|
})
|
|
return s
|
|
|
|
|
|
def _log(ctx, msg):
|
|
s = _st(ctx)
|
|
s["log"].append(time.strftime("%H:%M:%S ") + msg)
|
|
ctx.log(msg)
|
|
|
|
|
|
def _err(ctx, msg):
|
|
s = _st(ctx)
|
|
s["errors"].append(time.strftime("%H:%M:%S ") + msg)
|
|
ctx.log("error: " + msg)
|
|
|
|
|
|
def _pdeathsig():
|
|
try:
|
|
import ctypes
|
|
ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, int(signal.SIGTERM)) # PR_SET_PDEATHSIG
|
|
except Exception: # noqa: BLE001 - best effort
|
|
pass
|
|
|
|
|
|
def _vlc_cmd_line():
|
|
"""How to run VLC here: the system's vlc, else the Flathub app (the installer offers it), else None."""
|
|
v = shutil.which("vlc")
|
|
if v:
|
|
return [v]
|
|
fp = shutil.which("flatpak")
|
|
if fp:
|
|
try:
|
|
import subprocess
|
|
if subprocess.run([fp, "info", FLATPAK_VLC], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
timeout=10).returncode == 0:
|
|
return [fp, "run", "--command=vlc", FLATPAK_VLC]
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _free_port():
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sk:
|
|
sk.bind(("127.0.0.1", 0))
|
|
return sk.getsockname()[1]
|
|
|
|
|
|
def _uri(path):
|
|
return "file://" + urllib.parse.quote(path)
|
|
|
|
|
|
def _id(path, prefix):
|
|
return prefix + hashlib.sha1(path.encode("utf-8", "surrogateescape")).hexdigest()[:14]
|
|
|
|
|
|
async def _page(ctx, js, timeout=10):
|
|
"""Evaluates `js` (an expression over `A`, the page's api) in systemui; None without the api."""
|
|
r = await ctx.vr_eval(PAGE, f"(() => {{ const A = {API}; if (!A) return null; return JSON.stringify(({js})); }})()", timeout)
|
|
return json.loads(r) if isinstance(r, str) else None
|
|
|
|
|
|
async def _bridge(ctx, open_):
|
|
s = _st(ctx)
|
|
s["bridge_at"] = time.monotonic()
|
|
v = {"open": bool(open_), "hand": s["hand"] if open_ else None, "at": int(time.time() * 1000), "ttlMs": BRIDGE_TTL_MS}
|
|
try:
|
|
await ctx.steam_eval(f"(window.__LGS_RT && window.__LGS_RT.bridge.set('asspod', {json.dumps(v)}), 0)", 5)
|
|
except Exception as e: # noqa: BLE001 - Steam reloading; the TTL covers it
|
|
_err(ctx, f"bridge: {e!r}")
|
|
|
|
|
|
# ------------------------------------------------------------------ VLC
|
|
|
|
async def _http(ctx, params, timeout=3.0):
|
|
"""One call of VLC's HTTP interface (requests/status.json); its JSON, or None."""
|
|
s = _st(ctx)
|
|
if not s["port"]:
|
|
return None
|
|
q = urllib.parse.urlencode(params, doseq=True, quote_via=urllib.parse.quote)
|
|
req = (f"GET /requests/status.json{'?' + q if q else ''} HTTP/1.0\r\nHost: 127.0.0.1\r\n"
|
|
f"Authorization: Basic {base64.b64encode((':' + s['pw']).encode()).decode()}\r\n\r\n")
|
|
try:
|
|
r, w = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", s["port"]), timeout)
|
|
try:
|
|
w.write(req.encode())
|
|
await w.drain()
|
|
data = await asyncio.wait_for(r.read(), timeout)
|
|
finally:
|
|
w.close()
|
|
except (OSError, asyncio.TimeoutError):
|
|
return None
|
|
head, _, body = data.partition(b"\r\n\r\n")
|
|
if b" 200 " not in head.split(b"\r\n", 1)[0]:
|
|
return None
|
|
try:
|
|
return json.loads(body.decode("utf-8", "replace"))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _vlc_running(s):
|
|
p = s["vlc"]
|
|
return p is not None and p.returncode is None
|
|
|
|
|
|
async def _start_vlc(ctx):
|
|
"""VLC up and answering; the page shows the boot logo meanwhile. True when ready."""
|
|
s = _st(ctx)
|
|
if _vlc_running(s) and s["port"]:
|
|
return True
|
|
if s["starting"] is not None:
|
|
return await s["starting"]
|
|
fut = asyncio.get_running_loop().create_future()
|
|
s["starting"] = fut
|
|
ok = False
|
|
try:
|
|
try:
|
|
await _page(ctx, "A.setLoading(true)")
|
|
except Exception: # noqa: BLE001 - the logo is cosmetic
|
|
pass
|
|
base = await asyncio.get_running_loop().run_in_executor(None, _vlc_cmd_line)
|
|
if base is None:
|
|
_err(ctx, "VLC is not installed")
|
|
try:
|
|
await _page(ctx, "A.notice('VLC Needed', 'The assPod plays your music with VLC. Install it from Discover in Desktop Mode, or run the LiquidAss installer again.')")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return False
|
|
s["port"] = _free_port()
|
|
s["pw"] = secrets.token_hex(12)
|
|
args = base + ["-I", "http", "--http-host", "127.0.0.1", "--http-port", str(s["port"]),
|
|
"--http-password", s["pw"], "--no-video", "--no-video-title-show", "--play-and-stop",
|
|
"--no-playlist-autostart", "--no-random", "--no-loop", "--no-repeat", "--no-media-library",
|
|
"--no-metadata-network-access", "--no-one-instance", "--no-snapshot-preview", "--quiet"]
|
|
env = dict(os.environ)
|
|
env.pop("DISPLAY", None)
|
|
env.pop("WAYLAND_DISPLAY", None)
|
|
s["vlc"] = await asyncio.create_subprocess_exec(*args, stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
env=env, preexec_fn=_pdeathsig)
|
|
s["vlc_path"], s["vlc_state"], s["vol"], s["eq"] = None, "stopped", None, None
|
|
deadline = time.monotonic() + VLC_START_S
|
|
while time.monotonic() < deadline:
|
|
if not _vlc_running(s):
|
|
break
|
|
if await _http(ctx, {}) is not None:
|
|
ok = True
|
|
break
|
|
await asyncio.sleep(0.15)
|
|
if ok:
|
|
_log(ctx, f"vlc up (pid {s['vlc'].pid}, port {s['port']})")
|
|
else:
|
|
_err(ctx, "vlc did not start")
|
|
await _stop_vlc(ctx)
|
|
except Exception as e: # noqa: BLE001
|
|
_err(ctx, f"vlc start: {e!r}")
|
|
await _stop_vlc(ctx)
|
|
finally:
|
|
s["starting"] = None
|
|
fut.set_result(ok)
|
|
try:
|
|
await _page(ctx, "A.setLoading(false)")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return ok
|
|
|
|
|
|
async def _stop_vlc(ctx):
|
|
s = _st(ctx)
|
|
p = s["vlc"]
|
|
s["vlc"], s["port"], s["vlc_path"], s["vlc_state"], s["vlc_status"] = None, 0, None, "stopped", None
|
|
if p is None or p.returncode is not None:
|
|
return
|
|
try:
|
|
p.terminate()
|
|
await asyncio.wait_for(p.wait(), 3)
|
|
except asyncio.TimeoutError:
|
|
try:
|
|
p.kill()
|
|
await p.wait()
|
|
except ProcessLookupError:
|
|
pass
|
|
except ProcessLookupError:
|
|
pass
|
|
_log(ctx, "vlc stopped")
|
|
|
|
|
|
async def _vlc_cmd(ctx, cmd):
|
|
"""One command from the page."""
|
|
s = _st(ctx)
|
|
op = cmd.get("op")
|
|
if op == "art":
|
|
k = cmd.get("key")
|
|
if isinstance(k, str) and k not in s["art_jobs"]:
|
|
s["art_jobs"].add(k)
|
|
asyncio.get_running_loop().create_task(_send_art(ctx, k))
|
|
return
|
|
if op == "play":
|
|
path = cmd.get("path")
|
|
if not isinstance(path, str) or path not in s["paths"] or not os.path.isfile(path):
|
|
_err(ctx, f"play: not in the library: {str(path)[:80]}")
|
|
return
|
|
if not await _start_vlc(ctx):
|
|
return
|
|
at = max(0.0, float(cmd.get("at") or 0))
|
|
# until VLC gets there its reports still carry the old place (_vlc_report)
|
|
s["vlc_target"] = (at, time.monotonic() + VLC_SETTLE_S)
|
|
if s["vlc_path"] == path and s["vlc_state"] in ("playing", "paused"):
|
|
await _http(ctx, {"command": "seek", "val": str(int(round(at)))})
|
|
await _http(ctx, {"command": "pl_forceresume"})
|
|
else:
|
|
await _http(ctx, {"command": "pl_empty"})
|
|
params = {"command": "in_play", "input": _uri(path)}
|
|
if at >= 0.5:
|
|
params["option"] = f"start-time={at:.2f}"
|
|
await _http(ctx, params)
|
|
s["vlc_path"] = path
|
|
s["vlc_state"] = "playing"
|
|
await _apply_audio(ctx, force=True)
|
|
return
|
|
if not _vlc_running(s):
|
|
return
|
|
if op == "pause":
|
|
s["vlc_target"] = None
|
|
await _http(ctx, {"command": "pl_forcepause"})
|
|
elif op == "volume":
|
|
await _apply_audio(ctx)
|
|
elif op == "eq":
|
|
await _apply_audio(ctx)
|
|
|
|
|
|
async def _apply_audio(ctx, force=False):
|
|
"""VLC's volume and EQ to the page's (the last poll's values)."""
|
|
s = _st(ctx)
|
|
want_vol, want_eq = s.get("want_vol"), s.get("want_eq")
|
|
if want_vol is not None and (force or want_vol != s["vol"]):
|
|
await _http(ctx, {"command": "volume", "val": str(int(round(want_vol * 256)))})
|
|
s["vol"] = want_vol
|
|
if want_eq is not None and (force or want_eq != s["eq"]):
|
|
if want_eq in EQ_PRESETS:
|
|
await _http(ctx, {"command": "enableeq", "val": "1"})
|
|
await _http(ctx, {"command": "setpreset", "val": str(EQ_PRESETS.index(want_eq))})
|
|
else:
|
|
await _http(ctx, {"command": "enableeq", "val": "0"})
|
|
s["eq"] = want_eq
|
|
|
|
|
|
VLC_SETTLE_S = 2.0 # longest a play or seek takes to show in VLC's reports
|
|
|
|
|
|
def _vlc_report(s, j):
|
|
"""VLC's status.json -> what the page's engine reads."""
|
|
if not isinstance(j, dict):
|
|
return None
|
|
length = float(j.get("length") or 0)
|
|
pos = float(j.get("position") or 0)
|
|
t = pos * length if length > 0 else float(j.get("time") or 0)
|
|
state = j.get("state") or "stopped"
|
|
s["vlc_state"] = state
|
|
# just after a play or a seek VLC still reports where it was: the target stands in until it arrives
|
|
tgt = s.get("vlc_target")
|
|
if tgt is not None:
|
|
if abs(t - tgt[0]) < 2 or time.monotonic() > tgt[1]:
|
|
s["vlc_target"] = None
|
|
else:
|
|
t, state = tgt[0], "playing"
|
|
return {"state": state, "path": s["vlc_path"], "time": round(t, 3), "length": length,
|
|
"at": int(time.time() * 1000)}
|
|
|
|
|
|
# ------------------------------------------------------------------ video pictures
|
|
# VLC plays a video's sound (--no-video: no window anywhere). Its picture for the assPod's screen comes
|
|
# from ffmpeg, decoding the same file into frames that follow VLC's clock: frames are taken as VLC's
|
|
# position reaches them (so a pause holds the decode), and a jump of more than VIDEO_RESYNC_S (a seek,
|
|
# Previous, the next video) starts the decode again at VLC's position. With podd running the frames are
|
|
# raw RGBA written to PODD_VIDEO, which podd puts in its texture under the screen (full rate); without
|
|
# it they are small JPEGs sent into SteamVR's page (which only manages about half the rate).
|
|
|
|
VIDEO_FPS = 24
|
|
VIDEO_INFLIGHT = 3 # frames on their way into the page at once
|
|
VIDEO_W = 320 # the screen's own width (its 2x backing store would cost 4x the decode and redraw)
|
|
VIDEO_RESYNC_S = 1.0
|
|
VIDEO_DRIFT_S = 0.5
|
|
JPEG_SOI = bytes([0xFF, 0xD8])
|
|
JPEG_EOI = bytes([0xFF, 0xD9])
|
|
PODD_VIDEO = "/dev/shm/lgs/podd-video"
|
|
PODD_VIDEO_W, PODD_VIDEO_H = 320, 240 # podd's video tile (native/podd/podd.cpp VIDEO_W/H)
|
|
PODD_VIDEO_HDR = b"PVID"
|
|
|
|
|
|
def _vlc_time(s):
|
|
"""VLC's position now, as a steady clock: VLC's own reports jitter by tens of ms (and the polls
|
|
reach it late), so the clock runs on its own and only follows VLC when it strays by more than
|
|
VIDEO_DRIFT_S (a seek, a pause, the next video, or real drift)."""
|
|
st = s.get("vlc_status") or {}
|
|
t = float(st.get("time") or 0)
|
|
playing = st.get("state") == "playing"
|
|
if playing:
|
|
t += max(0.0, time.time() - float(st.get("at") or 0) / 1000)
|
|
c = s.get("video_clock")
|
|
now = time.monotonic()
|
|
if c is not None and c[2] == playing:
|
|
est = c[0] + (now - c[1] if playing else 0.0)
|
|
if abs(est - t) < VIDEO_DRIFT_S:
|
|
return est
|
|
s["video_clock"] = (t, now, playing)
|
|
return t
|
|
|
|
|
|
async def _reap(p):
|
|
"""A killed decoder's end. Its stdout must be read to the end too: asyncio only reports the exit
|
|
once every pipe has closed, and the frames still in the pipe keep it open (wait() alone hangs,
|
|
which froze the picture after a seek)."""
|
|
try:
|
|
await asyncio.wait_for(p.communicate(), 3)
|
|
except (asyncio.TimeoutError, ProcessLookupError, ValueError):
|
|
pass
|
|
|
|
|
|
async def _video_stop(s):
|
|
v = s.get("video")
|
|
s["video"] = None
|
|
if v and v["proc"].returncode is None:
|
|
try:
|
|
v["proc"].kill()
|
|
await _reap(v["proc"])
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
def _podd_video(s):
|
|
"""podd is up to show the picture."""
|
|
p = s.get("podd")
|
|
return p is not None and p.returncode is None and s.get("podd_video")
|
|
|
|
|
|
def _write_podd_frame(seq, frame):
|
|
tmp = PODD_VIDEO + ".tmp"
|
|
with open(tmp, "wb") as f:
|
|
f.write(PODD_VIDEO_HDR + struct.pack("<III", seq, PODD_VIDEO_W, PODD_VIDEO_H) + frame)
|
|
os.replace(tmp, PODD_VIDEO)
|
|
|
|
|
|
def _remove_podd_frame():
|
|
try:
|
|
os.remove(PODD_VIDEO)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
async def _send_frame(ctx, path, frame):
|
|
try:
|
|
await _page(ctx, f"A.videoFrame({json.dumps(path)}, {json.dumps(base64.b64encode(frame).decode())})", 3)
|
|
except Exception: # noqa: BLE001 - the page is busy or reloading; the next frame will do
|
|
pass
|
|
|
|
|
|
async def _video_frames(ctx, path):
|
|
"""Runs while the assPod shows `path`: decodes from VLC's position and sends the frame VLC is at."""
|
|
s = _st(ctx)
|
|
while s.get("video_want") == path:
|
|
start = _vlc_time(s)
|
|
raw = _podd_video(s)
|
|
W, H = PODD_VIDEO_W, PODD_VIDEO_H
|
|
if raw:
|
|
out = ["-vf", f"fps={VIDEO_FPS},scale={W}:{H}:force_original_aspect_ratio=decrease,pad={W}:{H}:(ow-iw)/2:(oh-ih)/2",
|
|
"-f", "rawvideo", "-pix_fmt", "rgba", "pipe:1"]
|
|
else:
|
|
out = ["-vf", f"fps={VIDEO_FPS},scale={VIDEO_W}:-2", "-f", "image2pipe", "-c:v", "mjpeg", "-q:v", "6", "pipe:1"]
|
|
p = await asyncio.create_subprocess_exec(
|
|
FFMPEG, "-v", "error", "-ss", f"{start:.3f}", "-i", path, "-an", "-sn", *out,
|
|
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
preexec_fn=_pdeathsig)
|
|
s["video"] = {"proc": p, "path": path}
|
|
s["video_stage"] = f"decode from {start:.2f}"
|
|
buf = b""
|
|
n = 0
|
|
resync = False
|
|
inflight = set()
|
|
try:
|
|
while s.get("video_want") == path and not resync:
|
|
chunk = await p.stdout.read(65536)
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
while True:
|
|
if raw:
|
|
size = W * H * 4
|
|
if len(buf) < size:
|
|
break
|
|
frame, buf = buf[:size], buf[size:]
|
|
else:
|
|
a = buf.find(JPEG_SOI) # a JPEG starts ...
|
|
b = buf.find(JPEG_EOI, a + 2) if a >= 0 else -1 # ... and ends
|
|
if a < 0 or b < 0:
|
|
break
|
|
frame, buf = buf[a:b + 2], buf[b + 2:]
|
|
ft = start + n / VIDEO_FPS
|
|
n += 1
|
|
# wait for VLC to reach this frame (paused: wait here); far off: start again there
|
|
while s.get("video_want") == path:
|
|
now_t = _vlc_time(s)
|
|
if abs(now_t - ft) > VIDEO_RESYNC_S:
|
|
s["video_resyncs"] = s.get("video_resyncs", 0) + 1
|
|
resync = True
|
|
break
|
|
if ft <= now_t + 0.5 / VIDEO_FPS:
|
|
break
|
|
await asyncio.sleep(min(0.04, ft - now_t))
|
|
if resync or s.get("video_want") != path:
|
|
break
|
|
if ft < _vlc_time(s) - 1.5 / VIDEO_FPS:
|
|
s["video_skipped"] = s.get("video_skipped", 0) + 1
|
|
continue # behind: skip to catch up
|
|
if raw:
|
|
_write_podd_frame(n, frame)
|
|
s["video_sent"] = s.get("video_sent", 0) + 1
|
|
continue
|
|
# sent without waiting for the page's answer (a round trip into SteamVR's page takes
|
|
# longer than a frame): up to VIDEO_INFLIGHT at once; past that, this frame is dropped
|
|
inflight = {t for t in inflight if not t.done()}
|
|
if len(inflight) < VIDEO_INFLIGHT:
|
|
inflight.add(asyncio.get_running_loop().create_task(_send_frame(ctx, path, frame)))
|
|
finally:
|
|
if p.returncode is None:
|
|
try:
|
|
p.kill()
|
|
await _reap(p)
|
|
except ProcessLookupError:
|
|
pass
|
|
s["video_stage"] = f"ended (resync {resync}, rc {p.returncode})"
|
|
if not resync:
|
|
# the file ended or ffmpeg stopped: wait for VLC to move somewhere else before starting again
|
|
t0 = _vlc_time(s)
|
|
while s.get("video_want") == path and abs(_vlc_time(s) - t0) < VIDEO_RESYNC_S:
|
|
await asyncio.sleep(0.2)
|
|
s["video"] = None
|
|
_remove_podd_frame()
|
|
|
|
|
|
async def _video_guard(ctx, path):
|
|
try:
|
|
await _video_frames(ctx, path)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e: # noqa: BLE001 - logged; the next change of video starts again
|
|
_err(ctx, f"video: {e!r}")
|
|
finally:
|
|
_st(ctx)["video"] = None
|
|
|
|
|
|
def _video_follow(ctx, want):
|
|
"""The picture for `want` (the video the assPod's screen shows), or none."""
|
|
s = _st(ctx)
|
|
if s.get("video_want") == want:
|
|
return
|
|
s["video_want"] = want
|
|
task = s.get("video_task")
|
|
if task is not None and not task.done():
|
|
task.cancel()
|
|
s["video_task"] = asyncio.get_running_loop().create_task(_video_guard(ctx, want)) if want else None
|
|
if not want:
|
|
_remove_podd_frame()
|
|
|
|
|
|
# ------------------------------------------------------------------ the poll
|
|
|
|
POLL_JS = "(A.setStatus(%s), A.poll())"
|
|
|
|
|
|
async def _poll_loop(ctx):
|
|
s = _st(ctx)
|
|
while True:
|
|
try:
|
|
report = s["vlc_status"]
|
|
r = await _page(ctx, POLL_JS % json.dumps(report), 5)
|
|
s["polls"] += 1
|
|
if r is None: # the page script is gone (systemui reloaded, flag off)
|
|
s["open"] = False
|
|
if not s["playing"]:
|
|
break
|
|
else:
|
|
if isinstance(r.get("volume"), (int, float)):
|
|
s["want_vol"] = max(0.0, min(1.0, float(r["volume"])))
|
|
if isinstance(r.get("eq"), str):
|
|
s["want_eq"] = r["eq"]
|
|
async with s["lock"]:
|
|
for c in r.get("cmds") or []:
|
|
if isinstance(c, dict):
|
|
try:
|
|
await _vlc_cmd(ctx, c)
|
|
except Exception as e: # noqa: BLE001
|
|
_err(ctx, f"cmd {c.get('op')}: {e!r}")
|
|
was_open = s["open"]
|
|
s["open"], s["playing"] = bool(r.get("open")), bool(r.get("playing"))
|
|
if isinstance(r.get("holds"), dict):
|
|
s["holds"] = r["holds"] # Reposition's places, kept for the next open
|
|
_prefs_note(ctx, r.get("prefs")) # the body and colours you picked, kept across launches
|
|
# podd follows the body: on the hand (hold) or pinned in the room while repositioning (world)
|
|
if s["open"] and s.get("podd") is not None and s["podd"].returncode is None:
|
|
h, w = r.get("hold") or {}, r.get("world")
|
|
model = r.get("model") if r.get("model") in PODD_FINISHES else "video"
|
|
fin = r.get("finish") if r.get("finish") in PODD_FINISHES[model] else PODD_FINISHES[model][0]
|
|
want = (json.dumps(h, sort_keys=True), json.dumps(w, sort_keys=True), model, fin)
|
|
if want != s.get("podd_pose") and h.get("rot") and h.get("centre"):
|
|
s["podd_pose"] = want
|
|
_podd_spec(s, rot=h["rot"], centre=h["centre"], world=w, model=model, finish=fin)
|
|
if s["open"]:
|
|
s["hand"] = r.get("hand")
|
|
if was_open and not s["open"]:
|
|
_log(ctx, f"put away ({r.get('closedBy')})")
|
|
await _bridge(ctx, False)
|
|
await _podd_stop(ctx)
|
|
elif s["open"] and time.monotonic() - s["bridge_at"] > BRIDGE_BEAT_S:
|
|
await _bridge(ctx, True)
|
|
if _vlc_running(s):
|
|
s["vlc_status"] = _vlc_report(s, await _http(ctx, {}))
|
|
_resume_note(s)
|
|
# the video's picture, while the assPod shows it and VLC plays that file
|
|
want = r.get("current") if r is not None and r.get("video") and s["vlc_path"] == r.get("current") else None
|
|
_video_follow(ctx, want)
|
|
# Away and nothing playing: VLC goes (the next open starts it again).
|
|
if not s["open"] and not s["playing"]:
|
|
if s["idle_since"] is None:
|
|
s["idle_since"] = time.monotonic()
|
|
elif time.monotonic() - s["idle_since"] > IDLE_STOP_S:
|
|
async with s["lock"]:
|
|
await _stop_vlc(ctx)
|
|
break
|
|
else:
|
|
s["idle_since"] = None
|
|
await asyncio.sleep(POLL_OPEN_S if s["open"] else POLL_BG_S)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e: # noqa: BLE001 - page reloading; try again shortly
|
|
_err(ctx, f"poll: {e!r}")
|
|
await asyncio.sleep(1.0)
|
|
if not s["open"] and not _vlc_running(s):
|
|
break
|
|
s["poll"] = None
|
|
|
|
|
|
def _ensure_poll(ctx):
|
|
s = _st(ctx)
|
|
if s["poll"] is None or s["poll"].done():
|
|
s["idle_since"] = None
|
|
s["poll"] = asyncio.get_running_loop().create_task(_poll_loop(ctx))
|
|
|
|
|
|
# ------------------------------------------------------------------ the library
|
|
|
|
def _xdg_dir(name, default):
|
|
try:
|
|
with open(os.path.join(HOME, ".config", "user-dirs.dirs"), encoding="utf-8") as f:
|
|
for line in f:
|
|
m = re.match(rf'^XDG_{name}_DIR="(.*)"\s*$', line.strip())
|
|
if m:
|
|
return m.group(1).replace("$HOME", HOME)
|
|
except OSError:
|
|
pass
|
|
return os.path.join(HOME, default)
|
|
|
|
|
|
def _ml_files():
|
|
"""VLC's saved Media Library list (VLC 3: ml.xspf in its data folder): [(path, title)]."""
|
|
out = []
|
|
data = os.environ.get("XDG_DATA_HOME") or os.path.join(HOME, ".local", "share")
|
|
for p in (os.path.join(data, "vlc", "ml.xspf"),
|
|
os.path.join(HOME, ".var", "app", "org.videolan.VLC", "data", "vlc", "ml.xspf")):
|
|
out += _xspf(p)
|
|
return out
|
|
|
|
|
|
def _xspf(path):
|
|
out = []
|
|
try:
|
|
root = ET.parse(path).getroot()
|
|
except (OSError, ET.ParseError):
|
|
return out
|
|
for el in root.iter():
|
|
if el.tag.endswith("track"):
|
|
loc = title = None
|
|
for c in el:
|
|
if c.tag.endswith("location") and c.text:
|
|
loc = c.text.strip()
|
|
elif c.tag.endswith("title") and c.text:
|
|
title = c.text.strip()
|
|
if loc and loc.startswith("file://"):
|
|
out.append((urllib.parse.unquote(urllib.parse.urlparse(loc).path), title))
|
|
return out
|
|
|
|
|
|
def _playlist_file(path):
|
|
"""Paths from a .m3u/.m3u8/.pls/.xspf file (relative entries against its folder)."""
|
|
ext = os.path.splitext(path)[1].lower()
|
|
if ext == ".xspf":
|
|
return [p for p, _ in _xspf(path)]
|
|
out = []
|
|
base = os.path.dirname(path)
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if ext == ".pls":
|
|
m = re.match(r"^File\d+=(.*)$", line)
|
|
line = m.group(1) if m else ""
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("file://"):
|
|
line = urllib.parse.unquote(urllib.parse.urlparse(line).path)
|
|
elif "://" in line:
|
|
continue
|
|
out.append(os.path.normpath(os.path.join(base, line)))
|
|
except OSError:
|
|
pass
|
|
return out
|
|
|
|
|
|
def _walk(top, files, lists):
|
|
top = os.path.realpath(top)
|
|
if not os.path.isdir(top):
|
|
return
|
|
base_depth = top.count(os.sep)
|
|
for d, dirs, names in os.walk(top, followlinks=False):
|
|
dirs[:] = sorted(x for x in dirs if not x.startswith("."))
|
|
if d.count(os.sep) - base_depth >= MAX_DEPTH:
|
|
dirs[:] = []
|
|
for n in sorted(names):
|
|
if n.startswith("."):
|
|
continue
|
|
ext = os.path.splitext(n)[1].lower()
|
|
p = os.path.join(d, n)
|
|
if ext in AUDIO or ext in VIDEO:
|
|
if len(files) < MAX_FILES:
|
|
files.setdefault(p, None)
|
|
elif ext in PLAYLISTS:
|
|
lists.append(p)
|
|
|
|
|
|
# Where each video was left (path -> seconds), kept across restarts. A video picks up there the next
|
|
# time it starts; one watched to within RESUME_END_S of its end starts over.
|
|
RESUME_FILE = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.join(HOME, ".local", "share"),
|
|
"glass-shell", "asspod-resume.json")
|
|
RESUME_MIN_S = 10.0
|
|
RESUME_END_S = 30.0
|
|
RESUME_SAVE_S = 5.0
|
|
|
|
|
|
def _resume(s):
|
|
if s.get("resume") is None:
|
|
try:
|
|
with open(RESUME_FILE, encoding="utf-8") as f:
|
|
d = json.load(f)
|
|
s["resume"] = {k: float(v) for k, v in d.items() if isinstance(k, str) and isinstance(v, (int, float))}
|
|
except (OSError, ValueError):
|
|
s["resume"] = {}
|
|
return s["resume"]
|
|
|
|
|
|
def _resume_save(s, force=False):
|
|
if not s.get("resume_dirty") or (not force and time.monotonic() - s.get("resume_saved", 0) < RESUME_SAVE_S):
|
|
return
|
|
try:
|
|
os.makedirs(os.path.dirname(RESUME_FILE), exist_ok=True)
|
|
with open(RESUME_FILE + ".tmp", "w", encoding="utf-8") as f:
|
|
json.dump(_resume(s), f)
|
|
os.replace(RESUME_FILE + ".tmp", RESUME_FILE)
|
|
s["resume_dirty"] = False
|
|
s["resume_saved"] = time.monotonic()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _resume_note(s):
|
|
"""Follows VLC through a video: where it is, or (near the end) that it was finished."""
|
|
st = s.get("vlc_status") or {}
|
|
path = st.get("path")
|
|
if st.get("state") != "playing" or path not in s.get("video_paths", ()):
|
|
return
|
|
t, length = float(st.get("time") or 0), float(st.get("length") or 0)
|
|
r = _resume(s)
|
|
if length > 0 and t >= length - RESUME_END_S:
|
|
if r.pop(path, None) is not None:
|
|
s["resume_dirty"] = True
|
|
elif t >= RESUME_MIN_S and abs(r.get(path, -1) - t) >= 1:
|
|
r[path] = round(t, 1)
|
|
s["resume_dirty"] = True
|
|
_resume_save(s)
|
|
|
|
|
|
LAB_MEDIA = "/tmp/lgs/asspod-media" # lab only: test media in RAM, scanned like the folders when present
|
|
|
|
|
|
def _sources():
|
|
music = _xdg_dir("MUSIC", "Music")
|
|
videos = _xdg_dir("VIDEOS", "Videos")
|
|
return [d for d in dict.fromkeys([music, videos, LAB_MEDIA]) if os.path.isdir(d)]
|
|
|
|
|
|
def _sig(paths):
|
|
h = hashlib.sha1()
|
|
for p in paths:
|
|
try:
|
|
st = os.stat(p)
|
|
h.update(f"{p}:{st.st_size}:{st.st_mtime_ns}\n".encode("utf-8", "surrogateescape"))
|
|
except OSError:
|
|
pass
|
|
return h.hexdigest()
|
|
|
|
|
|
async def _probe(path):
|
|
try:
|
|
p = await asyncio.create_subprocess_exec(FFPROBE, "-v", "quiet", "-print_format", "json", "-show_format",
|
|
"-show_streams", path, stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.DEVNULL)
|
|
out, _ = await asyncio.wait_for(p.communicate(), 15)
|
|
return json.loads(out.decode("utf-8", "replace") or "{}")
|
|
except (OSError, ValueError, asyncio.TimeoutError):
|
|
return {}
|
|
|
|
|
|
def _tags(j):
|
|
t = {}
|
|
for src in [(j.get("format") or {}).get("tags") or {}] + [(x.get("tags") or {}) for x in j.get("streams") or []]:
|
|
for k, v in src.items():
|
|
t.setdefault(k.lower(), v)
|
|
return t
|
|
|
|
|
|
def _num(v):
|
|
m = re.match(r"^\s*(\d+)", str(v or ""))
|
|
return int(m.group(1)) if m else 0
|
|
|
|
|
|
def _entry(path, j, st):
|
|
"""One file's catalog entry from its ffprobe JSON."""
|
|
t = _tags(j)
|
|
streams = j.get("streams") or []
|
|
has_pic = any((x.get("disposition") or {}).get("attached_pic") for x in streams)
|
|
is_video = any(x.get("codec_type") == "video" and not (x.get("disposition") or {}).get("attached_pic")
|
|
for x in streams) and os.path.splitext(path)[1].lower() in VIDEO
|
|
try:
|
|
dur = float((j.get("format") or {}).get("duration") or 0)
|
|
except ValueError:
|
|
dur = 0.0
|
|
e = {"path": path, "duration": round(dur, 3), "added": int(st.st_mtime), "pic": has_pic,
|
|
"title": t.get("title") or os.path.splitext(os.path.basename(path))[0]}
|
|
if is_video:
|
|
show = t.get("show") or t.get("tvshow") or ""
|
|
genre = (t.get("genre") or "").lower()
|
|
# an episode by its file name (Show.Name.S01E02...): its show is its folder (or the name before SxxEyy)
|
|
ep = EPISODE.search(os.path.basename(path))
|
|
if ep and not show:
|
|
folder = os.path.basename(os.path.dirname(path))
|
|
show = folder if folder.lower() not in ("videos", "video", "tv", "tv shows") else re.sub(r"[._]+", " ", ep.group(1)).strip()
|
|
if ep:
|
|
stem = os.path.splitext(os.path.basename(path))[0]
|
|
if not t.get("title") or t.get("title") == stem or "." in t.get("title", ""):
|
|
# the episode's name, when the file name has one after SxxEyy (before any "(480p ..." or
|
|
# "1080p.BluRay" tags): "S04E01 Mac and Dennis - Manhunters"; else just "S01E01"
|
|
rest = stem[ep.end():]
|
|
rest = re.split(r"[(\[]|\b\d{3,4}p\b|\b(?:bluray|web-?dl|webrip|hdtv|dvd|x26[45]|h\.?26[45])\b", rest, 1, re.I)[0]
|
|
name = re.sub(r"[._]+", " ", rest).strip(" -")
|
|
e["title"] = f"S{int(ep.group(2)):02d}E{int(ep.group(3)):02d}" + (f" {name}" if name else "")
|
|
elif not t.get("title") or t.get("title") == os.path.splitext(os.path.basename(path))[0]:
|
|
# a movie's file name: "Guardians.of.the.Galaxy.2014.1080p.BluRay..." -> "Guardians of the Galaxy (2014)"
|
|
stem = os.path.splitext(os.path.basename(path))[0]
|
|
m = re.match(r"^(.*?)[ ._(\[]+((?:19|20)\d\d)(?:[ ._)\]]|$)", stem)
|
|
name = m.group(1) if m else re.split(r"[(\[]|\b\d{3,4}p\b|\b(?:bluray|web-?dl|webrip|hdtv|dvd|x26[45])\b", stem, 1, re.I)[0]
|
|
name = re.sub(r"[._]+", " ", name).strip(" -")
|
|
if name:
|
|
e["title"] = name + (f" ({m.group(2)})" if m else "")
|
|
if show or t.get("season_number") or t.get("episode_id"):
|
|
cat = "TV Shows"
|
|
elif "podcast" in genre:
|
|
cat = "Video Podcasts"
|
|
elif t.get("artist") and 0 < dur < 900:
|
|
cat = "Music Videos"
|
|
else:
|
|
cat = "Movies"
|
|
e.update(kind="video", category=cat, artist=show or t.get("artist") or "")
|
|
else:
|
|
e.update(kind="song", artist=t.get("artist") or "", albumArtist=t.get("album_artist") or t.get("albumartist") or "",
|
|
album=t.get("album") or "", genre=t.get("genre") or "", composer=t.get("composer") or "",
|
|
trackNo=_num(t.get("track")), discNo=_num(t.get("disc")) or 1, year=_num(t.get("date") or t.get("year")))
|
|
return e
|
|
|
|
|
|
def _cover_beside(path):
|
|
d = os.path.dirname(path)
|
|
try:
|
|
names = os.listdir(d)
|
|
except OSError:
|
|
return None
|
|
best = None
|
|
for n in names:
|
|
stem, ext = os.path.splitext(n)
|
|
if ext.lower() in (".jpg", ".jpeg", ".png", ".webp") and stem.lower() in COVERS:
|
|
if best is None or COVERS.index(stem.lower()) < COVERS.index(os.path.splitext(best)[0].lower()):
|
|
best = n
|
|
return os.path.join(d, best) if best else None
|
|
|
|
|
|
async def _scan(ctx):
|
|
"""The catalog the page's library.load() takes, built from VLC's media library."""
|
|
s = _st(ctx)
|
|
t0 = time.monotonic()
|
|
loop = asyncio.get_running_loop()
|
|
files, lists = {}, []
|
|
ml = await loop.run_in_executor(None, _ml_files)
|
|
for p, _ in ml:
|
|
if os.path.splitext(p)[1].lower() in AUDIO | VIDEO and os.path.isfile(p):
|
|
files.setdefault(p, None)
|
|
sources = await loop.run_in_executor(None, _sources)
|
|
for d in sources:
|
|
await loop.run_in_executor(None, _walk, d, files, lists)
|
|
try:
|
|
with open(CACHE, encoding="utf-8") as f:
|
|
cache = json.load(f)
|
|
except (OSError, ValueError):
|
|
cache = {}
|
|
new_cache, todo = {}, []
|
|
for p in files:
|
|
try:
|
|
st = os.stat(p)
|
|
except OSError:
|
|
continue
|
|
key = f"{CACHE_VERSION}:{st.st_size}:{st.st_mtime_ns}"
|
|
c = cache.get(p)
|
|
if c and c.get("k") == key:
|
|
new_cache[p] = c
|
|
else:
|
|
todo.append((p, st, key))
|
|
sem = asyncio.Semaphore(PROBE_JOBS)
|
|
|
|
async def one(p, st, key):
|
|
async with sem:
|
|
j = await _probe(p)
|
|
new_cache[p] = {"k": key, "e": _entry(p, j, st)}
|
|
|
|
await asyncio.gather(*(one(*x) for x in todo))
|
|
try:
|
|
os.makedirs(os.path.dirname(CACHE), exist_ok=True)
|
|
with open(CACHE + ".tmp", "w", encoding="utf-8") as f:
|
|
json.dump(new_cache, f)
|
|
os.replace(CACHE + ".tmp", CACHE)
|
|
except OSError:
|
|
pass
|
|
tracks, videos, art_src = [], [], {}
|
|
for p in files:
|
|
c = new_cache.get(p)
|
|
if not c:
|
|
continue
|
|
e = dict(c["e"])
|
|
if e.get("kind") == "video":
|
|
e["id"] = _id(p, "v")
|
|
e["art"] = "v:" + e["id"]
|
|
art_src[e["art"]] = ("frame", p, e.get("duration") or 0)
|
|
e.pop("pic", None)
|
|
e.pop("kind", None)
|
|
videos.append(e)
|
|
continue
|
|
e["id"] = _id(p, "t")
|
|
if e.pop("pic", False):
|
|
k = "a:" + _id(p, "p")
|
|
art_src[k] = ("pic", p, 0)
|
|
e["art"] = k
|
|
else:
|
|
cov = _cover_beside(p)
|
|
if cov:
|
|
k = "a:" + _id(cov, "c")
|
|
art_src[k] = ("file", cov, 0)
|
|
e["art"] = k
|
|
e.pop("kind", None)
|
|
tracks.append(e)
|
|
by_path = {e["path"]: e["id"] for e in tracks + videos}
|
|
playlists = []
|
|
if ml:
|
|
ids = [by_path[p] for p, _ in ml if p in by_path]
|
|
if ids:
|
|
playlists.append({"id": "vlc-ml", "title": "VLC Media Library", "items": ids})
|
|
for lp in sorted(lists):
|
|
ids = [by_path[p] for p in await loop.run_in_executor(None, _playlist_file, lp) if p in by_path]
|
|
if ids:
|
|
playlists.append({"id": _id(lp, "l"), "title": os.path.splitext(os.path.basename(lp))[0], "items": ids})
|
|
s["art_src"] = art_src
|
|
s["paths"] = {e["path"] for e in tracks + videos} # the only files VLC is asked to play
|
|
s["video_paths"] = {e["path"] for e in videos}
|
|
_log(ctx, f"library: {len(tracks)} songs, {len(videos)} videos, {len(playlists)} playlists "
|
|
f"({len(todo)} probed, {time.monotonic() - t0:.1f} s)")
|
|
return {"tracks": tracks, "videos": videos, "playlists": playlists, "sources": sources + (["VLC Media Library"] if ml else []),
|
|
"scanned": int(time.time())}
|
|
|
|
|
|
async def _load_library(ctx, force=False):
|
|
s = _st(ctx)
|
|
if s["lib_task"] is not None and not s["lib_task"].done():
|
|
return await s["lib_task"]
|
|
if not force and s["lib"] is not None and time.monotonic() - s["lib_at"] < RESCAN_S:
|
|
return s["lib"]
|
|
s["lib_task"] = asyncio.get_running_loop().create_task(_scan(ctx))
|
|
try:
|
|
s["lib"] = await s["lib_task"]
|
|
s["lib_at"] = time.monotonic()
|
|
finally:
|
|
s["lib_task"] = None
|
|
return s["lib"]
|
|
|
|
|
|
async def _push_library(ctx):
|
|
"""The page's library, when it does not have this catalog yet (a new scan, or systemui reloaded)."""
|
|
s = _st(ctx)
|
|
lib = await _load_library(ctx)
|
|
sig = hashlib.sha1(json.dumps(lib, sort_keys=True).encode()).hexdigest()
|
|
have = await _page(ctx, "A.status().library")
|
|
if have is None:
|
|
return False
|
|
if sig == s["page_lib"] and have.get("tracks") == len(lib["tracks"]) and have.get("videos") == len(lib["videos"]):
|
|
return True
|
|
await _page(ctx, f"A.setLibrary({json.dumps(lib)})", 30)
|
|
s["page_lib"] = sig
|
|
return True
|
|
|
|
|
|
# ------------------------------------------------------------------ art
|
|
|
|
async def _extract(kind, path, dur):
|
|
if kind == "frame":
|
|
at = max(0.0, min(60.0, (dur or 0) * 0.1))
|
|
args = [FFMPEG, "-v", "quiet", "-ss", f"{at:.2f}", "-i", path, "-frames:v", "1",
|
|
"-vf", "scale=320:-2", "-f", "image2", "-c:v", "mjpeg", "-q:v", "5", "pipe:1"]
|
|
else:
|
|
args = [FFMPEG, "-v", "quiet", "-i", path, "-map", "0:v:0", "-frames:v", "1",
|
|
"-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256", "-f", "image2",
|
|
"-c:v", "mjpeg", "-q:v", "4", "pipe:1"]
|
|
try:
|
|
p = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
|
out, _ = await asyncio.wait_for(p.communicate(), 20)
|
|
return out if out[:2] == b"\xff\xd8" else None
|
|
except (OSError, asyncio.TimeoutError):
|
|
return None
|
|
|
|
|
|
async def _send_art(ctx, key):
|
|
s = _st(ctx)
|
|
try:
|
|
data = s["art"].get(key)
|
|
if data is None and key not in s["art"]:
|
|
src = s["art_src"].get(key)
|
|
data = await _extract(*src) if src else None
|
|
s["art"][key] = data
|
|
while len(s["art"]) > ART_MAX:
|
|
s["art"].popitem(last=False)
|
|
b64 = base64.b64encode(data).decode() if data else None
|
|
await _page(ctx, f"A.receiveArt({json.dumps(key)}, {json.dumps(b64)})", 10)
|
|
except Exception as e: # noqa: BLE001
|
|
_err(ctx, f"art {key}: {e!r}")
|
|
finally:
|
|
s["art_jobs"].discard(key)
|
|
|
|
|
|
# ------------------------------------------------------------------ the 3D body
|
|
|
|
def _model_dir():
|
|
"""The model's files, copied to RAM under a folder named for their content: SteamVR keeps a
|
|
render model by its path, so a changed model must come from a new path."""
|
|
h = hashlib.sha1()
|
|
for d, dirs, names in os.walk(MODEL_DIR):
|
|
dirs.sort()
|
|
for n in sorted(names):
|
|
p = os.path.join(d, n)
|
|
st = os.stat(p)
|
|
h.update(f"{os.path.relpath(p, MODEL_DIR)}:{st.st_size}:{st.st_mtime_ns};".encode())
|
|
dst = os.path.join("/tmp/lgs/asspod-model", h.hexdigest()[:12])
|
|
if not os.path.isdir(dst):
|
|
shutil.rmtree("/tmp/lgs/asspod-model", ignore_errors=True)
|
|
shutil.copytree(MODEL_DIR, dst)
|
|
return dst
|
|
|
|
|
|
# ------------------------------------------------------------------ podd (native/podd: the body with real shaders)
|
|
|
|
def _podd_spec(s, **kw):
|
|
spec = dict(s.get("podd_spec") or {"open": False, "hand": "right", "scale": 0.085 / 0.0618, "nanoScale": NANO_SCALE,
|
|
"model": "video", "finish": "black"})
|
|
spec.update(kw)
|
|
s["podd_spec"] = spec
|
|
os.makedirs(os.path.dirname(PODD_SPEC), exist_ok=True)
|
|
with open(PODD_SPEC + ".tmp", "w", encoding="utf-8") as f:
|
|
json.dump(spec, f)
|
|
os.replace(PODD_SPEC + ".tmp", PODD_SPEC)
|
|
|
|
|
|
def _podd_kill_strays():
|
|
"""Ends every podd process of this user (by its binary's path) and waits for them to go."""
|
|
pids = []
|
|
for d in os.listdir("/proc"):
|
|
if not d.isdigit():
|
|
continue
|
|
try:
|
|
exe = os.readlink(f"/proc/{d}/exe")
|
|
except OSError:
|
|
continue
|
|
if exe.replace(" (deleted)", "") == PODD:
|
|
pids.append(int(d))
|
|
for pid in pids:
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
deadline = time.time() + 2
|
|
while pids and time.time() < deadline:
|
|
pids = [p for p in pids if os.path.exists(f"/proc/{p}")]
|
|
time.sleep(0.05)
|
|
|
|
|
|
async def _podd_start(ctx, model_dir, scale, finish, nano_scale=NANO_SCALE, model="video"):
|
|
"""podd up with its layout (podd-out.json), or None (then SteamVR's render models draw the body)."""
|
|
s = _st(ctx)
|
|
if not os.access(PODD, os.X_OK) and os.path.isfile(PODD_PREBUILT):
|
|
# the release's shipped podd (built for the Frame), when none was built here
|
|
try:
|
|
shutil.copyfile(PODD_PREBUILT, PODD)
|
|
os.chmod(PODD, 0o755)
|
|
except OSError as e:
|
|
_err(ctx, f"podd: {e!r}")
|
|
if not os.access(PODD, os.X_OK):
|
|
return None
|
|
_podd_spec(s, open=False, scale=scale, nanoScale=nano_scale, model=model, finish=finish)
|
|
p = s.get("podd")
|
|
# a podd older than its binary (a rebuild) is replaced
|
|
if p is not None and p.returncode is None and os.path.getmtime(PODD) > s.get("podd_t0", 0):
|
|
await _podd_stop(ctx)
|
|
p = None
|
|
if p is None or p.returncode is not None:
|
|
# one left by an earlier copy of this plugin (a reload) holds podd's lock: it goes
|
|
await asyncio.get_running_loop().run_in_executor(None, _podd_kill_strays)
|
|
try:
|
|
os.remove(PODD_OUT)
|
|
except OSError:
|
|
pass
|
|
s["podd_t0"] = time.time()
|
|
s["podd"] = await asyncio.create_subprocess_exec(PODD, "--model", model_dir, "--spec", PODD_SPEC, "--out", PODD_OUT,
|
|
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL, preexec_fn=_pdeathsig)
|
|
for _ in range(40):
|
|
try:
|
|
with open(PODD_OUT, encoding="utf-8") as f:
|
|
out = json.load(f)
|
|
if out.get("ok") and out.get("tiles") and out.get("pid") == s["podd"].pid:
|
|
s["podd_video"] = bool(out.get("video"))
|
|
return {k: out[k] for k in ("key", "texW", "texH", "pad", "box", "tiles", "video") if k in out}
|
|
except (OSError, ValueError):
|
|
pass
|
|
if s["podd"].returncode is not None:
|
|
_err(ctx, f"podd exited ({s['podd'].returncode})")
|
|
return None
|
|
await asyncio.sleep(0.1)
|
|
_err(ctx, "podd gave no layout")
|
|
return None
|
|
|
|
|
|
async def _podd_stop(ctx):
|
|
s = _st(ctx)
|
|
p = s.get("podd")
|
|
s["podd"] = None
|
|
if p is None or p.returncode is not None:
|
|
await asyncio.get_running_loop().run_in_executor(None, _podd_kill_strays)
|
|
return
|
|
try:
|
|
_podd_spec(s, open=False)
|
|
p.terminate()
|
|
await asyncio.wait_for(p.wait(), 3)
|
|
except (ProcessLookupError, asyncio.TimeoutError):
|
|
try:
|
|
p.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
# ------------------------------------------------------------------ the body you picked, kept
|
|
|
|
def _prefs_clean(p):
|
|
"""The model and colours from `p`, each checked; the defaults for anything missing or wrong."""
|
|
p = p if isinstance(p, dict) else {}
|
|
out = dict(PREFS_DEFAULT)
|
|
if p.get("model") in PODD_FINISHES:
|
|
out["model"] = p["model"]
|
|
if p.get("finish") in PODD_FINISHES["video"]:
|
|
out["finish"] = p["finish"]
|
|
if p.get("nanoFinish") in PODD_FINISHES["nano"]:
|
|
out["nanoFinish"] = p["nanoFinish"]
|
|
return out
|
|
|
|
|
|
def _prefs_load(s):
|
|
if s.get("prefs") is None:
|
|
try:
|
|
with open(PREFS, encoding="utf-8") as f:
|
|
s["prefs"] = _prefs_clean(json.load(f))
|
|
except (OSError, ValueError):
|
|
s["prefs"] = dict(PREFS_DEFAULT)
|
|
return s["prefs"]
|
|
|
|
|
|
def _prefs_note(ctx, p):
|
|
"""The page's current model and colours (each poll): saved when they changed."""
|
|
s = _st(ctx)
|
|
if not isinstance(p, dict):
|
|
return
|
|
want = _prefs_clean(p)
|
|
if want == _prefs_load(s):
|
|
return
|
|
s["prefs"] = want
|
|
try:
|
|
os.makedirs(os.path.dirname(PREFS), exist_ok=True)
|
|
with open(PREFS + ".tmp", "w", encoding="utf-8") as f:
|
|
json.dump(want, f)
|
|
os.replace(PREFS + ".tmp", PREFS)
|
|
except OSError as e:
|
|
_err(ctx, f"prefs: {e!r}")
|
|
|
|
|
|
# ------------------------------------------------------------------ actions
|
|
|
|
async def _open(ctx):
|
|
s = _st(ctx)
|
|
st = await _page(ctx, "A.status()")
|
|
if st is None:
|
|
raise RuntimeError("the assPod page script is not installed in SteamVR (systemui)")
|
|
if not st.get("open"):
|
|
model_dir = _model_dir()
|
|
prefs = _prefs_load(s)
|
|
model = prefs["model"]
|
|
podd = await _podd_start(ctx, model_dir, st.get("bodyScale") or 0.085 / 0.0618,
|
|
prefs["nanoFinish"] if model == "nano" else prefs["finish"],
|
|
st.get("nanoScale") or NANO_SCALE, model)
|
|
r = await _page(ctx, f"A.open({json.dumps({'modelDir': model_dir, 'podd': podd, 'holds': s.get('holds'), 'resume': _resume(s), 'prefs': prefs})})")
|
|
if not r or not r.get("open"):
|
|
await _podd_stop(ctx)
|
|
raise RuntimeError(f"open failed: {(r or {}).get('error')}")
|
|
st = r
|
|
hold = st.get("hold") or {}
|
|
if podd and hold.get("rot") and hold.get("centre"):
|
|
_podd_spec(s, open=True, hand=st.get("hand") or "right", rot=hold["rot"], centre=hold["centre"], world=None)
|
|
s["podd_pose"] = None
|
|
s["open"], s["hand"] = True, st.get("hand")
|
|
await _bridge(ctx, True)
|
|
_ensure_poll(ctx)
|
|
# VLC (the boot logo shows while it starts) and the library, together
|
|
vlc_ok, _ = await asyncio.gather(_start_vlc(ctx), _push_library(ctx))
|
|
_log(ctx, f"open in the {s['hand']} hand (vlc {'up' if vlc_ok else 'down'})")
|
|
return {"open": True, "hand": s["hand"], "vlc": bool(vlc_ok),
|
|
"library": {"tracks": len((s["lib"] or {}).get("tracks", [])), "videos": len((s["lib"] or {}).get("videos", []))}}
|
|
|
|
|
|
async def run(ctx, atype, args):
|
|
s = _st(ctx)
|
|
if atype == "asspod.open":
|
|
return await _open(ctx)
|
|
if atype == "asspod.close":
|
|
r = await _page(ctx, "A.close()")
|
|
s["open"] = False
|
|
await _bridge(ctx, False)
|
|
await _podd_stop(ctx)
|
|
return {"open": False, "page": bool(r)}
|
|
if atype == "asspod.btn":
|
|
r = await _page(ctx, f"A.btn({json.dumps(args['b'])}, {json.dumps(bool(args['down']))}, {json.dumps(args['side'])})", 3)
|
|
return {"handled": bool(r)} # a close is noticed by the poll, which tells Steam
|
|
if atype == "asspod.status":
|
|
page = None
|
|
try:
|
|
page = await _page(ctx, "A.status()")
|
|
except Exception as e: # noqa: BLE001
|
|
page = {"error": repr(e)}
|
|
return {"vlc": {"running": _vlc_running(s), "pid": s["vlc"].pid if _vlc_running(s) else None, "state": s["vlc_state"],
|
|
"path": s["vlc_path"]}, "open": s["open"], "hand": s["hand"], "playing": s["playing"],
|
|
"polling": s["poll"] is not None and not s["poll"].done(), "polls": s["polls"],
|
|
"library": {"tracks": len((s["lib"] or {}).get("tracks", [])), "videos": len((s["lib"] or {}).get("videos", []))},
|
|
"video": {"raw": bool(_podd_video(s)), "sent": s.get("video_sent", 0), "skipped": s.get("video_skipped", 0),
|
|
"resyncs": s.get("video_resyncs", 0), "want": s.get("video_want"),
|
|
"task": None if s.get("video_task") is None else ("done" if s["video_task"].done() else "running"),
|
|
"stage": s.get("video_stage"), "clock": round(_vlc_time(s), 2)},
|
|
"page": page, "log": list(s["log"]), "errors": list(s["errors"])}
|
|
raise ValueError(atype)
|
|
|
|
|
|
async def tick(ctx):
|
|
"""Once a second: a VLC that died is forgotten; a playing VLC is always being polled."""
|
|
s = _st(ctx)
|
|
if s["vlc"] is not None and s["vlc"].returncode is not None:
|
|
_err(ctx, f"vlc exited ({s['vlc'].returncode})")
|
|
s["vlc"], s["port"], s["vlc_path"], s["vlc_state"] = None, 0, None, "stopped"
|
|
if _vlc_running(s) or s["open"]:
|
|
_ensure_poll(ctx)
|
|
|
|
|
|
async def stop(ctx):
|
|
"""Teardown (theme off, daemon exit, plugin reload): the assPod away, VLC gone, Steam's input back."""
|
|
s = _st(ctx)
|
|
if s["poll"] is not None:
|
|
s["poll"].cancel()
|
|
try:
|
|
await s["poll"]
|
|
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
pass
|
|
s["poll"] = None
|
|
try:
|
|
await _page(ctx, "A.close()", 3)
|
|
except Exception: # noqa: BLE001 - page gone
|
|
pass
|
|
_video_follow(ctx, None)
|
|
_resume_save(s, force=True)
|
|
await _stop_vlc(ctx)
|
|
await _podd_stop(ctx)
|
|
s["open"] = False
|
|
await _bridge(ctx, False)
|